{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "09454356",
   "metadata": {
    "id": "09454356"
   },
   "source": [
    "# Proyecto final: dataset real $4\\times 4$ → QUBO → QAOA local y hardware opcional\n",
    "\n",
    "Qubit.mx - QMexico Summer School 2026.\n",
    "\n",
    "El objetivo del proyecto es transformar un problema real o semi-real bien justificado en un problema de **matching bipartito**, formularlo como **QUBO** y resolver una instancia pequeña con **QAOA local**. La ejecución en hardware real de IBM Quantum es opcional y avanzada.\n",
    "\n",
    "La calificación está cargada hacia el modelado de datos:\n",
    "\n",
    "- **≈80%**: encontrar, justificar y documentar un dataset real o semi-real de tamaño $4\\times 4$.\n",
    "- **≈20%**: implementar correctamente el pipeline `QUBO → validación clásica → QAOA local`; la comparación con hardware real solo cuenta como extensión avanzada si fue autorizada.\n",
    "\n",
    "La instancia molecular incluida aquí es solo un **ejemplo educativo** para probar el código. Para que el proyecto final cuente y pueda evaluarse en el curso, cada equipo debe sustituirla por su propio dataset real o semi-real documentado."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Entorno de ejecución en Google Colab\n",
    "\n",
    "Ejecuten la siguiente celda al iniciar la sesión. Instala las librerías científicas usadas en el pipeline local y las librerías de Qiskit necesarias para la sección avanzada de IBM Quantum.\n",
    "\n",
    "La parte base del proyecto no requiere cuenta de IBM Quantum. Qiskit se instala para evitar errores de importación si el equipo decide ejecutar la sección opcional de hardware real."
   ],
   "id": "1642ffc1"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%capture\n",
    "%pip -q install numpy pandas scipy matplotlib qiskit qiskit-aer qiskit-ibm-runtime pylatexenc"
   ],
   "id": "74f6d098"
  },
  {
   "cell_type": "markdown",
   "id": "4fca71bd",
   "metadata": {
    "id": "4fca71bd"
   },
   "source": [
    "## 0. Lectura operativa de la rúbrica\n",
    "\n",
    "Este proyecto no evalúa únicamente si el código corre. Evalúa si el equipo puede hacer una traducción razonada:\n",
    "\n",
    "```text\n",
    "datos reales → entidades A y B → score S_ij → variables binarias x_ij → restricciones → QUBO → solución\n",
    "```\n",
    "\n",
    "La pregunta central no es “¿qué dataset encontré?”, sino:\n",
    "\n",
    "> ¿Por qué este dataset puede modelarse de forma honesta como un matching bipartito 4×4?"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a81287cf",
   "metadata": {
    "id": "a81287cf"
   },
   "source": [
    "## 1. Modelo matemático mínimo\n",
    "\n",
    "Sean dos conjuntos de tamaño 4:\n",
    "\n",
    "$$\n",
    "A = \\{a_1,a_2,a_3,a_4\\}, \\qquad\n",
    "B = \\{b_1,b_2,b_3,b_4\\}.\n",
    "$$\n",
    "\n",
    "Cada variable binaria indica si se elige un emparejamiento entre $a_i$ y $b_j$:\n",
    "\n",
    "$$\n",
    "x_{ij}=\\begin{cases}\n",
    "1, & \\text{si } a_i \\text{ se asigna a } b_j,\\\\\n",
    "0, & \\text{en otro caso.}\n",
    "\\end{cases}\n",
    "$$\n",
    "\n",
    "La matriz $S\\in\\mathbb{R}^{4\\times 4}$ contiene el beneficio, compatibilidad o score de cada posible match:\n",
    "\n",
    "$$\n",
    "S_{ij}=\\text{score de asignar } a_i \\text{ con } b_j.\n",
    "$$"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "48016d70",
   "metadata": {
    "id": "48016d70"
   },
   "source": [
    "## 2. Objetivo y restricciones\n",
    "\n",
    "El problema de asignación uno-a-uno busca maximizar el score total:\n",
    "\n",
    "$$\n",
    "\\max_x \\sum_{i=1}^{4}\\sum_{j=1}^{4} S_{ij}x_{ij}.\n",
    "$$\n",
    "\n",
    "Restricción por filas: cada elemento de $A$ se asigna exactamente una vez.\n",
    "\n",
    "$$\n",
    "\\sum_{j=1}^{4} x_{ij}=1 \\qquad \\forall i\\in\\{1,2,3,4\\}.\n",
    "$$\n",
    "\n",
    "Restricción por columnas: cada elemento de $B$ recibe exactamente una asignación.\n",
    "\n",
    "$$\n",
    "\\sum_{i=1}^{4} x_{ij}=1 \\qquad \\forall j\\in\\{1,2,3,4\\}.\n",
    "$$"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0203f624",
   "metadata": {
    "id": "0203f624"
   },
   "source": [
    "## 3. Formulación QUBO\n",
    "\n",
    "Un QUBO es una minimización cuadrática binaria. Convertimos el problema anterior en:\n",
    "\n",
    "$$\n",
    "E(x)=\n",
    "-\\sum_{i=1}^{4}\\sum_{j=1}^{4} S_{ij}x_{ij}\n",
    "+\\lambda_A\\sum_{i=1}^{4}\\left(\\sum_{j=1}^{4} x_{ij}-1\\right)^2\n",
    "+\\lambda_B\\sum_{j=1}^{4}\\left(\\sum_{i=1}^{4} x_{ij}-1\\right)^2.\n",
    "$$\n",
    "\n",
    "El primer término premia scores altos. Los términos con $\\lambda_A$ y $\\lambda_B$ penalizan violaciones de las restricciones.\n",
    "\n",
    "Si las penalizaciones son suficientemente grandes, el mínimo del QUBO corresponde a una asignación factible de alto score."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "24428bae",
   "metadata": {
    "id": "24428bae"
   },
   "source": [
    "# Parte A — Búsqueda y justificación del dataset\n",
    "\n",
    "Esta parte vale aproximadamente el 80% de la nota. Debe quedar documentada antes de ejecutar QAOA."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "dc977adc",
   "metadata": {
    "id": "dc977adc"
   },
   "source": [
    "## 4. Criterios de un dataset apto para QUBO\n",
    "\n",
    "Un dataset es apto si permite contestar claramente estas preguntas:\n",
    "\n",
    "| Criterio | Pregunta que debe responder el equipo |\n",
    "|---|---|\n",
    "| Dos lados identificables | ¿Qué será $A$ y qué será $B$? |\n",
    "| Tamaño reducible a $4\\times 4$ | ¿Cómo se seleccionan exactamente 4 elementos de cada lado? |\n",
    "| Score justificable | ¿Cómo se calcula $S_{ij}$ con columnas observables o reglas explícitas? |\n",
    "| Decisión binaria | ¿Qué significa $x_{ij}=1$? |\n",
    "| Restricciones claras | ¿Cada elemento se usa una vez, a lo más una vez, o hay capacidades? |\n",
    "| Fuente legítima | ¿La fuente, institución, URL, licencia y fecha de consulta están documentadas? |\n",
    "| Riesgo ético controlado | ¿No se usan datos personales sensibles ni se simulan decisiones reales de alto impacto? |\n",
    "\n",
    "Un dataset no es apto si solo contiene una serie temporal sin entidades asignables, si no tiene una fuente verificable, si no permite construir un score $S_{ij}$, o si requiere datos personales identificables."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3b46b7ea",
   "metadata": {
    "id": "3b46b7ea"
   },
   "source": [
    "## 5. Portales mexicanos recomendados\n",
    "\n",
    "Estos portales son puntos de partida. El equipo debe revisar fuente, licencia, campos disponibles, granularidad, fecha de actualización y riesgos éticos.\n",
    "\n",
    "| Portal | Qué buscar | Posible $A$ vs $B$ | Ejemplo de score $S_{ij}$ |\n",
    "|---|---|---|---|\n",
    "| [Plataforma Nacional de Datos Abiertos](https://www.datos.gob.mx/) | Bases federales por institución, categoría, CSV o API. | Municipios vs programas; unidades públicas vs servicios; regiones vs recursos. | Normalización de demanda, cobertura, distancia, volumen o prioridad. |\n",
    "| [CENATRA / Secretaría de Salud](https://www.gob.mx/cenatra) | Tabulados o estadísticas públicas agregadas de donación y trasplantes. | Tipos de órgano, entidades, hospitales o unidades agregadas. | Compatibilidad agregada o volumen histórico, nunca datos personales. |\n",
    "| [INEGI Banco de Indicadores / API](https://www.inegi.org.mx/servicios/api_indicadores.html) | Indicadores por país, entidad o municipio. | Municipios vs programas; entidades vs sectores; zonas vs necesidades. | Score de necesidad, rezago, cobertura o prioridad territorial. |\n",
    "| [SNIIV / SEDATU](https://sniiv.sedatu.gob.mx/) | Vivienda, rezago habitacional, financiamientos, inventario y subsidios. | Zonas o municipios vs tipos de vivienda, subsidios o programas. | Ajuste entre demanda, rezago, inventario y disponibilidad. |\n",
    "| [Datos Abiertos CDMX](https://datos.cdmx.gob.mx/) | Servicios urbanos, atención ciudadana, movilidad, seguridad, cultura. | Alcaldías o colonias vs servicios, cuadrillas, recursos o programas. | Prioridad basada en solicitudes, rezago, cobertura y severidad. |\n",
    "| [Data México](https://www.economia.gob.mx/datamexico/) | Empleo, economía, ocupaciones, industria, comercio y territorio. | Ocupaciones vs sectores; municipios vs industrias; perfiles vs regiones. | Compatibilidad entre habilidades, demanda, salario y crecimiento. |\n",
    "\n",
    "El objetivo no es descargar una base grande. El objetivo es justificar una **subinstancia $4\\times 4$** extraída con reglas claras."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4d20c427",
   "metadata": {
    "id": "4d20c427"
   },
   "source": [
    "## 6. Dominios aplicables en México\n",
    "\n",
    "**Salud y trasplantes.** Posible modelado: $A=$ unidades agregadas, tipos de órgano o perfiles sintéticos; $B=$ centros, regiones o categorías agregadas. Debe evitarse cualquier dato de pacientes reales. No se debe presentar como recomendación clínica.\n",
    "\n",
    "**Vivienda.** Posible modelado: $A=$ municipios o zonas; $B=$ programas, tipos de vivienda o subsidios. El score puede combinar rezago habitacional, inventario, financiamiento y cobertura.\n",
    "\n",
    "**Empleo.** Posible modelado: $A=$ ocupaciones o perfiles de habilidades; $B=$ sectores, regiones o industrias. El score puede combinar demanda, salario, crecimiento y compatibilidad de habilidades.\n",
    "\n",
    "**Servicios urbanos.** Posible modelado: $A=$ alcaldías o colonias; $B=$ servicios, cuadrillas o recursos. El score puede combinar solicitudes, severidad, rezago y tiempo de respuesta.\n",
    "\n",
    "**Educación.** Posible modelado: $A=$ escuelas, municipios o programas; $B=$ becas, apoyos o recursos. El score puede combinar matrícula, rezago, cobertura y prioridad social."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3743c328",
   "metadata": {
    "id": "3743c328"
   },
   "source": [
    "## 7. Cómo convertir un dataset real en una matriz $4\\times 4$\n",
    "\n",
    "Procedimiento mínimo recomendado:\n",
    "\n",
    "1. Elegir un dominio y una fuente oficial.\n",
    "2. Definir qué entidades serán $A$ y qué entidades serán $B$.\n",
    "3. Filtrar exactamente 4 elementos de cada lado con una regla reproducible.\n",
    "4. Definir $x_{ij}=1$ en lenguaje del dominio.\n",
    "5. Construir $S_{ij}$ con una fórmula explícita.\n",
    "6. Normalizar las columnas usadas para que ninguna domine artificialmente.\n",
    "7. Documentar restricciones: uno-a-uno, a lo más uno, capacidades o exclusiones.\n",
    "8. Auditar riesgos éticos y sesgos.\n",
    "\n",
    "Ejemplo de score semi-real:\n",
    "\n",
    "$$\n",
    "S_{ij}=0.5\\,z(\\mathrm{demanda}_{ij})+0.3\\,z(\\mathrm{cobertura}_{ij})-0.2\\,z(\\mathrm{distancia}_{ij}).\n",
    "$$\n",
    "\n",
    "Donde $z(\\cdot)$ puede ser una normalización min-max o una estandarización."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "49d351e7",
   "metadata": {
    "id": "49d351e7"
   },
   "source": [
    "## 8. Formato obligatorio de entrega: repositorio GitHub\n",
    "\n",
    "La entrega es un enlace a un repositorio de GitHub creado desde el perfil del estudiante o del equipo. Si el repositorio es privado, debe compartirse con el docente antes de la fecha límite.\n",
    "\n",
    "Estructura mínima del repositorio:\n",
    "\n",
    "```text\n",
    "nombre-del-repositorio/\n",
    "├── data/\n",
    "│   └── dataset_real_4x4.csv\n",
    "├── README.md\n",
    "└── proyecto_qubo_qaoa.ipynb\n",
    "```\n",
    "\n",
    "Requisitos obligatorios:\n",
    "\n",
    "1. La carpeta `data/` debe contener el CSV real o semi-real usado por el equipo.\n",
    "2. El archivo `README.md` debe justificar el dataset, explicar la formulación QUBO y reportar la comparación entre la solución clásica y QAOA.\n",
    "3. El archivo `.ipynb` debe poder abrirse en Google Colab y ejecutar todas las celdas en orden con `Runtime → Run all` sin errores intermedios.\n",
    "4. La instancia molecular de ejemplo no cuenta como dataset del proyecto final. Puede conservarse como respaldo, pero la entrega evaluable debe usar el CSV del equipo.\n",
    "5. Si el archivo `.ipynb` se abre directamente desde GitHub en Colab, el equipo debe asegurarse de que el CSV pueda leerse sin intervención manual, por ejemplo mediante una URL raw del archivo `data/dataset_real_4x4.csv` o mediante una celda clara de clonación del repositorio.\n",
    "6. No se deben subir tokens personales, credenciales, archivos privados ni datos sensibles.\n",
    "\n",
    "Contenido mínimo del `README.md`:\n",
    "\n",
    "```text\n",
    "# Proyecto QUBO-QAOA: matching bipartito 4x4\n",
    "\n",
    "## Dataset\n",
    "Nombre del dataset:\n",
    "Fuente oficial o confiable:\n",
    "Institución responsable:\n",
    "URL de la fuente:\n",
    "URL raw del CSV usado en data/:\n",
    "Licencia o condiciones de uso:\n",
    "Fecha de consulta:\n",
    "Dominio del problema:\n",
    "\n",
    "## Modelado\n",
    "Conjunto A:\n",
    "Criterio para elegir exactamente 4 elementos de A:\n",
    "Conjunto B:\n",
    "Criterio para elegir exactamente 4 elementos de B:\n",
    "Definición de x_ij = 1:\n",
    "Interpretación de x_ij = 0:\n",
    "\n",
    "## Matriz de score\n",
    "Columnas usadas:\n",
    "Fórmula exacta de S_ij:\n",
    "Normalización aplicada:\n",
    "Matriz S 4x4:\n",
    "\n",
    "## Restricciones\n",
    "Restricción por filas:\n",
    "Restricción por columnas:\n",
    "Otras restricciones, si existen:\n",
    "Justificación de por qué el problema es matching bipartito:\n",
    "Justificación de por qué es razonable modelarlo como QUBO:\n",
    "\n",
    "## Resultados\n",
    "Solución clásica exacta:\n",
    "Resultado QAOA local:\n",
    "Comparación clásico vs QAOA local:\n",
    "Si se usó hardware real o pipeline híbrido, comparación adicional:\n",
    "\n",
    "## Ética y limitaciones\n",
    "Riesgos éticos:\n",
    "Medidas de mitigación:\n",
    "Limitaciones del modelo:\n",
    "\n",
    "## Ejecución\n",
    "Instrucciones para abrir el archivo .ipynb en Google Colab:\n",
    "Instrucciones para ejecutar todas las celdas sin errores:\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6fea4917",
   "metadata": {
    "id": "6fea4917"
   },
   "source": [
    "## 9. Advertencia ética\n",
    "\n",
    "Este proyecto es educativo. No debe usarse para tomar decisiones reales sobre salud, trasplantes, vivienda, empleo, seguridad, becas, apoyos públicos ni asignación de recursos.\n",
    "\n",
    "Reglas mínimas:\n",
    "\n",
    "- No usar nombres, CURP, expedientes, domicilios, teléfonos ni identificadores personales.\n",
    "- No usar datos sensibles individuales.\n",
    "- Preferir datos agregados por municipio, entidad, institución o categoría.\n",
    "- Explicar sesgos potenciales del dataset.\n",
    "- No presentar la salida de QAOA como recomendación normativa.\n",
    "- Si el dominio es de alto impacto, usar perfiles sintéticos o agregados."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e034ceb3",
   "metadata": {
    "id": "e034ceb3"
   },
   "source": [
    "# Parte B — Pipeline computacional local\n",
    "\n",
    "Esta parte vale aproximadamente el 20% de la nota. Debe ejecutarse localmente en Google Colab sin saturar memoria.\n",
    "\n",
    "La implementación local usa simulación vectorial de QAOA con $2^{16}=65\\,536$ amplitudes complejas. Esto es pequeño para Colab."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "385d31b9",
   "metadata": {
    "id": "385d31b9"
   },
   "source": [
    "## 11. Importaciones y configuración\n",
    "\n",
    "Objetivo: cargar librerías y fijar una semilla reproducible."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f699c5cd",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:34.532426Z",
     "iopub.status.busy": "2026-06-19T14:35:34.532138Z",
     "iopub.status.idle": "2026-06-19T14:35:35.966308Z",
     "shell.execute_reply": "2026-06-19T14:35:35.965081Z"
    },
    "id": "f699c5cd"
   },
   "outputs": [],
   "source": [
    "from collections import Counter\n",
    "from itertools import permutations\n",
    "from pathlib import Path\n",
    "from typing import Any\n",
    "import math\n",
    "\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "import matplotlib.pyplot as plt\n",
    "from scipy.optimize import minimize, linear_sum_assignment\n",
    "from IPython.display import display\n",
    "\n",
    "SEED = 2026\n",
    "rng = np.random.default_rng(SEED)\n",
    "\n",
    "N_A = 4\n",
    "N_B = 4\n",
    "N_VARS = N_A * N_B"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ab367a0e",
   "metadata": {
    "id": "ab367a0e"
   },
   "source": [
    "## 12. Dataset molecular educativo $4\\times 4$\n",
    "\n",
    "Esta instancia molecular permite verificar que el pipeline computacional funciona de inicio a fin.\n",
    "\n",
    "Debe quedar claro en el reporte: **la molécula no es el dataset del proyecto final**. El equipo debe reemplazar `A_df`, `B_df` y `S` por una instancia $4\\times 4$ construida a partir de su dataset real o semi-real. Si entregan únicamente la molécula de ejemplo, la parte central del proyecto no queda validada."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "823c0cf2",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/",
     "height": 206
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:35.969102Z",
     "iopub.status.busy": "2026-06-19T14:35:35.968779Z",
     "iopub.status.idle": "2026-06-19T14:35:35.985771Z",
     "shell.execute_reply": "2026-06-19T14:35:35.984647Z"
    },
    "id": "823c0cf2",
    "outputId": "203afbfe-14df-4670-bb69-62655ca4975f"
   },
   "outputs": [],
   "source": [
    "A_df = pd.DataFrame([\n",
    "    {\"id\": \"A1\", \"nombre\": \"Lys / NH3+\", \"charge\": +1, \"donor\": 1, \"acceptor\": 0, \"hydrophobic\": 0, \"aromatic\": 0, \"polar\": 1, \"size\": 2.0},\n",
    "    {\"id\": \"A2\", \"nombre\": \"Asp / COO-\", \"charge\": -1, \"donor\": 0, \"acceptor\": 1, \"hydrophobic\": 0, \"aromatic\": 0, \"polar\": 1, \"size\": 2.0},\n",
    "    {\"id\": \"A3\", \"nombre\": \"Phe / anillo\", \"charge\": 0, \"donor\": 0, \"acceptor\": 0, \"hydrophobic\": 1, \"aromatic\": 1, \"polar\": 0, \"size\": 3.0},\n",
    "    {\"id\": \"A4\", \"nombre\": \"Ser / OH\", \"charge\": 0, \"donor\": 1, \"acceptor\": 1, \"hydrophobic\": 0, \"aromatic\": 0, \"polar\": 1, \"size\": 1.0},\n",
    "]).set_index(\"id\")\n",
    "\n",
    "display(A_df)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f7a2e136",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/",
     "height": 206
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:35.988361Z",
     "iopub.status.busy": "2026-06-19T14:35:35.987835Z",
     "iopub.status.idle": "2026-06-19T14:35:36.002294Z",
     "shell.execute_reply": "2026-06-19T14:35:36.000705Z"
    },
    "id": "f7a2e136",
    "outputId": "44abde30-4b06-4dcf-a034-9470a97d9019"
   },
   "outputs": [],
   "source": [
    "B_df = pd.DataFrame([\n",
    "    {\"id\": \"B1\", \"nombre\": \"pocket ácido\", \"charge\": -1, \"donor\": 0, \"acceptor\": 1, \"hydrophobic\": 0, \"aromatic\": 0, \"polar\": 1, \"size\": 2.0},\n",
    "    {\"id\": \"B2\", \"nombre\": \"pocket catiónico\", \"charge\": +1, \"donor\": 1, \"acceptor\": 0, \"hydrophobic\": 0, \"aromatic\": 0, \"polar\": 1, \"size\": 2.0},\n",
    "    {\"id\": \"B3\", \"nombre\": \"pocket aromático\", \"charge\": 0, \"donor\": 0, \"acceptor\": 0, \"hydrophobic\": 1, \"aromatic\": 1, \"polar\": 0, \"size\": 3.0},\n",
    "    {\"id\": \"B4\", \"nombre\": \"pocket polar pequeño\", \"charge\": 0, \"donor\": 1, \"acceptor\": 1, \"hydrophobic\": 0, \"aromatic\": 0, \"polar\": 1, \"size\": 1.0},\n",
    "]).set_index(\"id\")\n",
    "\n",
    "display(B_df)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1c9b146f",
   "metadata": {
    "id": "1c9b146f"
   },
   "source": [
    "## 13. Regla de score molecular\n",
    "\n",
    "Objetivo: construir $S_{ij}$ con una regla transparente.\n",
    "\n",
    "La regla premia cargas opuestas, compatibilidad donor-acceptor, similitud hidrofóbica, aromaticidad y polaridad. Penaliza diferencias de tamaño."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7f36f715",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.005553Z",
     "iopub.status.busy": "2026-06-19T14:35:36.005356Z",
     "iopub.status.idle": "2026-06-19T14:35:36.010983Z",
     "shell.execute_reply": "2026-06-19T14:35:36.010180Z"
    },
    "id": "7f36f715"
   },
   "outputs": [],
   "source": [
    "def molecular_score(a: pd.Series, b: pd.Series) -> float:\n",
    "    score = 0.0\n",
    "\n",
    "    charge_product = a[\"charge\"] * b[\"charge\"]\n",
    "    if charge_product == -1:\n",
    "        score += 4.0\n",
    "    elif charge_product == 1:\n",
    "        score -= 4.0\n",
    "\n",
    "    hbond = (a[\"donor\"] and b[\"acceptor\"]) or (a[\"acceptor\"] and b[\"donor\"])\n",
    "    if hbond:\n",
    "        score += 2.5\n",
    "\n",
    "    if a[\"hydrophobic\"] and b[\"hydrophobic\"]:\n",
    "        score += 2.0\n",
    "\n",
    "    if a[\"aromatic\"] and b[\"aromatic\"]:\n",
    "        score += 1.5\n",
    "\n",
    "    if a[\"polar\"] and b[\"polar\"]:\n",
    "        score += 1.0\n",
    "\n",
    "    score -= 0.5 * abs(a[\"size\"] - b[\"size\"])\n",
    "    return float(score)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "953cc7d4",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/",
     "height": 206
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.013602Z",
     "iopub.status.busy": "2026-06-19T14:35:36.013386Z",
     "iopub.status.idle": "2026-06-19T14:35:36.026322Z",
     "shell.execute_reply": "2026-06-19T14:35:36.025172Z"
    },
    "id": "953cc7d4",
    "outputId": "ab90adfb-f31e-435b-e47e-4a10501b26bf"
   },
   "outputs": [],
   "source": [
    "S = np.zeros((N_A, N_B), dtype=float)\n",
    "\n",
    "for i, (_, a) in enumerate(A_df.iterrows()):\n",
    "    for j, (_, b) in enumerate(B_df.iterrows()):\n",
    "        S[i, j] = molecular_score(a, b)\n",
    "\n",
    "S_df = pd.DataFrame(S, index=A_df.index, columns=B_df.index)\n",
    "display(S_df.round(2))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b82f9538",
   "metadata": {
    "id": "b82f9538"
   },
   "source": [
    "## 14. Punto de reemplazo para el dataset del equipo\n",
    "\n",
    "Esta es la sección donde cada equipo debe sustituir el ejemplo molecular por su dataset.\n",
    "\n",
    "Deben mantener tres objetos con el mismo formato:\n",
    "\n",
    "- `A_df`: tabla con exactamente 4 elementos del lado $A$.\n",
    "- `B_df`: tabla con exactamente 4 elementos del lado $B$.\n",
    "- `S`: matriz numérica $4\\times 4$ con el score $S_{ij}$.\n",
    "\n",
    "La carga recomendada para la entrega final es leer el CSV desde `data/dataset_real_4x4.csv`. El formato más simple es una tabla larga con estas columnas:\n",
    "\n",
    "```text\n",
    "a_id,b_id,score\n",
    "A1,B1,7.5\n",
    "A1,B2,3.0\n",
    "...\n",
    "A4,B4,8.0\n",
    "```\n",
    "\n",
    "También pueden agregar columnas opcionales como `a_nombre` y `b_nombre` para mostrar etiquetas más legibles.\n",
    "\n",
    "La matriz `S` debe salir de una fórmula justificada en el `README.md`, no de valores arbitrarios. Para que el proyecto final sea contado y validado para el curso, esta sección debe cargar o definir la instancia construida a partir del dataset real o semi-real del equipo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "19fc2930",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.028583Z",
     "iopub.status.busy": "2026-06-19T14:35:36.028434Z",
     "iopub.status.idle": "2026-06-19T14:35:36.032448Z",
     "shell.execute_reply": "2026-06-19T14:35:36.031612Z"
    },
    "id": "19fc2930"
   },
   "outputs": [],
   "source": [
    "# Carga recomendada para la entrega final.\n",
    "# Si existe data/dataset_real_4x4.csv, se carga automáticamente.\n",
    "# Si el archivo .ipynb se abre directamente desde GitHub en Colab, pueden pegar la URL raw del CSV.\n",
    "\n",
    "DATASET_CSV_PATH = Path(\"data/dataset_real_4x4.csv\")\n",
    "DATASET_CSV_URL = \"\"  #@param {type:\"string\"}\n",
    "\n",
    "CARGAR_DATASET_REAL = DATASET_CSV_PATH.exists() or bool(DATASET_CSV_URL.strip())\n",
    "\n",
    "if CARGAR_DATASET_REAL:\n",
    "    if DATASET_CSV_PATH.exists():\n",
    "        raw_dataset_df = pd.read_csv(DATASET_CSV_PATH)\n",
    "        dataset_source = str(DATASET_CSV_PATH)\n",
    "    else:\n",
    "        raw_dataset_df = pd.read_csv(DATASET_CSV_URL.strip())\n",
    "        dataset_source = DATASET_CSV_URL.strip()\n",
    "\n",
    "    required_columns = {\"a_id\", \"b_id\", \"score\"}\n",
    "    missing = required_columns - set(raw_dataset_df.columns)\n",
    "    if missing:\n",
    "        raise ValueError(f\"El CSV debe contener las columnas {required_columns}. Faltan: {missing}\")\n",
    "\n",
    "    raw_dataset_df = raw_dataset_df.copy()\n",
    "    raw_dataset_df[\"a_id\"] = raw_dataset_df[\"a_id\"].astype(str)\n",
    "    raw_dataset_df[\"b_id\"] = raw_dataset_df[\"b_id\"].astype(str)\n",
    "    raw_dataset_df[\"score\"] = pd.to_numeric(raw_dataset_df[\"score\"], errors=\"raise\")\n",
    "\n",
    "    if raw_dataset_df.duplicated(subset=[\"a_id\", \"b_id\"]).any():\n",
    "        raise ValueError(\"El CSV tiene pares duplicados (a_id, b_id). Debe haber exactamente un score por par.\")\n",
    "\n",
    "    a_values = list(pd.unique(raw_dataset_df[\"a_id\"]))\n",
    "    b_values = list(pd.unique(raw_dataset_df[\"b_id\"]))\n",
    "\n",
    "    if len(a_values) != 4 or len(b_values) != 4:\n",
    "        raise ValueError(\"El CSV debe definir exactamente 4 valores únicos de a_id y 4 valores únicos de b_id.\")\n",
    "\n",
    "    S_loaded_df = raw_dataset_df.pivot(index=\"a_id\", columns=\"b_id\", values=\"score\").loc[a_values, b_values]\n",
    "    if S_loaded_df.shape != (4, 4) or S_loaded_df.isna().any().any():\n",
    "        raise ValueError(\"El CSV debe contener exactamente un score para cada par (a_id, b_id).\")\n",
    "\n",
    "    A_df = pd.DataFrame({\"id\": a_values}).set_index(\"id\")\n",
    "    B_df = pd.DataFrame({\"id\": b_values}).set_index(\"id\")\n",
    "\n",
    "    if \"a_nombre\" in raw_dataset_df.columns:\n",
    "        A_df[\"nombre\"] = raw_dataset_df.groupby(\"a_id\")[\"a_nombre\"].first().loc[a_values].values\n",
    "    if \"b_nombre\" in raw_dataset_df.columns:\n",
    "        B_df[\"nombre\"] = raw_dataset_df.groupby(\"b_id\")[\"b_nombre\"].first().loc[b_values].values\n",
    "\n",
    "    S = S_loaded_df.to_numpy(dtype=float)\n",
    "    S_df = pd.DataFrame(S, index=A_df.index, columns=B_df.index)\n",
    "    USANDO_DATASET_MOLECULAR_DE_EJEMPLO = False\n",
    "    NOMBRE_INSTANCIA = dataset_source\n",
    "\n",
    "    print(\"Dataset real/semi-real cargado:\", NOMBRE_INSTANCIA)\n",
    "    display(A_df)\n",
    "    display(B_df)\n",
    "    display(S_df)\n",
    "else:\n",
    "    USANDO_DATASET_MOLECULAR_DE_EJEMPLO = True\n",
    "    NOMBRE_INSTANCIA = \"molecular_educativa_de_respaldo\"\n",
    "    print(\"No se encontró data/dataset_real_4x4.csv ni DATASET_CSV_URL. Se mantiene la instancia molecular de ejemplo.\")\n",
    "\n",
    "# Alternativa manual: si no usan CSV, pueden asignar A_df, B_df y S directamente aquí,\n",
    "# siempre que documenten la fuente y la construcción de S en el README.md del repositorio."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "64c2a736",
   "metadata": {
    "id": "64c2a736"
   },
   "source": [
    "## 15. Validación mínima del dataset\n",
    "\n",
    "Objetivo: detener la ejecución si la instancia no tiene tamaño $4\\times 4$ o contiene valores no numéricos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1e5a06da",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/"
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.035980Z",
     "iopub.status.busy": "2026-06-19T14:35:36.035750Z",
     "iopub.status.idle": "2026-06-19T14:35:36.041225Z",
     "shell.execute_reply": "2026-06-19T14:35:36.039741Z"
    },
    "id": "1e5a06da",
    "outputId": "b4b42da9-6445-482b-9442-ad58b8231fdf"
   },
   "outputs": [],
   "source": [
    "assert len(A_df) == 4, \"A_df debe tener exactamente 4 filas.\"\n",
    "assert len(B_df) == 4, \"B_df debe tener exactamente 4 filas.\"\n",
    "assert S.shape == (4, 4), \"S debe tener forma (4, 4).\"\n",
    "assert np.isfinite(S).all(), \"S contiene valores no finitos.\"\n",
    "\n",
    "print(\"Dataset validado: A_df, B_df y S tienen formato compatible con matching 4x4.\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6ee7ac91",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/",
     "height": 357
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.043820Z",
     "iopub.status.busy": "2026-06-19T14:35:36.043660Z",
     "iopub.status.idle": "2026-06-19T14:35:36.196342Z",
     "shell.execute_reply": "2026-06-19T14:35:36.194874Z"
    },
    "id": "6ee7ac91",
    "outputId": "a3249d92-1d2d-4eee-d2dd-7bbd878668a6"
   },
   "outputs": [],
   "source": [
    "plt.figure(figsize=(4.5, 3.5))\n",
    "plt.imshow(S)\n",
    "plt.xticks(range(N_B), B_df.index)\n",
    "plt.yticks(range(N_A), A_df.index)\n",
    "plt.colorbar(label=\"score S_ij\")\n",
    "plt.title(\"Matriz de compatibilidad S\")\n",
    "plt.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "78e73852",
   "metadata": {
    "id": "78e73852"
   },
   "source": [
    "## 16. Variables binarias $x_{ij}$\n",
    "\n",
    "Objetivo: mapear cada posible match a una variable binaria y a un qubit lógico."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f9823ccc",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/",
     "height": 582
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.199329Z",
     "iopub.status.busy": "2026-06-19T14:35:36.199088Z",
     "iopub.status.idle": "2026-06-19T14:35:36.212028Z",
     "shell.execute_reply": "2026-06-19T14:35:36.210957Z"
    },
    "id": "f9823ccc",
    "outputId": "b746b25f-2215-4752-e9a8-16606da2fbe3"
   },
   "outputs": [],
   "source": [
    "def var_index(i: int, j: int, n_b: int = N_B) -> int:\n",
    "    return i * n_b + j\n",
    "\n",
    "variables = []\n",
    "for i, a_id in enumerate(A_df.index):\n",
    "    for j, b_id in enumerate(B_df.index):\n",
    "        variables.append({\n",
    "            \"k\": var_index(i, j),\n",
    "            \"variable\": f\"x_{a_id}_{b_id}\",\n",
    "            \"A\": a_id,\n",
    "            \"B\": b_id,\n",
    "            \"score\": S[i, j],\n",
    "        })\n",
    "\n",
    "variables_df = pd.DataFrame(variables).set_index(\"k\")\n",
    "display(variables_df)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7b870b16",
   "metadata": {
    "id": "7b870b16"
   },
   "source": [
    "## 17. Penalización QUBO\n",
    "\n",
    "Objetivo: elegir penalizaciones suficientemente grandes para que violar restricciones sea más costoso que ganar score.\n",
    "\n",
    "La regla usada aquí es conservadora para una instancia educativa pequeña."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "35042aa2",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/"
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.214505Z",
     "iopub.status.busy": "2026-06-19T14:35:36.214293Z",
     "iopub.status.idle": "2026-06-19T14:35:36.220255Z",
     "shell.execute_reply": "2026-06-19T14:35:36.219077Z"
    },
    "id": "35042aa2",
    "outputId": "964f9796-a444-447b-8769-6df2dd82d731"
   },
   "outputs": [],
   "source": [
    "def choose_penalty(score_matrix: np.ndarray) -> float:\n",
    "    max_abs_score = float(np.max(np.abs(score_matrix)))\n",
    "    return float(math.ceil(4.0 * max_abs_score + 1.0))\n",
    "\n",
    "LAMBDA_A = choose_penalty(S)\n",
    "LAMBDA_B = choose_penalty(S)\n",
    "\n",
    "print(\"lambda_A =\", LAMBDA_A)\n",
    "print(\"lambda_B =\", LAMBDA_B)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "68df1352",
   "metadata": {
    "id": "68df1352"
   },
   "source": [
    "## 18. Construcción del QUBO\n",
    "\n",
    "Usaremos la convención:\n",
    "\n",
    "$$\n",
    "E(x)=\\sum_k Q_{kk}x_k+\\sum_{k<\\ell}Q_{k\\ell}x_kx_\\ell+\\mathrm{offset}.\n",
    "$$\n",
    "\n",
    "`Q` guarda la diagonal y la parte triangular superior."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4498308e",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.222974Z",
     "iopub.status.busy": "2026-06-19T14:35:36.222766Z",
     "iopub.status.idle": "2026-06-19T14:35:36.227939Z",
     "shell.execute_reply": "2026-06-19T14:35:36.226545Z"
    },
    "id": "4498308e"
   },
   "outputs": [],
   "source": [
    "def add_exactly_one_penalty(Q: np.ndarray, group: list[int], penalty: float) -> float:\n",
    "    \"\"\"Agrega penalty * (sum(group) - 1)^2 al QUBO.\"\"\"\n",
    "    offset = float(penalty)\n",
    "\n",
    "    for k in group:\n",
    "        Q[k, k] += -penalty\n",
    "\n",
    "    for pos, k in enumerate(group):\n",
    "        for l in group[pos + 1:]:\n",
    "            Q[k, l] += 2.0 * penalty\n",
    "\n",
    "    return offset"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1c90b248",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.231466Z",
     "iopub.status.busy": "2026-06-19T14:35:36.231029Z",
     "iopub.status.idle": "2026-06-19T14:35:36.237680Z",
     "shell.execute_reply": "2026-06-19T14:35:36.236372Z"
    },
    "id": "1c90b248"
   },
   "outputs": [],
   "source": [
    "def build_assignment_qubo(score_matrix: np.ndarray, lambda_a: float, lambda_b: float):\n",
    "    S_local = np.asarray(score_matrix, dtype=float)\n",
    "    n_a, n_b = S_local.shape\n",
    "    Q = np.zeros((n_a * n_b, n_a * n_b), dtype=float)\n",
    "    offset = 0.0\n",
    "\n",
    "    for i in range(n_a):\n",
    "        for j in range(n_b):\n",
    "            k = var_index(i, j, n_b)\n",
    "            Q[k, k] += -S_local[i, j]\n",
    "\n",
    "    for i in range(n_a):\n",
    "        group = [var_index(i, j, n_b) for j in range(n_b)]\n",
    "        offset += add_exactly_one_penalty(Q, group, lambda_a)\n",
    "\n",
    "    for j in range(n_b):\n",
    "        group = [var_index(i, j, n_b) for i in range(n_a)]\n",
    "        offset += add_exactly_one_penalty(Q, group, lambda_b)\n",
    "\n",
    "    return Q, float(offset)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "35ba40c7",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/"
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.240594Z",
     "iopub.status.busy": "2026-06-19T14:35:36.240328Z",
     "iopub.status.idle": "2026-06-19T14:35:36.246638Z",
     "shell.execute_reply": "2026-06-19T14:35:36.245348Z"
    },
    "id": "35ba40c7",
    "outputId": "53feea1a-0282-488b-a590-743b876f6d09"
   },
   "outputs": [],
   "source": [
    "Q, qubo_offset = build_assignment_qubo(S, LAMBDA_A, LAMBDA_B)\n",
    "\n",
    "print(\"Variables binarias:\", N_VARS)\n",
    "print(\"Offset QUBO:\", qubo_offset)\n",
    "print(\"Términos no nulos en Q:\", int(np.count_nonzero(np.abs(Q) > 1e-12)))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "aaaab8eb",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/",
     "height": 677
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.249164Z",
     "iopub.status.busy": "2026-06-19T14:35:36.248981Z",
     "iopub.status.idle": "2026-06-19T14:35:36.264872Z",
     "shell.execute_reply": "2026-06-19T14:35:36.263743Z"
    },
    "id": "aaaab8eb",
    "outputId": "8ffcf671-ace1-49ea-e6e3-8f374b00caa4"
   },
   "outputs": [],
   "source": [
    "def qubo_terms_dataframe(Q: np.ndarray) -> pd.DataFrame:\n",
    "    rows = []\n",
    "    n = Q.shape[0]\n",
    "    for k in range(n):\n",
    "        if abs(Q[k, k]) > 1e-12:\n",
    "            rows.append({\"tipo\": \"lineal\", \"k\": k, \"l\": k, \"coeficiente\": Q[k, k]})\n",
    "        for l in range(k + 1, n):\n",
    "            if abs(Q[k, l]) > 1e-12:\n",
    "                rows.append({\"tipo\": \"cuadrático\", \"k\": k, \"l\": l, \"coeficiente\": Q[k, l]})\n",
    "    return pd.DataFrame(rows)\n",
    "\n",
    "qubo_terms_df = qubo_terms_dataframe(Q)\n",
    "display(qubo_terms_df.head(20))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c2a9ac3c",
   "metadata": {
    "id": "c2a9ac3c"
   },
   "source": [
    "## 19. Funciones de energía e interpretación\n",
    "\n",
    "Objetivo: evaluar el QUBO, reconstruir matrices de asignación y revisar restricciones."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "184234de",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.267833Z",
     "iopub.status.busy": "2026-06-19T14:35:36.267657Z",
     "iopub.status.idle": "2026-06-19T14:35:36.274320Z",
     "shell.execute_reply": "2026-06-19T14:35:36.273037Z"
    },
    "id": "184234de"
   },
   "outputs": [],
   "source": [
    "def qubo_energy(x: np.ndarray, Q: np.ndarray, offset: float = 0.0) -> float:\n",
    "    x = np.asarray(x, dtype=int)\n",
    "    energy = float(offset)\n",
    "\n",
    "    for k in range(len(x)):\n",
    "        energy += Q[k, k] * x[k]\n",
    "\n",
    "    for k in range(len(x)):\n",
    "        for l in range(k + 1, len(x)):\n",
    "            energy += Q[k, l] * x[k] * x[l]\n",
    "\n",
    "    return float(energy)\n",
    "\n",
    "\n",
    "def assignment_matrix(x: np.ndarray) -> np.ndarray:\n",
    "    return np.asarray(x, dtype=int).reshape(N_A, N_B)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "24472d3c",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.277665Z",
     "iopub.status.busy": "2026-06-19T14:35:36.277351Z",
     "iopub.status.idle": "2026-06-19T14:35:36.284324Z",
     "shell.execute_reply": "2026-06-19T14:35:36.282826Z"
    },
    "id": "24472d3c"
   },
   "outputs": [],
   "source": [
    "def is_feasible(x: np.ndarray) -> bool:\n",
    "    M = assignment_matrix(x)\n",
    "    return bool((M.sum(axis=1) == 1).all() and (M.sum(axis=0) == 1).all())\n",
    "\n",
    "\n",
    "def assignment_score(x: np.ndarray, score_matrix: np.ndarray = S) -> float:\n",
    "    return float(np.sum(assignment_matrix(x) * score_matrix))\n",
    "\n",
    "\n",
    "def selected_pairs(x: np.ndarray) -> pd.DataFrame:\n",
    "    M = assignment_matrix(x)\n",
    "    rows = []\n",
    "    for i, a_id in enumerate(A_df.index):\n",
    "        for j, b_id in enumerate(B_df.index):\n",
    "            if M[i, j] == 1:\n",
    "                rows.append({\"A\": a_id, \"B\": b_id, \"score\": S[i, j]})\n",
    "    return pd.DataFrame(rows)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3f994110",
   "metadata": {},
   "source": [
    "## 20. Convención de bitstrings\n",
    "\n",
    "Qiskit normalmente muestra los bitstrings en el orden del registro clásico, de izquierda a derecha. Internamente, el vector `x` usa el índice $k=iN_B+j$.\n",
    "\n",
    "Estas funciones evitan errores de interpretación cuando se comparan conteos locales contra conteos de hardware real."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "32dff94c",
   "metadata": {},
   "outputs": [],
   "source": [
    "def x_to_qiskit_bitstring(x: np.ndarray) -> str:\n",
    "    \"\"\"Convierte un vector binario x al formato de bitstring que suele reportar Qiskit.\"\"\"\n",
    "    x = np.asarray(x, dtype=int)\n",
    "    return \"\".join(str(int(bit)) for bit in x[::-1])\n",
    "\n",
    "\n",
    "def index_to_qiskit_bitstring(index: int, n_bits: int = N_VARS) -> str:\n",
    "    \"\"\"Convierte un índice de estado computacional al bitstring equivalente.\"\"\"\n",
    "    return format(int(index), f\"0{n_bits}b\")\n",
    "\n",
    "\n",
    "def decode_qiskit_bitstring(bitstring: str, n_bits: int = N_VARS) -> np.ndarray:\n",
    "    \"\"\"Convierte un bitstring de Qiskit al vector x usado por el QUBO.\"\"\"\n",
    "    clean = str(bitstring).replace(\" \", \"\").strip()\n",
    "    if clean.startswith(\"0b\"):\n",
    "        clean = clean[2:]\n",
    "    if len(clean) < n_bits:\n",
    "        clean = clean.zfill(n_bits)\n",
    "    elif len(clean) > n_bits:\n",
    "        clean = clean[-n_bits:]\n",
    "    return np.array([int(bit) for bit in clean[::-1]], dtype=int)\n",
    "\n",
    "\n",
    "def normalizar_conteos(counts: dict | Counter | None, n_bits: int = N_VARS) -> dict[str, int] | None:\n",
    "    \"\"\"Normaliza conteos agregados a bitstrings de longitud fija.\"\"\"\n",
    "    if counts is None:\n",
    "        return None\n",
    "\n",
    "    normalized: dict[str, int] = {}\n",
    "    for key, value in counts.items():\n",
    "        if value is None:\n",
    "            continue\n",
    "\n",
    "        if isinstance(key, (int, np.integer)):\n",
    "            bitstring = index_to_qiskit_bitstring(int(key), n_bits)\n",
    "        else:\n",
    "            bitstring = str(key).replace(\" \", \"\").strip()\n",
    "            if bitstring.startswith(\"0b\"):\n",
    "                bitstring = bitstring[2:]\n",
    "            if len(bitstring) < n_bits:\n",
    "                bitstring = bitstring.zfill(n_bits)\n",
    "            elif len(bitstring) > n_bits:\n",
    "                bitstring = bitstring[-n_bits:]\n",
    "\n",
    "        normalized[bitstring] = normalized.get(bitstring, 0) + int(value)\n",
    "\n",
    "    return normalized"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "73c8e439",
   "metadata": {
    "id": "73c8e439"
   },
   "source": [
    "## 21. Validación clásica exacta\n",
    "\n",
    "Objetivo: resolver por fuerza bruta las $2^{16}=65\\,536$ configuraciones binarias.\n",
    "\n",
    "Esto permite verificar si el QUBO fue construido correctamente."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d224d18e",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/"
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.287448Z",
     "iopub.status.busy": "2026-06-19T14:35:36.287181Z",
     "iopub.status.idle": "2026-06-19T14:35:36.305146Z",
     "shell.execute_reply": "2026-06-19T14:35:36.303875Z"
    },
    "id": "d224d18e",
    "outputId": "5be43031-0cae-464a-ceb2-247211df5b74"
   },
   "outputs": [],
   "source": [
    "def all_binary_vectors(n_bits: int) -> np.ndarray:\n",
    "    integers = np.arange(2 ** n_bits, dtype=np.uint32)\n",
    "    return ((integers[:, None] >> np.arange(n_bits)) & 1).astype(np.int8)\n",
    "\n",
    "\n",
    "all_x = all_binary_vectors(N_VARS)\n",
    "print(\"Número de configuraciones:\", len(all_x))\n",
    "print(\"Memoria de all_x en MB:\", round(all_x.nbytes / 1024**2, 3))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "48375efb",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.308201Z",
     "iopub.status.busy": "2026-06-19T14:35:36.307999Z",
     "iopub.status.idle": "2026-06-19T14:35:36.358115Z",
     "shell.execute_reply": "2026-06-19T14:35:36.356796Z"
    },
    "id": "48375efb"
   },
   "outputs": [],
   "source": [
    "def qubo_energy_many(X: np.ndarray, Q: np.ndarray, offset: float = 0.0) -> np.ndarray:\n",
    "    X = np.asarray(X, dtype=float)\n",
    "    energies = np.full(X.shape[0], float(offset), dtype=float)\n",
    "\n",
    "    for k in range(Q.shape[0]):\n",
    "        energies += Q[k, k] * X[:, k]\n",
    "\n",
    "    for k in range(Q.shape[0]):\n",
    "        for l in range(k + 1, Q.shape[0]):\n",
    "            if abs(Q[k, l]) > 1e-12:\n",
    "                energies += Q[k, l] * X[:, k] * X[:, l]\n",
    "\n",
    "    return energies\n",
    "\n",
    "\n",
    "energy_by_state = qubo_energy_many(all_x, Q, qubo_offset)\n",
    "best_state_index = int(np.argmin(energy_by_state))\n",
    "best_x_exact = all_x[best_state_index]"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "611436cd",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/",
     "height": 416
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.361745Z",
     "iopub.status.busy": "2026-06-19T14:35:36.361387Z",
     "iopub.status.idle": "2026-06-19T14:35:36.378117Z",
     "shell.execute_reply": "2026-06-19T14:35:36.376970Z"
    },
    "id": "611436cd",
    "outputId": "cc49c39e-53b2-4ec7-e78f-b8a462b6469b"
   },
   "outputs": [],
   "source": [
    "best_energy_exact = float(energy_by_state[best_state_index])\n",
    "best_score_exact = assignment_score(best_x_exact)\n",
    "best_feasible_exact = is_feasible(best_x_exact)\n",
    "\n",
    "print(\"Mejor energía QUBO exacta:\", best_energy_exact)\n",
    "print(\"Score de la mejor solución:\", best_score_exact)\n",
    "print(\"¿La mejor solución es factible?\", best_feasible_exact)\n",
    "\n",
    "display(pd.DataFrame(assignment_matrix(best_x_exact), index=A_df.index, columns=B_df.index))\n",
    "display(selected_pairs(best_x_exact))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9c201e05",
   "metadata": {
    "id": "9c201e05"
   },
   "source": [
    "## 22. Validación adicional por permutaciones factibles\n",
    "\n",
    "Objetivo: comparar el óptimo QUBO contra las 24 asignaciones uno-a-uno posibles.\n",
    "\n",
    "Si el QUBO está bien penalizado, ambas soluciones deben coincidir."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "01a705dc",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/",
     "height": 258
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.382084Z",
     "iopub.status.busy": "2026-06-19T14:35:36.381692Z",
     "iopub.status.idle": "2026-06-19T14:35:36.397672Z",
     "shell.execute_reply": "2026-06-19T14:35:36.395895Z"
    },
    "id": "01a705dc",
    "outputId": "1461e8f9-737d-448f-e1ad-4c08d742558f"
   },
   "outputs": [],
   "source": [
    "best_perm = None\n",
    "best_perm_score = -np.inf\n",
    "\n",
    "for perm in permutations(range(N_B)):\n",
    "    score = sum(S[i, perm[i]] for i in range(N_A))\n",
    "    if score > best_perm_score:\n",
    "        best_perm_score = float(score)\n",
    "        best_perm = perm\n",
    "\n",
    "x_perm = np.zeros(N_VARS, dtype=int)\n",
    "for i, j in enumerate(best_perm):\n",
    "    x_perm[var_index(i, j)] = 1\n",
    "\n",
    "print(\"Mejor score factible por permutaciones:\", best_perm_score)\n",
    "print(\"Energía QUBO de esa asignación:\", qubo_energy(x_perm, Q, qubo_offset))\n",
    "print(\"¿Coincide con el óptimo QUBO?\", np.array_equal(x_perm, best_x_exact))\n",
    "\n",
    "display(pd.DataFrame(assignment_matrix(x_perm), index=A_df.index, columns=B_df.index))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d26c761c",
   "metadata": {
    "id": "d26c761c"
   },
   "source": [
    "# Parte C — QAOA local ligero\n",
    "\n",
    "La simulación QAOA local usa únicamente `numpy` y `scipy`.\n",
    "\n",
    "No se usa IBM Quantum ni simuladores pesados. El estado cuántico tiene $2^{16}=65\\,536$ amplitudes complejas, por lo que el consumo de RAM es bajo."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6ad3fd12",
   "metadata": {
    "id": "6ad3fd12"
   },
   "source": [
    "## 23. Estado inicial y costo diagonal\n",
    "\n",
    "QAOA inicia en el estado uniforme:\n",
    "\n",
    "$$\n",
    "|+\\rangle^{\\otimes n}.\n",
    "$$\n",
    "\n",
    "Como el Hamiltoniano de costo es diagonal en la base computacional, la fase de costo se aplica directamente con el vector de energías QUBO."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cc483f24",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/"
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.400769Z",
     "iopub.status.busy": "2026-06-19T14:35:36.400584Z",
     "iopub.status.idle": "2026-06-19T14:35:36.408590Z",
     "shell.execute_reply": "2026-06-19T14:35:36.407368Z"
    },
    "id": "cc483f24",
    "outputId": "cc993920-f1be-4ad4-de3c-b17fce4fd212"
   },
   "outputs": [],
   "source": [
    "N_STATES = 2 ** N_VARS\n",
    "\n",
    "energy_center = float(np.mean(energy_by_state))\n",
    "energy_scale = float(np.std(energy_by_state))\n",
    "if energy_scale == 0:\n",
    "    energy_scale = 1.0\n",
    "\n",
    "phase_energy = (energy_by_state - energy_center) / energy_scale\n",
    "\n",
    "print(\"Estados:\", N_STATES)\n",
    "print(\"Memoria del vector de estado complejo en MB:\", round((N_STATES * np.dtype(np.complex128).itemsize) / 1024**2, 3))\n",
    "print(\"Escala usada en fases QAOA:\", round(energy_scale, 4))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2cd04be5",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.411791Z",
     "iopub.status.busy": "2026-06-19T14:35:36.411518Z",
     "iopub.status.idle": "2026-06-19T14:35:36.418561Z",
     "shell.execute_reply": "2026-06-19T14:35:36.415993Z"
    },
    "id": "2cd04be5"
   },
   "outputs": [],
   "source": [
    "def plus_state(n_bits: int) -> np.ndarray:\n",
    "    return np.ones(2 ** n_bits, dtype=np.complex128) / math.sqrt(2 ** n_bits)\n",
    "\n",
    "\n",
    "def apply_cost_phase(state: np.ndarray, gamma: float) -> np.ndarray:\n",
    "    return state * np.exp(-1j * gamma * phase_energy)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3f62998c",
   "metadata": {
    "id": "3f62998c"
   },
   "source": [
    "## 24. Mixer estándar\n",
    "\n",
    "Usamos el mixer estándar:\n",
    "\n",
    "$$\n",
    "U_M(\\beta)=\\exp\\left(-i\\beta\\sum_k X_k\\right).\n",
    "$$\n",
    "\n",
    "Este mixer no preserva automáticamente la factibilidad. Por eso mediremos factibilidad después del muestreo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "439a2a4e",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.421926Z",
     "iopub.status.busy": "2026-06-19T14:35:36.421745Z",
     "iopub.status.idle": "2026-06-19T14:35:36.427973Z",
     "shell.execute_reply": "2026-06-19T14:35:36.426559Z"
    },
    "id": "439a2a4e"
   },
   "outputs": [],
   "source": [
    "def apply_mixer(state: np.ndarray, beta: float, n_bits: int) -> np.ndarray:\n",
    "    state = state.copy()\n",
    "    c = math.cos(beta)\n",
    "    s = -1j * math.sin(beta)\n",
    "\n",
    "    for k in range(n_bits):\n",
    "        step = 1 << k\n",
    "        block = step << 1\n",
    "        view = state.reshape(-1, block)\n",
    "\n",
    "        left = view[:, :step].copy()\n",
    "        right = view[:, step:block].copy()\n",
    "\n",
    "        view[:, :step] = c * left + s * right\n",
    "        view[:, step:block] = s * left + c * right\n",
    "\n",
    "    return state"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6464b7da",
   "metadata": {
    "id": "6464b7da"
   },
   "source": [
    "## 25. Construcción del estado QAOA\n",
    "\n",
    "Para $p=1$:\n",
    "\n",
    "$$\n",
    "|\\psi(\\gamma,\\beta)\\rangle\n",
    "=\n",
    "U_M(\\beta)U_C(\\gamma)|+\\rangle^{\\otimes n}.\n",
    "$$\n",
    "\n",
    "El código permite aumentar `QAOA_P`, pero para Colab se recomienda iniciar con `p=1`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "17142dbb",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.431148Z",
     "iopub.status.busy": "2026-06-19T14:35:36.430739Z",
     "iopub.status.idle": "2026-06-19T14:35:36.437559Z",
     "shell.execute_reply": "2026-06-19T14:35:36.436502Z"
    },
    "id": "17142dbb"
   },
   "outputs": [],
   "source": [
    "QAOA_P = 1\n",
    "\n",
    "def qaoa_state(params: np.ndarray, p: int = QAOA_P) -> np.ndarray:\n",
    "    params = np.asarray(params, dtype=float)\n",
    "    state = plus_state(N_VARS)\n",
    "\n",
    "    for layer in range(p):\n",
    "        gamma = params[2 * layer]\n",
    "        beta = params[2 * layer + 1]\n",
    "        state = apply_cost_phase(state, gamma)\n",
    "        state = apply_mixer(state, beta, N_VARS)\n",
    "\n",
    "    return state\n",
    "\n",
    "\n",
    "def qaoa_expected_energy(params: np.ndarray) -> float:\n",
    "    state = qaoa_state(params, QAOA_P)\n",
    "    probs = np.abs(state) ** 2\n",
    "    return float(np.dot(probs, energy_by_state))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0e442aa0",
   "metadata": {
    "id": "0e442aa0"
   },
   "source": [
    "## 26. Optimización clásica de parámetros\n",
    "\n",
    "Objetivo: encontrar ángulos $(\\gamma,\\beta)$ que reduzcan la energía esperada.\n",
    "\n",
    "Para que la ejecución local sea ligera, se usan pocos reinicios y pocas iteraciones."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "07a60a8d",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/",
     "height": 143
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:35:36.441111Z",
     "iopub.status.busy": "2026-06-19T14:35:36.440836Z",
     "iopub.status.idle": "2026-06-19T14:36:02.741453Z",
     "shell.execute_reply": "2026-06-19T14:36:02.739942Z"
    },
    "id": "07a60a8d",
    "outputId": "194662ea-64bc-4199-c31c-26445a4dc111"
   },
   "outputs": [],
   "source": [
    "N_RESTARTS = 1\n",
    "MAXITER = 25\n",
    "\n",
    "restart_rows = []\n",
    "best_result = None\n",
    "\n",
    "for restart in range(N_RESTARTS):\n",
    "    initial = rng.uniform(low=-np.pi, high=np.pi, size=2 * QAOA_P)\n",
    "\n",
    "    result = minimize(\n",
    "        qaoa_expected_energy,\n",
    "        initial,\n",
    "        method=\"COBYLA\",\n",
    "        options={\"maxiter\": MAXITER, \"rhobeg\": 0.7, \"disp\": False},\n",
    "    )\n",
    "\n",
    "    restart_rows.append({\n",
    "        \"restart\": restart,\n",
    "        \"energia_esperada\": float(result.fun),\n",
    "        \"parametros\": np.round(result.x, 4),\n",
    "        \"evaluaciones\": result.nfev,\n",
    "    })\n",
    "\n",
    "    if best_result is None or result.fun < best_result.fun:\n",
    "        best_result = result\n",
    "\n",
    "restart_df = pd.DataFrame(restart_rows)\n",
    "display(restart_df)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "abc96b93",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/"
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:36:02.745121Z",
     "iopub.status.busy": "2026-06-19T14:36:02.744870Z",
     "iopub.status.idle": "2026-06-19T14:36:02.753805Z",
     "shell.execute_reply": "2026-06-19T14:36:02.751987Z"
    },
    "id": "abc96b93",
    "outputId": "31c0b2a3-7185-4957-9787-8596798f30be"
   },
   "outputs": [],
   "source": [
    "best_params = np.asarray(best_result.x, dtype=float)\n",
    "best_expected_energy = float(best_result.fun)\n",
    "\n",
    "print(\"Mejores parámetros QAOA:\", np.round(best_params, 6))\n",
    "print(\"Energía esperada QAOA:\", round(best_expected_energy, 6))\n",
    "print(\"Óptimo clásico exacto:\", round(best_energy_exact, 6))\n",
    "print(\"Brecha esperada:\", round(best_expected_energy - best_energy_exact, 6))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8f248fd9",
   "metadata": {
    "id": "8f248fd9"
   },
   "source": [
    "## 27. Muestreo local\n",
    "\n",
    "Objetivo: convertir la distribución QAOA en bitstrings observados.\n",
    "\n",
    "Se toma el mejor bitstring observado por energía QUBO, no necesariamente el más frecuente."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "760aa404",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-06-19T14:36:02.756879Z",
     "iopub.status.busy": "2026-06-19T14:36:02.756673Z",
     "iopub.status.idle": "2026-06-19T14:36:02.774282Z",
     "shell.execute_reply": "2026-06-19T14:36:02.772378Z"
    },
    "id": "760aa404"
   },
   "outputs": [],
   "source": [
    "qaoa_final_state = qaoa_state(best_params, QAOA_P)\n",
    "qaoa_probs = np.abs(qaoa_final_state) ** 2\n",
    "qaoa_probs = qaoa_probs / qaoa_probs.sum()\n",
    "\n",
    "SHOTS_LOCAL = 2000\n",
    "sampled_indices = rng.choice(N_STATES, size=SHOTS_LOCAL, replace=True, p=qaoa_probs)\n",
    "\n",
    "# Conteos locales en formato comparable con Qiskit.\n",
    "sample_counts = Counter(sampled_indices)\n",
    "counts_local = Counter(index_to_qiskit_bitstring(int(idx), N_VARS) for idx in sampled_indices)\n",
    "\n",
    "observed_indices = np.array(list(sample_counts.keys()), dtype=int)\n",
    "best_observed_index = int(observed_indices[np.argmin(energy_by_state[observed_indices])])\n",
    "best_x_qaoa = all_x[best_observed_index]"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b1e01096",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/",
     "height": 451
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:36:02.776865Z",
     "iopub.status.busy": "2026-06-19T14:36:02.776624Z",
     "iopub.status.idle": "2026-06-19T14:36:02.795522Z",
     "shell.execute_reply": "2026-06-19T14:36:02.793856Z"
    },
    "id": "b1e01096",
    "outputId": "c4058f55-b3a7-4e5c-9c2e-ce7257054298"
   },
   "outputs": [],
   "source": [
    "print(\"Shots locales:\", SHOTS_LOCAL)\n",
    "print(\"Bitstrings distintos observados:\", len(sample_counts))\n",
    "print(\"Mejor energía observada:\", float(energy_by_state[best_observed_index]))\n",
    "print(\"Score de la mejor muestra:\", assignment_score(best_x_qaoa))\n",
    "print(\"¿Mejor muestra factible?\", is_feasible(best_x_qaoa))\n",
    "\n",
    "display(pd.DataFrame(assignment_matrix(best_x_qaoa), index=A_df.index, columns=B_df.index))\n",
    "display(selected_pairs(best_x_qaoa))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "208703c6",
   "metadata": {
    "id": "208703c6"
   },
   "source": [
    "## 28. Métricas probabilísticas de QAOA\n",
    "\n",
    "Objetivo: reportar probabilidad de factibilidad y probabilidad del óptimo clásico bajo la distribución ideal simulada."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6ed825f0",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/"
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:36:02.799108Z",
     "iopub.status.busy": "2026-06-19T14:36:02.798646Z",
     "iopub.status.idle": "2026-06-19T14:36:02.840194Z",
     "shell.execute_reply": "2026-06-19T14:36:02.838735Z"
    },
    "id": "6ed825f0",
    "outputId": "8a754e0e-175b-4853-83fd-e362816224f6"
   },
   "outputs": [],
   "source": [
    "assignments_3d = all_x.reshape(N_STATES, N_A, N_B)\n",
    "row_ok = (assignments_3d.sum(axis=2) == 1).all(axis=1)\n",
    "col_ok = (assignments_3d.sum(axis=1) == 1).all(axis=1)\n",
    "feasible_mask = row_ok & col_ok\n",
    "\n",
    "prob_feasible = float(qaoa_probs[feasible_mask].sum())\n",
    "prob_exact_optimum = float(qaoa_probs[best_state_index])\n",
    "\n",
    "print(\"Probabilidad ideal de soluciones factibles:\", round(prob_feasible, 6))\n",
    "print(\"Probabilidad ideal del óptimo clásico exacto:\", round(prob_exact_optimum, 6))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1be8f1a1",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/",
     "height": 363
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:36:02.843207Z",
     "iopub.status.busy": "2026-06-19T14:36:02.842990Z",
     "iopub.status.idle": "2026-06-19T14:36:02.860628Z",
     "shell.execute_reply": "2026-06-19T14:36:02.858992Z"
    },
    "id": "1be8f1a1",
    "outputId": "a7d97c60-9172-4ce0-9c18-93f6c35ab4f9"
   },
   "outputs": [],
   "source": [
    "top_indices = np.argsort(qaoa_probs)[::-1][:10]\n",
    "\n",
    "top_rows = []\n",
    "for rank, idx in enumerate(top_indices, start=1):\n",
    "    x = all_x[idx]\n",
    "    top_rows.append({\n",
    "        \"rank\": rank,\n",
    "        \"probabilidad\": qaoa_probs[idx],\n",
    "        \"energia_QUBO\": energy_by_state[idx],\n",
    "        \"score\": assignment_score(x),\n",
    "        \"factible\": is_feasible(x),\n",
    "    })\n",
    "\n",
    "top_qaoa_df = pd.DataFrame(top_rows)\n",
    "display(top_qaoa_df)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a3b40542",
   "metadata": {
    "id": "a3b40542"
   },
   "source": [
    "## 29. Distribución de energías muestreadas\n",
    "\n",
    "Objetivo: visualizar qué energías aparecen en las mediciones simuladas."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1c8eb0c8",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/",
     "height": 357
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:36:02.865045Z",
     "iopub.status.busy": "2026-06-19T14:36:02.864581Z",
     "iopub.status.idle": "2026-06-19T14:36:03.012741Z",
     "shell.execute_reply": "2026-06-19T14:36:03.011106Z"
    },
    "id": "1c8eb0c8",
    "outputId": "31ca95cc-e44a-4dea-fd60-adae1992acb2"
   },
   "outputs": [],
   "source": [
    "sampled_energies = energy_by_state[sampled_indices]\n",
    "\n",
    "plt.figure(figsize=(6, 3.5))\n",
    "plt.hist(sampled_energies, bins=30)\n",
    "plt.axvline(best_energy_exact, linestyle=\"--\", linewidth=1, label=\"óptimo clásico\")\n",
    "plt.xlabel(\"Energía QUBO\")\n",
    "plt.ylabel(\"Frecuencia\")\n",
    "plt.title(\"Distribución de energías muestreadas por QAOA local\")\n",
    "plt.legend()\n",
    "plt.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "89122dc4",
   "metadata": {
    "id": "89122dc4"
   },
   "source": [
    "## 30. Comparación final local\n",
    "\n",
    "Objetivo: comparar clásico exacto contra QAOA local."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "55b8ba2c",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/",
     "height": 112
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:36:03.016810Z",
     "iopub.status.busy": "2026-06-19T14:36:03.016542Z",
     "iopub.status.idle": "2026-06-19T14:36:03.032103Z",
     "shell.execute_reply": "2026-06-19T14:36:03.030653Z"
    },
    "id": "55b8ba2c",
    "outputId": "6f37180d-f960-4824-9017-cf35e6313c97"
   },
   "outputs": [],
   "source": [
    "comparison_df = pd.DataFrame([\n",
    "    {\n",
    "        \"método\": \"Clásico exacto\",\n",
    "        \"energía\": best_energy_exact,\n",
    "        \"score\": best_score_exact,\n",
    "        \"factible\": best_feasible_exact,\n",
    "        \"probabilidad_factible\": np.nan,\n",
    "        \"probabilidad_óptimo\": np.nan,\n",
    "    },\n",
    "    {\n",
    "        \"método\": \"QAOA local: mejor muestra\",\n",
    "        \"energía\": float(energy_by_state[best_observed_index]),\n",
    "        \"score\": assignment_score(best_x_qaoa),\n",
    "        \"factible\": is_feasible(best_x_qaoa),\n",
    "        \"probabilidad_factible\": prob_feasible,\n",
    "        \"probabilidad_óptimo\": prob_exact_optimum,\n",
    "    },\n",
    "])\n",
    "\n",
    "display(comparison_df)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a3ed362e",
   "metadata": {
    "colab": {
     "base_uri": "https://localhost:8080/",
     "height": 357
    },
    "execution": {
     "iopub.execute_input": "2026-06-19T14:36:03.035107Z",
     "iopub.status.busy": "2026-06-19T14:36:03.034907Z",
     "iopub.status.idle": "2026-06-19T14:36:03.121129Z",
     "shell.execute_reply": "2026-06-19T14:36:03.119463Z"
    },
    "id": "a3ed362e",
    "outputId": "0421fc8a-ac99-48fe-e13e-ee97343843b7"
   },
   "outputs": [],
   "source": [
    "plt.figure(figsize=(5.5, 3.5))\n",
    "plt.bar(comparison_df[\"método\"], comparison_df[\"energía\"])\n",
    "plt.axhline(best_energy_exact, linestyle=\"--\", linewidth=1)\n",
    "plt.ylabel(\"Energía QUBO\")\n",
    "plt.title(\"Comparación de energía\")\n",
    "plt.xticks(rotation=20, ha=\"right\")\n",
    "plt.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "68913ca7",
   "metadata": {},
   "source": [
    "# Parte D — IBM Quantum opcional y comparación local vs hardware\n",
    "\n",
    "Esta parte es avanzada y no es requisito para la calificación base. La ejecución en hardware real puede tener cola, ruido, límites de uso y condiciones asociadas a la cuenta de IBM Quantum.\n",
    "\n",
    "El objetivo didáctico de esta sección es comparar:\n",
    "\n",
    "```text\n",
    "Clásico exacto      referencia óptima por fuerza bruta\n",
    "QAOA local          simulación ideal y muestreo local\n",
    "Hardware real       muestras obtenidas en un QPU de IBM Quantum\n",
    "Pipeline híbrido    hardware o simulación con reparación clásica de muestras\n",
    "```\n",
    "\n",
    "La comparación solo será completa si `USAR_IBM_QUANTUM = True` y el job devuelve conteos. Para la entrega base, mantengan `USAR_IBM_QUANTUM = False`."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "46afd66d",
   "metadata": {},
   "source": [
    "## 31. Configuración de IBM Quantum\n",
    "\n",
    "Pega el token únicamente en una copia privada. No subas el token al repositorio de GitHub.\n",
    "\n",
    "Para la entrega ordinaria del proyecto, deja `USAR_IBM_QUANTUM = False`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c5be2ef1",
   "metadata": {},
   "outputs": [],
   "source": [
    "USAR_IBM_QUANTUM = False  #@param {type:\"boolean\"}\n",
    "IBM_QUANTUM_TOKEN = \"\"  #@param {type:\"string\"}\n",
    "IBM_INSTANCE_CRN = \"\"  #@param {type:\"string\"}\n",
    "\n",
    "SHOTS_HARDWARE = 512  #@param {type:\"integer\"}\n",
    "ESPERAR_RESULTADO_IBM = True  #@param {type:\"boolean\"}\n",
    "\n",
    "hardware_backend_name = None\n",
    "hardware_job_id = None\n",
    "hardware_job_status = None\n",
    "hardware_circuit_depth = None\n",
    "hardware_circuit_ops = None\n",
    "counts_hardware = None\n",
    "\n",
    "if not USAR_IBM_QUANTUM:\n",
    "    print(\"IBM Quantum desactivado. La entrega base se completa con QAOA local.\")\n",
    "elif not IBM_QUANTUM_TOKEN.strip():\n",
    "    print(\"IBM Quantum activado, pero falta pegar el token en IBM_QUANTUM_TOKEN.\")\n",
    "else:\n",
    "    print(\"IBM Quantum activado. Se intentará ejecutar la sección avanzada.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7d12f022",
   "metadata": {},
   "source": [
    "## 32. Circuito QAOA para Qiskit\n",
    "\n",
    "El circuito usa los mismos parámetros optimizados localmente. No se reoptimiza en hardware porque eso multiplicaría el número de jobs.\n",
    "\n",
    "La capa de costo implementa la fase de la energía QUBO escalada, la misma que se usó en la simulación local."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9a87b586",
   "metadata": {},
   "outputs": [],
   "source": [
    "def add_cost_layer_to_qiskit(qc: Any, gamma: float) -> None:\n",
    "    \"\"\"Agrega una capa de costo QUBO al circuito de Qiskit.\"\"\"\n",
    "    for k in range(N_VARS):\n",
    "        coeff = Q[k, k]\n",
    "        if abs(coeff) > 1e-12:\n",
    "            qc.rz(-gamma * coeff / energy_scale, k)\n",
    "\n",
    "    for k in range(N_VARS):\n",
    "        for l in range(k + 1, N_VARS):\n",
    "            coeff = Q[k, l]\n",
    "            if abs(coeff) > 1e-12:\n",
    "                qc.rz(-gamma * coeff / (2.0 * energy_scale), k)\n",
    "                qc.rz(-gamma * coeff / (2.0 * energy_scale), l)\n",
    "                qc.rzz(gamma * coeff / (2.0 * energy_scale), k, l)\n",
    "\n",
    "\n",
    "def build_qiskit_qaoa_circuit(params: np.ndarray):\n",
    "    \"\"\"Construye el circuito QAOA medido para ejecutar con SamplerV2.\"\"\"\n",
    "    from qiskit import QuantumCircuit\n",
    "\n",
    "    qc = QuantumCircuit(N_VARS)\n",
    "    qc.h(range(N_VARS))\n",
    "\n",
    "    for layer in range(QAOA_P):\n",
    "        gamma = float(params[2 * layer])\n",
    "        beta = float(params[2 * layer + 1])\n",
    "\n",
    "        add_cost_layer_to_qiskit(qc, gamma)\n",
    "\n",
    "        for k in range(N_VARS):\n",
    "            qc.rx(2.0 * beta, k)\n",
    "\n",
    "    qc.measure_all()\n",
    "    return qc"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e320c224",
   "metadata": {},
   "source": [
    "## 33. Extracción de conteos del Sampler\n",
    "\n",
    "El `SamplerV2` devuelve resultados por registros clásicos. Esta función intenta extraer los conteos de forma robusta para poder compararlos con el muestreo local."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "017f00a2",
   "metadata": {},
   "outputs": [],
   "source": [
    "def extract_counts_from_sampler_result(pub_result: Any) -> dict[str, int]:\n",
    "    \"\"\"Extrae conteos de un resultado de SamplerV2.\"\"\"\n",
    "    data = getattr(pub_result, \"data\", None)\n",
    "    if data is None:\n",
    "        raise ValueError(\"El resultado no contiene el atributo data.\")\n",
    "\n",
    "    for register_name in [\"meas\", \"c\", \"creg\"]:\n",
    "        if hasattr(data, register_name):\n",
    "            register_data = getattr(data, register_name)\n",
    "\n",
    "            if hasattr(register_data, \"get_counts\"):\n",
    "                return dict(register_data.get_counts())\n",
    "\n",
    "            if hasattr(register_data, \"get_bitstrings\"):\n",
    "                return dict(Counter(register_data.get_bitstrings()))\n",
    "\n",
    "    for attribute_name in dir(data):\n",
    "        if attribute_name.startswith(\"_\"):\n",
    "            continue\n",
    "\n",
    "        try:\n",
    "            candidate = getattr(data, attribute_name)\n",
    "        except Exception:\n",
    "            continue\n",
    "\n",
    "        if hasattr(candidate, \"get_counts\"):\n",
    "            return dict(candidate.get_counts())\n",
    "\n",
    "        if hasattr(candidate, \"get_bitstrings\"):\n",
    "            return dict(Counter(candidate.get_bitstrings()))\n",
    "\n",
    "    raise ValueError(\"No fue posible extraer conteos del resultado de SamplerV2.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a7c970a4",
   "metadata": {},
   "source": [
    "## 34. Ejecución opcional en hardware real\n",
    "\n",
    "Esta celda envía un solo circuito QAOA a un backend real. Si `ESPERAR_RESULTADO_IBM = True`, la celda esperará a que el job termine para obtener los conteos.\n",
    "\n",
    "Si la cola es larga, puede ponerse `ESPERAR_RESULTADO_IBM = False`, guardar el `job_id` y recuperar el resultado después."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "08fa2b35",
   "metadata": {},
   "outputs": [],
   "source": [
    "if USAR_IBM_QUANTUM:\n",
    "    import os\n",
    "    from qiskit.transpiler import generate_preset_pass_manager\n",
    "    from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2 as Sampler\n",
    "\n",
    "    token = IBM_QUANTUM_TOKEN.strip() or os.getenv(\"IBM_QUANTUM_TOKEN\", \"\").strip()\n",
    "    if not token:\n",
    "        raise RuntimeError(\"Pega tu token en IBM_QUANTUM_TOKEN o define la variable de entorno IBM_QUANTUM_TOKEN.\")\n",
    "\n",
    "    service_kwargs = {\n",
    "        \"channel\": \"ibm_quantum_platform\",\n",
    "        \"token\": token,\n",
    "    }\n",
    "    if IBM_INSTANCE_CRN.strip():\n",
    "        service_kwargs[\"instance\"] = IBM_INSTANCE_CRN.strip()\n",
    "\n",
    "    service = QiskitRuntimeService(**service_kwargs)\n",
    "    backend = service.least_busy(operational=True, simulator=False, min_num_qubits=N_VARS)\n",
    "\n",
    "    backend_name_attr = getattr(backend, \"name\", None)\n",
    "    hardware_backend_name = backend_name_attr() if callable(backend_name_attr) else str(backend_name_attr)\n",
    "\n",
    "    circuit_hardware = build_qiskit_qaoa_circuit(best_params)\n",
    "\n",
    "    pass_manager = generate_preset_pass_manager(\n",
    "        optimization_level=1,\n",
    "        backend=backend,\n",
    "        seed_transpiler=SEED,\n",
    "    )\n",
    "    circuit_isa = pass_manager.run(circuit_hardware)\n",
    "\n",
    "    hardware_circuit_depth = int(circuit_isa.depth())\n",
    "    hardware_circuit_ops = dict(circuit_isa.count_ops())\n",
    "\n",
    "    sampler = Sampler(mode=backend)\n",
    "    job = sampler.run([circuit_isa], shots=SHOTS_HARDWARE)\n",
    "\n",
    "    hardware_job_id = job.job_id()\n",
    "    hardware_job_status = str(job.status())\n",
    "\n",
    "    print(\"Backend:\", hardware_backend_name)\n",
    "    print(\"Job ID:\", hardware_job_id)\n",
    "    print(\"Estado inicial:\", hardware_job_status)\n",
    "    print(\"Profundidad ISA:\", hardware_circuit_depth)\n",
    "    print(\"Operaciones ISA:\", hardware_circuit_ops)\n",
    "\n",
    "    if ESPERAR_RESULTADO_IBM:\n",
    "        hardware_result = job.result()\n",
    "        pub_result = hardware_result[0]\n",
    "        counts_hardware = normalizar_conteos(extract_counts_from_sampler_result(pub_result), N_VARS)\n",
    "        hardware_job_status = str(job.status())\n",
    "\n",
    "        print(\"Estado final:\", hardware_job_status)\n",
    "        print(\"Shots recibidos:\", sum(counts_hardware.values()))\n",
    "        print(\"Bitstrings distintos:\", len(counts_hardware))\n",
    "else:\n",
    "    print(\"No se envió ningún job a IBM Quantum.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "42c7ec8e",
   "metadata": {},
   "source": [
    "## 35. Reparación clásica conservadora\n",
    "\n",
    "El mixer estándar de QAOA no preserva automáticamente las restricciones de asignación. Por eso pueden aparecer bitstrings no factibles.\n",
    "\n",
    "La reparación clásica proyecta cada bitstring observado al conjunto de asignaciones uno-a-uno factibles. Debe reportarse como **postprocesamiento híbrido**, no como salida cuántica pura."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0f563b11",
   "metadata": {},
   "outputs": [],
   "source": [
    "REPARAR_MUESTRAS = True\n",
    "REPAIR_SAMPLE_BONUS = 0.15\n",
    "\n",
    "\n",
    "def repair_x_to_feasible_assignment(x: np.ndarray, sample_bonus: float = REPAIR_SAMPLE_BONUS) -> np.ndarray:\n",
    "    \"\"\"Proyecta un bitstring observado a una asignación factible.\"\"\"\n",
    "    observed_matrix = assignment_matrix(x)\n",
    "\n",
    "    s_min = float(np.min(S))\n",
    "    s_max = float(np.max(S))\n",
    "    if abs(s_max - s_min) < 1e-12:\n",
    "        S_norm = np.zeros_like(S, dtype=float)\n",
    "    else:\n",
    "        S_norm = (S - s_min) / (s_max - s_min)\n",
    "\n",
    "    projection_score = S_norm + sample_bonus * observed_matrix\n",
    "    row_ind, col_ind = linear_sum_assignment(-projection_score)\n",
    "\n",
    "    repaired = np.zeros(N_VARS, dtype=int)\n",
    "    for i, j in zip(row_ind, col_ind):\n",
    "        repaired[var_index(i, j)] = 1\n",
    "\n",
    "    return repaired\n",
    "\n",
    "\n",
    "def repair_counts_to_counts(counts: dict[str, int] | None) -> dict[str, int] | None:\n",
    "    \"\"\"Aplica la reparación clásica a todos los bitstrings de un diccionario de conteos.\"\"\"\n",
    "    normalized = normalizar_conteos(counts, N_VARS)\n",
    "    if normalized is None:\n",
    "        return None\n",
    "\n",
    "    repaired_counts = Counter()\n",
    "    for bitstring, count in normalized.items():\n",
    "        x = decode_qiskit_bitstring(bitstring, N_VARS)\n",
    "        x_repaired = repair_x_to_feasible_assignment(x)\n",
    "        repaired_counts[x_to_qiskit_bitstring(x_repaired)] += int(count)\n",
    "\n",
    "    return dict(repaired_counts)\n",
    "\n",
    "\n",
    "counts_local_repaired = repair_counts_to_counts(counts_local) if REPARAR_MUESTRAS else None\n",
    "counts_hardware_repaired = repair_counts_to_counts(counts_hardware) if (REPARAR_MUESTRAS and counts_hardware is not None) else None\n",
    "\n",
    "print(\"Reparación local disponible:\", counts_local_repaired is not None)\n",
    "print(\"Reparación hardware disponible:\", counts_hardware_repaired is not None)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "072859f3",
   "metadata": {},
   "source": [
    "## 36. Tabla comparativa de métodos\n",
    "\n",
    "La comparación usa la energía QUBO como métrica principal. El score solo debe interpretarse si la solución es factible."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "44bbe7db",
   "metadata": {},
   "outputs": [],
   "source": [
    "def counts_to_dataframe(counts: dict[str, int] | None, method_name: str) -> pd.DataFrame:\n",
    "    \"\"\"Convierte conteos de muestreo en una tabla evaluada con el QUBO.\"\"\"\n",
    "    normalized = normalizar_conteos(counts, N_VARS)\n",
    "    if normalized is None or len(normalized) == 0:\n",
    "        return pd.DataFrame()\n",
    "\n",
    "    total_shots = int(sum(normalized.values()))\n",
    "    rows = []\n",
    "\n",
    "    for bitstring, count in normalized.items():\n",
    "        x = decode_qiskit_bitstring(bitstring, N_VARS)\n",
    "        rows.append({\n",
    "            \"método\": method_name,\n",
    "            \"bitstring\": bitstring,\n",
    "            \"conteos\": int(count),\n",
    "            \"probabilidad\": int(count) / total_shots,\n",
    "            \"energía_QUBO\": float(qubo_energy(x, Q, qubo_offset)),\n",
    "            \"score\": float(assignment_score(x, S)),\n",
    "            \"factible\": bool(is_feasible(x)),\n",
    "            \"es_óptimo_clásico\": bool(np.array_equal(x, best_x_exact)),\n",
    "        })\n",
    "\n",
    "    df = pd.DataFrame(rows)\n",
    "    return df.sort_values(\n",
    "        by=[\"energía_QUBO\", \"score\", \"probabilidad\"],\n",
    "        ascending=[True, False, False],\n",
    "    ).reset_index(drop=True)\n",
    "\n",
    "\n",
    "def summarize_counts_method(counts: dict[str, int] | None, method_name: str) -> dict[str, Any] | None:\n",
    "    \"\"\"Resume la distribución de conteos de un método.\"\"\"\n",
    "    normalized = normalizar_conteos(counts, N_VARS)\n",
    "    df = counts_to_dataframe(normalized, method_name)\n",
    "    if df.empty:\n",
    "        return None\n",
    "\n",
    "    total_shots = int(sum(normalized.values()))\n",
    "    best = df.iloc[0]\n",
    "\n",
    "    feasible_counts = int(df.loc[df[\"factible\"], \"conteos\"].sum())\n",
    "    optimal_counts = int(df.loc[df[\"es_óptimo_clásico\"], \"conteos\"].sum())\n",
    "\n",
    "    expected_energy = float(np.sum(df[\"energía_QUBO\"] * df[\"probabilidad\"]))\n",
    "    expected_score = float(np.sum(df[\"score\"] * df[\"probabilidad\"]))\n",
    "\n",
    "    return {\n",
    "        \"método\": method_name,\n",
    "        \"shots\": total_shots,\n",
    "        \"energía_mejor_observada\": float(best[\"energía_QUBO\"]),\n",
    "        \"energía_media_muestreo\": expected_energy,\n",
    "        \"score_mejor_observado\": float(best[\"score\"]),\n",
    "        \"score_medio_muestreo\": expected_score,\n",
    "        \"factible_mejor_observado\": bool(best[\"factible\"]),\n",
    "        \"probabilidad_factible\": feasible_counts / total_shots,\n",
    "        \"probabilidad_óptimo_clásico\": optimal_counts / total_shots,\n",
    "        \"bitstring_mejor\": str(best[\"bitstring\"]),\n",
    "    }\n",
    "\n",
    "\n",
    "summary_rows = [\n",
    "    {\n",
    "        \"método\": \"Clásico exacto\",\n",
    "        \"shots\": np.nan,\n",
    "        \"energía_mejor_observada\": best_energy_exact,\n",
    "        \"energía_media_muestreo\": np.nan,\n",
    "        \"score_mejor_observado\": best_score_exact,\n",
    "        \"score_medio_muestreo\": np.nan,\n",
    "        \"factible_mejor_observado\": best_feasible_exact,\n",
    "        \"probabilidad_factible\": np.nan,\n",
    "        \"probabilidad_óptimo_clásico\": np.nan,\n",
    "        \"bitstring_mejor\": x_to_qiskit_bitstring(best_x_exact),\n",
    "    }\n",
    "]\n",
    "\n",
    "for method_name, counts_obj in [\n",
    "    (\"QAOA local\", counts_local),\n",
    "    (\"QAOA local reparado\", counts_local_repaired),\n",
    "    (\"Hardware real\", counts_hardware),\n",
    "    (\"Hardware real reparado\", counts_hardware_repaired),\n",
    "]:\n",
    "    row = summarize_counts_method(counts_obj, method_name)\n",
    "    if row is not None:\n",
    "        summary_rows.append(row)\n",
    "\n",
    "comparacion_metodos_df = pd.DataFrame(summary_rows)\n",
    "display(comparacion_metodos_df)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "80cea06f",
   "metadata": {},
   "source": [
    "## 37. Gráficas comparativas\n",
    "\n",
    "Estas gráficas permiten contrastar QAOA local contra hardware real cuando hay conteos disponibles.\n",
    "\n",
    "Interpretación mínima:\n",
    "\n",
    "- Menor energía QUBO es mejor.\n",
    "- Mayor probabilidad de óptimo clásico es mejor.\n",
    "- Mayor probabilidad factible es mejor.\n",
    "- La reparación clásica se analiza por separado."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1a8887f2",
   "metadata": {},
   "outputs": [],
   "source": [
    "def plot_metric_bar(df: pd.DataFrame, metric: str, ylabel: str, title: str, reference: float | None = None) -> None:\n",
    "    \"\"\"Grafica una métrica por método.\"\"\"\n",
    "    plot_df = df.dropna(subset=[metric]).copy()\n",
    "    if plot_df.empty:\n",
    "        print(f\"No hay datos para graficar: {metric}\")\n",
    "        return\n",
    "\n",
    "    plt.figure(figsize=(8.5, 4.0))\n",
    "    plt.bar(plot_df[\"método\"], plot_df[metric])\n",
    "\n",
    "    if reference is not None:\n",
    "        plt.axhline(reference, linestyle=\"--\", linewidth=1)\n",
    "\n",
    "    plt.ylabel(ylabel)\n",
    "    plt.title(title)\n",
    "    plt.xticks(rotation=25, ha=\"right\")\n",
    "    plt.grid(True, axis=\"y\", alpha=0.3)\n",
    "    plt.tight_layout()\n",
    "    plt.show()\n",
    "\n",
    "\n",
    "plot_metric_bar(\n",
    "    comparacion_metodos_df,\n",
    "    \"energía_mejor_observada\",\n",
    "    \"Energía QUBO\",\n",
    "    \"Mejor energía observada por método; menor es mejor\",\n",
    "    reference=best_energy_exact,\n",
    ")\n",
    "\n",
    "plot_metric_bar(\n",
    "    comparacion_metodos_df,\n",
    "    \"energía_media_muestreo\",\n",
    "    \"Energía QUBO media\",\n",
    "    \"Energía media de muestreo; menor es mejor\",\n",
    "    reference=best_energy_exact,\n",
    ")\n",
    "\n",
    "plot_metric_bar(\n",
    "    comparacion_metodos_df,\n",
    "    \"probabilidad_óptimo_clásico\",\n",
    "    \"Probabilidad\",\n",
    "    \"Probabilidad de observar el óptimo clásico\",\n",
    ")\n",
    "\n",
    "plot_metric_bar(\n",
    "    comparacion_metodos_df,\n",
    "    \"probabilidad_factible\",\n",
    "    \"Probabilidad\",\n",
    "    \"Probabilidad de obtener soluciones factibles\",\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ff759e5a",
   "metadata": {},
   "source": [
    "## 38. Curvas acumuladas local vs hardware\n",
    "\n",
    "El `Sampler` entrega conteos agregados, no el orden temporal real de cada shot. Para construir curvas acumuladas se expande la distribución de conteos y se mezcla con una semilla fija.\n",
    "\n",
    "La curva responde esta pregunta: si acumulamos muestras de esta distribución, ¿qué tan rápido aparece una solución buena?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "45ec433a",
   "metadata": {},
   "outputs": [],
   "source": [
    "CURVE_RANDOM_SEED = SEED + 9001\n",
    "CURVE_MAX_POINTS = 250\n",
    "\n",
    "\n",
    "def expand_counts_to_samples(counts: dict[str, int] | None, seed: int) -> list[str]:\n",
    "    \"\"\"Expande conteos agregados a una lista mezclada de bitstrings.\"\"\"\n",
    "    normalized = normalizar_conteos(counts, N_VARS)\n",
    "    if normalized is None or len(normalized) == 0:\n",
    "        return []\n",
    "\n",
    "    samples = []\n",
    "    for bitstring, count in sorted(normalized.items()):\n",
    "        samples.extend([bitstring] * int(count))\n",
    "\n",
    "    local_rng = np.random.default_rng(seed)\n",
    "    local_rng.shuffle(samples)\n",
    "    return samples\n",
    "\n",
    "\n",
    "def cumulative_curve_from_counts(counts: dict[str, int] | None, method_name: str, seed_offset: int = 0) -> pd.DataFrame:\n",
    "    \"\"\"Construye curvas acumuladas de desempeño a partir de conteos.\"\"\"\n",
    "    samples = expand_counts_to_samples(counts, CURVE_RANDOM_SEED + seed_offset)\n",
    "    if not samples:\n",
    "        return pd.DataFrame()\n",
    "\n",
    "    n_samples = len(samples)\n",
    "    n_points = min(CURVE_MAX_POINTS, n_samples)\n",
    "    checkpoints = set(np.unique(np.linspace(1, n_samples, n_points, dtype=int)).tolist())\n",
    "\n",
    "    best_energy_so_far = float(\"inf\")\n",
    "    feasible_count = 0\n",
    "    optimal_count = 0\n",
    "    rows = []\n",
    "\n",
    "    for shot_index, bitstring in enumerate(samples, start=1):\n",
    "        x = decode_qiskit_bitstring(bitstring, N_VARS)\n",
    "        energy = float(qubo_energy(x, Q, qubo_offset))\n",
    "        best_energy_so_far = min(best_energy_so_far, energy)\n",
    "\n",
    "        if is_feasible(x):\n",
    "            feasible_count += 1\n",
    "\n",
    "        if np.array_equal(x, best_x_exact):\n",
    "            optimal_count += 1\n",
    "\n",
    "        if shot_index in checkpoints:\n",
    "            gap_pct = 100.0 * (best_energy_so_far - best_energy_exact) / abs(best_energy_exact) if best_energy_exact != 0 else np.nan\n",
    "            rows.append({\n",
    "                \"método\": method_name,\n",
    "                \"shots_acumulados\": shot_index,\n",
    "                \"mejor_energía_acumulada\": best_energy_so_far,\n",
    "                \"brecha_mejor_energía_%\": max(0.0, float(gap_pct)) if np.isfinite(gap_pct) else np.nan,\n",
    "                \"factibilidad_%\": 100.0 * feasible_count / shot_index,\n",
    "                \"óptimo_observado_%\": 100.0 * optimal_count / shot_index,\n",
    "            })\n",
    "\n",
    "    return pd.DataFrame(rows)\n",
    "\n",
    "\n",
    "curve_dfs = []\n",
    "for seed_offset, (method_name, counts_obj) in enumerate([\n",
    "    (\"QAOA local\", counts_local),\n",
    "    (\"QAOA local reparado\", counts_local_repaired),\n",
    "    (\"Hardware real\", counts_hardware),\n",
    "    (\"Hardware real reparado\", counts_hardware_repaired),\n",
    "]):\n",
    "    curve_df = cumulative_curve_from_counts(counts_obj, method_name, seed_offset)\n",
    "    if not curve_df.empty:\n",
    "        curve_dfs.append(curve_df)\n",
    "\n",
    "curvas_desempeno_df = pd.concat(curve_dfs, ignore_index=True) if curve_dfs else pd.DataFrame()\n",
    "display(curvas_desempeno_df.head())"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9c82ad52",
   "metadata": {},
   "outputs": [],
   "source": [
    "def plot_curve(metric: str, ylabel: str, title: str, reference: float | None = None) -> None:\n",
    "    \"\"\"Grafica una curva acumulada por método.\"\"\"\n",
    "    if curvas_desempeno_df.empty:\n",
    "        print(\"No hay curvas disponibles.\")\n",
    "        return\n",
    "\n",
    "    plt.figure(figsize=(8.5, 4.5))\n",
    "\n",
    "    if reference is not None:\n",
    "        plt.axhline(reference, linestyle=\"--\", linewidth=1, label=\"Referencia clásica\")\n",
    "\n",
    "    for method_name in curvas_desempeno_df[\"método\"].unique():\n",
    "        method_df = curvas_desempeno_df[curvas_desempeno_df[\"método\"] == method_name]\n",
    "        plt.plot(method_df[\"shots_acumulados\"], method_df[metric], linewidth=2, label=method_name)\n",
    "\n",
    "    plt.xlabel(\"Shots acumulados\")\n",
    "    plt.ylabel(ylabel)\n",
    "    plt.title(title)\n",
    "    plt.grid(True, alpha=0.3)\n",
    "    plt.legend()\n",
    "    plt.tight_layout()\n",
    "    plt.show()\n",
    "\n",
    "\n",
    "plot_curve(\n",
    "    \"mejor_energía_acumulada\",\n",
    "    \"Mejor energía QUBO acumulada\",\n",
    "    \"Curva acumulada de mejor energía; menor es mejor\",\n",
    "    reference=best_energy_exact,\n",
    ")\n",
    "\n",
    "plot_curve(\n",
    "    \"brecha_mejor_energía_%\",\n",
    "    \"Brecha energética (%)\",\n",
    "    \"Curva acumulada de brecha energética; menor es mejor\",\n",
    "    reference=0.0,\n",
    ")\n",
    "\n",
    "plot_curve(\n",
    "    \"factibilidad_%\",\n",
    "    \"Muestras factibles acumuladas (%)\",\n",
    "    \"Curva acumulada de factibilidad; mayor es mejor\",\n",
    "    reference=100.0,\n",
    ")\n",
    "\n",
    "plot_curve(\n",
    "    \"óptimo_observado_%\",\n",
    "    \"Óptimo clásico observado acumulado (%)\",\n",
    "    \"Curva acumulada de observación del óptimo; mayor es mejor\",\n",
    "    reference=100.0,\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fec88a75",
   "metadata": {},
   "source": [
    "## 39. Lectura automática local vs hardware\n",
    "\n",
    "Esta lectura no reemplaza el análisis del equipo. Sirve para detectar rápidamente si hardware real produjo una distribución comparable, peor o mejor que QAOA local en esta instancia pequeña."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1a742da6",
   "metadata": {},
   "outputs": [],
   "source": [
    "def get_comparison_row(method_name: str) -> pd.Series | None:\n",
    "    selected = comparacion_metodos_df[comparacion_metodos_df[\"método\"] == method_name]\n",
    "    if selected.empty:\n",
    "        return None\n",
    "    return selected.iloc[0]\n",
    "\n",
    "\n",
    "local_row = get_comparison_row(\"QAOA local\")\n",
    "hardware_row = get_comparison_row(\"Hardware real\")\n",
    "local_repaired_row = get_comparison_row(\"QAOA local reparado\")\n",
    "hardware_repaired_row = get_comparison_row(\"Hardware real reparado\")\n",
    "\n",
    "print(\"Lectura automática:\")\n",
    "\n",
    "if hardware_row is None:\n",
    "    print(\"- No hay conteos de hardware real. Para completar esta comparación, activa IBM Quantum y ejecuta la sección avanzada.\")\n",
    "else:\n",
    "    if hardware_row[\"energía_mejor_observada\"] <= local_row[\"energía_mejor_observada\"]:\n",
    "        print(\"- Hardware real igualó o mejoró la mejor energía observada por QAOA local.\")\n",
    "    else:\n",
    "        print(\"- QAOA local obtuvo mejor energía observada que hardware real.\")\n",
    "\n",
    "    if hardware_row[\"probabilidad_factible\"] >= local_row[\"probabilidad_factible\"]:\n",
    "        print(\"- Hardware real tuvo igual o mayor proporción de muestras factibles que QAOA local.\")\n",
    "    else:\n",
    "        print(\"- QAOA local tuvo mayor proporción de muestras factibles que hardware real.\")\n",
    "\n",
    "    if hardware_row[\"probabilidad_óptimo_clásico\"] >= local_row[\"probabilidad_óptimo_clásico\"]:\n",
    "        print(\"- Hardware real observó el óptimo clásico con igual o mayor frecuencia que QAOA local.\")\n",
    "    else:\n",
    "        print(\"- QAOA local observó el óptimo clásico con mayor frecuencia que hardware real.\")\n",
    "\n",
    "if hardware_repaired_row is not None and local_repaired_row is not None:\n",
    "    print(\"\\nLectura con reparación clásica:\")\n",
    "    if hardware_repaired_row[\"energía_mejor_observada\"] <= local_repaired_row[\"energía_mejor_observada\"]:\n",
    "        print(\"- Hardware reparado igualó o mejoró la mejor energía local reparada.\")\n",
    "    else:\n",
    "        print(\"- La reparación local obtuvo mejor energía que la reparación de hardware.\")\n",
    "\n",
    "print(\"\\nRegla de reporte:\")\n",
    "print(\"- Contra el clásico exacto, QAOA y hardware solo pueden empatar el óptimo en esta instancia pequeña.\")\n",
    "print(\"- Si la mejora aparece solo después de reparación, debe reportarse como mejora del pipeline híbrido, no del hardware aislado.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "250fc4c7",
   "metadata": {},
   "source": [
    "# Parte E — Entrega obligatoria en GitHub\n",
    "\n",
    "La entrega final es el enlace al repositorio GitHub del estudiante o del equipo. El repositorio debe contener el CSV en `data/`, un `README.md` justificando el dataset y el archivo `.ipynb` listo para abrirse en Google Colab."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5dca4941",
   "metadata": {},
   "source": [
    "## 40. Checklist técnico del repositorio\n",
    "\n",
    "Antes de entregar el enlace de GitHub, verifiquen:\n",
    "\n",
    "```text\n",
    "[ ] El repositorio fue creado desde el perfil GitHub del estudiante, del equipo o de un representante.\n",
    "[ ] Existe una carpeta data/ en la raíz del repositorio.\n",
    "[ ] data/ contiene dataset_real_4x4.csv o un CSV equivalente claramente documentado.\n",
    "[ ] Existe README.md en la raíz del repositorio.\n",
    "[ ] README.md justifica fuente, institución, URL, licencia o condiciones de uso y fecha de consulta.\n",
    "[ ] README.md define A, B, x_ij, S_ij y las restricciones.\n",
    "[ ] README.md explica por qué el problema es matching bipartito.\n",
    "[ ] README.md explica por qué el modelo puede formularse como QUBO.\n",
    "[ ] README.md compara solución clásica exacta contra QAOA local.\n",
    "[ ] Si se ejecutó IBM Quantum, README.md compara QAOA local contra hardware real.\n",
    "[ ] Si se usó reparación clásica, README.md la reporta como postprocesamiento híbrido.\n",
    "[ ] Existe un archivo .ipynb que se puede abrir en Google Colab.\n",
    "[ ] El archivo .ipynb ejecuta todas las celdas en orden sin errores intermedios.\n",
    "[ ] No hay tokens, credenciales ni datos sensibles en el repositorio.\n",
    "[ ] La instancia molecular de ejemplo fue reemplazada por el dataset real o semi-real del equipo.\n",
    "[ ] La advertencia ética y las limitaciones del modelo están explícitas.\n",
    "```\n",
    "\n",
    "La instancia molecular de ejemplo no cuenta como dataset final. Su función es verificar que el código funciona antes de insertar el dataset real o semi-real."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "30fefc43",
   "metadata": {},
   "source": [
    "## 41. Interpretación mínima del resultado\n",
    "\n",
    "El equipo debe responder en el `README.md`:\n",
    "\n",
    "1. ¿Cuál fue la mejor asignación encontrada?\n",
    "2. ¿Cuál fue su score en el dominio?\n",
    "3. ¿La asignación cumple todas las restricciones?\n",
    "4. ¿QAOA local observó el óptimo clásico?\n",
    "5. ¿Qué tan frecuente fue observar soluciones factibles?\n",
    "6. ¿Qué limitaciones tiene el modelo $4\\times 4$?\n",
    "7. ¿Qué cambiaría si el dataset creciera?\n",
    "8. ¿Qué riesgos éticos existen y cómo se mitigaron?\n",
    "9. Si se usó hardware real, ¿cómo compara contra QAOA local?\n",
    "10. Si se usó reparación clásica, ¿qué parte del resultado corresponde al postprocesamiento híbrido?\n",
    "\n",
    "La salida de QAOA no debe presentarse como una recomendación real de política pública, salud, empleo, vivienda o asignación de recursos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c650974e",
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"Resumen automático\")\n",
    "print(\"------------------\")\n",
    "print(\"Instancia:\", NOMBRE_INSTANCIA)\n",
    "print(\"Dataset:\", f\"{len(A_df)} x {len(B_df)}\")\n",
    "print(\"Mejor energía clásica:\", best_energy_exact)\n",
    "print(\"Mejor score clásico:\", best_score_exact)\n",
    "print(\"Mejor asignación clásica:\")\n",
    "display(selected_pairs(best_x_exact))\n",
    "\n",
    "print(\"QAOA local\")\n",
    "print(\"----------\")\n",
    "print(\"Energía esperada:\", best_expected_energy)\n",
    "print(\"Mejor energía observada:\", float(energy_by_state[best_observed_index]))\n",
    "print(\"Probabilidad ideal de factibilidad:\", prob_feasible)\n",
    "print(\"Probabilidad ideal del óptimo clásico:\", prob_exact_optimum)\n",
    "\n",
    "if counts_hardware is not None:\n",
    "    print(\"Hardware real\")\n",
    "    print(\"-------------\")\n",
    "    print(\"Backend:\", hardware_backend_name)\n",
    "    print(\"Job ID:\", hardware_job_id)\n",
    "    print(\"Shots:\", sum(counts_hardware.values()))\n",
    "    print(\"Profundidad ISA:\", hardware_circuit_depth)\n",
    "else:\n",
    "    print(\"Hardware real: no ejecutado.\")\n",
    "\n",
    "if USANDO_DATASET_MOLECULAR_DE_EJEMPLO:\n",
    "    print(\"\\nAdvertencia: la instancia molecular es solo de ejemplo. Para la entrega final debe sustituirse por el dataset real o semi-real del equipo.\")\n",
    "else:\n",
    "    print(\"\\nDataset real/semi-real activo. Verifiquen que el README.md documente la fuente y la construcción de S.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "31e35ab6",
   "metadata": {},
   "source": [
    "## 42. Referencias técnicas sugeridas\n",
    "\n",
    "Para profundizar:\n",
    "\n",
    "- QUBO e Ising Hamiltonians.\n",
    "- Matching bipartito y problema de asignación.\n",
    "- QAOA y algoritmos variacionales.\n",
    "- Mixers que preservan restricciones.\n",
    "- Penalizaciones cuadráticas.\n",
    "- Mitigación de error y transpilación en hardware real."
   ]
  }
 ],
 "metadata": {
  "colab": {
   "provenance": [],
   "toc_visible": true
  },
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.x"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}