From 3d0aef0410753cf2cd1cb2a735c77acaa27fcb4c Mon Sep 17 00:00:00 2001 From: Victor Fraile Garcia Date: Mon, 20 Jul 2026 18:25:39 +0200 Subject: [PATCH] test(ucepsa): add context regression and hardening suite --- ...ext_regression_hardening_shadow_v0.3.13.md | 174 ++ ...sa-shopfloor-context-health.dashboard.json | 362 +++- ...ploy_context_regression_dashboard_v0313.sh | 259 +++ .../ops/manage_context_regression_v0313.sh | 50 + .../ops/validate_context_regression_v0313.sql | 217 ++ ...text_regression_hardening_shadow_v0313.sql | 501 +++++ .../tools/context_regression_suite.py | 1824 +++++++++++++++++ 7 files changed, 3385 insertions(+), 2 deletions(-) create mode 100644 ucepsa/edge-oee-demo/docs/runbooks/112_context_regression_hardening_shadow_v0.3.13.md create mode 100755 ucepsa/edge-oee-demo/ops/deploy_context_regression_dashboard_v0313.sh create mode 100755 ucepsa/edge-oee-demo/ops/manage_context_regression_v0313.sh create mode 100644 ucepsa/edge-oee-demo/ops/validate_context_regression_v0313.sql create mode 100644 ucepsa/edge-oee-demo/sql/versions/115_ucepsa_context_regression_hardening_shadow_v0313.sql create mode 100755 ucepsa/edge-oee-demo/tools/context_regression_suite.py diff --git a/ucepsa/edge-oee-demo/docs/runbooks/112_context_regression_hardening_shadow_v0.3.13.md b/ucepsa/edge-oee-demo/docs/runbooks/112_context_regression_hardening_shadow_v0.3.13.md new file mode 100644 index 0000000..04013f2 --- /dev/null +++ b/ucepsa/edge-oee-demo/docs/runbooks/112_context_regression_hardening_shadow_v0.3.13.md @@ -0,0 +1,174 @@ +# UCEPSA — Regresión y hardening SHADOW v0.3.13 + +## Objetivo + +Proteger automáticamente los comportamientos ya validados: + +```text +cierre Shop Floor +arbitraje canónico +contexto legacy +conflictos sin prioridad silenciosa +casos raíz +decisiones consolidadas +agenda diaria +snapshots +readiness +guardas oficiales +``` + +La suite no modifica Odoo ni datos de producción. + +## Contratos principales + +```text +WO 340: +sesión cerrada +100 kg finales +Esteban confirmado +sin extensión posterior al cierre + +Episodio 1: +LEGACY_ERP autorizado +intervalo exacto +cobertura canónica completa + +WO 649/698: +conflicto histórico conservado +sin clave canónica + +Gobernanza: +7/8/9 → un caso raíz +1-4, 5-6 y 7-8 → tres decisiones +agenda = backlog +snapshot 1 coherente + +SHADOW: +cero filas oficiales +``` + +## Objetos + +```text +context_regression_policies +context_regression_runs +context_regression_results + +li_context_regression_latest_run_v1 +li_context_regression_latest_results_v1 +li_context_regression_failures_v1 +li_context_regression_run_history_v1 +li_context_regression_health_v1 +``` + +## Despliegue + +```bash +FILE=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/sql/versions/115_ucepsa_context_regression_hardening_shadow_v0313.sql + +docker exec -i mv_ucepsa_postgres_hot sh -lc \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + < "$FILE" +``` + +## Runtime + +```bash +RUNTIME=/srv/mesavault/edge-oee-ucepsa +TARGET=/srv/mesavault/40-clients/ucepsa/edge-oee-demo + +install -m 0644 \ + "$TARGET/tools/context_regression_suite.py" \ + "$RUNTIME/tools/" +``` + +## Ejecución previa sin persistencia + +```bash +MANAGER=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/manage_context_regression_v0313.sh + +"$MANAGER" dry-run \ + --triggered-by "Víctor Fraile" +``` + +## Primera ejecución persistente + +```bash +"$MANAGER" run \ + --triggered-by "Víctor Fraile" \ + --trigger-kind DEPLOYMENT \ + --notes "Validación posterior al despliegue v0.3.13." +``` + +`PASS` es el resultado ideal. `WARN` es admisible si se limita a salud o +rendimiento. `FAIL` o `ERROR` requiere investigación antes de continuar. + +## Consultas + +```bash +"$MANAGER" show +"$MANAGER" list --limit 20 +"$MANAGER" show --run-id NUMERO +``` + +## Exportar Markdown + +```bash +OUT=/srv/mesavault/exports/context-regression-$(date +%F).md + +"$MANAGER" export-markdown \ + > "$OUT" + +sed -n '1,260p' "$OUT" +``` + +## Validación + +```bash +VALIDATION=/srv/mesavault/40-clients/ucepsa/edge-oee-demo/ops/validate_context_regression_v0313.sql + +docker exec -i mv_ucepsa_postgres_hot sh -lc \ + 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \ + < "$VALIDATION" +``` + +Valores obligatorios: + +```text +run_count_mismatches = 0 +duplicate_test_codes = 0 +suite_complete = true +latest_fail_or_error_count = 0 +critical_contract_not_passed = 0 + +official_run_rows = 0 +official_result_rows = 0 +official_health_rows = 0 +official_operator_queue_rows = 0 +``` + +## Uso operativo recomendado + +Ejecutar la suite: + +```text +después de cada migración +después de modificar sincronizadores o arbitraje +antes de desplegar el dashboard +antes de activar cualquier nueva fase de readiness +``` + +La ejecución periódica puede programarse después de acordar la hora adecuada. +No se instala ningún cron automáticamente en esta versión. + +## Dashboard + +Se mantiene el mismo UID y se añaden: + +```text +Última ejecución de regresión +Warnings y fallos +Histórico de regresión +``` + +Paneles totales esperados: 31. diff --git a/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json b/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json index 50420c0..c97f097 100644 --- a/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json +++ b/ucepsa/edge-oee-demo/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json @@ -15,7 +15,7 @@ } ] }, - "description": "Supervisión SHADOW de sesiones, arbitraje, casos raíz, decisiones humanas y agenda diaria de gobernanza. La revisión se organiza por preguntas operativas y puede guardarse como snapshot auditable.", + "description": "Supervisión SHADOW de sesiones, gobernanza diaria y regresión funcional. La suite protege contratos históricos, arbitraje, agrupación, agenda y guardas oficiales sin modificar datos productivos.", "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 1, @@ -3286,6 +3286,360 @@ ], "title": "Indicadores de gobernanza diaria", "type": "table" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Estado" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "PASS": { + "text": "PASS", + "color": "green", + "index": 0 + }, + "WARN": { + "text": "WARN", + "color": "orange", + "index": 1 + }, + "FAIL": { + "text": "FAIL", + "color": "red", + "index": 2 + }, + "ERROR": { + "text": "ERROR", + "color": "red", + "index": 3 + } + } + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 218 + }, + "id": 29, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n run_id AS \"Ejecución\",\n suite_version AS \"Suite\",\n overall_status AS \"Estado\",\n triggered_by AS \"Disparada por\",\n trigger_kind AS \"Tipo\",\n started_at AS \"Inicio\",\n finished_at AS \"Fin\",\n duration_ms AS \"Duración ms\",\n result_count AS \"Tests\",\n pass_count AS \"PASS\",\n warn_count AS \"WARN\",\n fail_count AS \"FAIL\",\n error_count AS \"ERROR\",\n source_git_commit AS \"Commit\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_regression_latest_run_v1", + "refId": "A" + } + ], + "title": "Última ejecución de regresión", + "type": "table" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Estado" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "PASS": { + "text": "PASS", + "color": "green", + "index": 0 + }, + "WARN": { + "text": "WARN", + "color": "orange", + "index": 1 + }, + "FAIL": { + "text": "FAIL", + "color": "red", + "index": 2 + }, + "ERROR": { + "text": "ERROR", + "color": "red", + "index": 3 + } + } + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Contrato" + }, + "properties": [ + { + "id": "custom.width", + "value": 420 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Detalle" + }, + "properties": [ + { + "id": "custom.width", + "value": 520 + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 226 + }, + "id": 30, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n test_code AS \"Test\",\n test_group AS \"Grupo\",\n test_title AS \"Contrato\",\n status AS \"Estado\",\n severity AS \"Severidad\",\n detail AS \"Detalle\",\n actual_json::text AS \"Resultado\",\n duration_ms AS \"Duración ms\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_regression_failures_v1\nORDER BY\n CASE status\n WHEN 'ERROR' THEN 1\n WHEN 'FAIL' THEN 2\n ELSE 3\n END,\n test_code", + "refId": "A" + } + ], + "title": "Warnings y fallos de regresión", + "type": "table" + }, + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": true, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Estado" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "type": "value", + "options": { + "PASS": { + "text": "PASS", + "color": "green", + "index": 0 + }, + "WARN": { + "text": "WARN", + "color": "orange", + "index": 1 + }, + "FAIL": { + "text": "FAIL", + "color": "red", + "index": 2 + }, + "ERROR": { + "text": "ERROR", + "color": "red", + "index": 3 + } + } + } + ] + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 236 + }, + "id": 31, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "postgres", + "uid": "bfnbcasbm6hhca" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "SELECT\n run_id AS \"Ejecución\",\n overall_status AS \"Estado\",\n triggered_by AS \"Disparada por\",\n trigger_kind AS \"Tipo\",\n started_at AS \"Inicio\",\n duration_ms AS \"Duración ms\",\n result_count AS \"Tests\",\n pass_count AS \"PASS\",\n warn_count AS \"WARN\",\n fail_count AS \"FAIL\",\n error_count AS \"ERROR\",\n source_git_commit AS \"Commit\"\nFROM\n mv_reports_ucepsa_prod\n .li_context_regression_run_history_v1\nORDER BY\n started_at DESC,\n run_id DESC\nLIMIT 50", + "refId": "A" + } + ], + "title": "Histórico de regresión · 30 días", + "type": "table" } ], "refresh": "15s", @@ -3314,7 +3668,11 @@ "daily-governance", "agenda", "evidence-pack", - "v0312" + "v0312", + "regression", + "hardening", + "contracts", + "v0313" ], "templating": { "list": [] diff --git a/ucepsa/edge-oee-demo/ops/deploy_context_regression_dashboard_v0313.sh b/ucepsa/edge-oee-demo/ops/deploy_context_regression_dashboard_v0313.sh new file mode 100755 index 0000000..bfc7703 --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/deploy_context_regression_dashboard_v0313.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTION="${1:-deploy}" +GRAFANA_CONTAINER="${GRAFANA_CONTAINER:-mv_ucepsa_grafana}" +REPO_ROOT="${REGRESSION_DASHBOARD_REPO_ROOT:-/srv/mesavault/40-clients/ucepsa/edge-oee-demo}" +DASHBOARD_FILE="${REGRESSION_DASHBOARD_FILE:-$REPO_ROOT/grafana/dashboards/ucepsa-shopfloor-context-health.dashboard.json}" +FOLDER_UID="${REGRESSION_DASHBOARD_FOLDER_UID:-ucepsa-mesavault}" +DASHBOARD_UID="ucepsa-shopfloor-context-health" +DATASOURCE_UID="bfnbcasbm6hhca" + +resolve_url() { + if [[ -n "${GRAFANA_URL:-}" ]]; then + printf '%s' "$GRAFANA_URL" + return + fi + + local port_line host_port container_ip + port_line="$( + docker port "$GRAFANA_CONTAINER" 3000/tcp 2>/dev/null \ + | head -n 1 || true + )" + + if [[ -n "$port_line" ]]; then + host_port="${port_line##*:}" + printf 'http://127.0.0.1:%s' "$host_port" + return + fi + + container_ip="$( + docker inspect "$GRAFANA_CONTAINER" \ + --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' + )" + + if [[ -z "$container_ip" ]]; then + echo "ERROR: no se pudo resolver Grafana." >&2 + exit 1 + fi + + printf 'http://%s:3000' "$container_ip" +} + +if [[ -z "${GRAFANA_API_TOKEN:-}" ]]; then + echo "ERROR: falta GRAFANA_API_TOKEN." >&2 + exit 1 +fi + +export GRAFANA_URL_RESOLVED +GRAFANA_URL_RESOLVED="$(resolve_url)" + +python3 - \ + "$ACTION" \ + "$DASHBOARD_FILE" \ + "$FOLDER_UID" \ + "$DASHBOARD_UID" \ + "$DATASOURCE_UID" <<'PY' +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +action, dashboard_file, folder_uid, dashboard_uid, datasource_uid = sys.argv[1:] +base_url = os.environ["GRAFANA_URL_RESOLVED"].rstrip("/") +token = os.environ["GRAFANA_API_TOKEN"] + + +def request(method, path, payload=None): + body = None + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {token}", + } + + if payload is not None: + body = json.dumps( + payload, + ensure_ascii=False, + ).encode("utf-8") + headers["Content-Type"] = "application/json" + + req = urllib.request.Request( + base_url + path, + data=body, + headers=headers, + method=method, + ) + + try: + with urllib.request.urlopen( + req, + timeout=20, + ) as response: + raw = response.read() + return ( + json.loads(raw.decode("utf-8")) + if raw + else None + ) + except urllib.error.HTTPError as exc: + detail = exc.read().decode( + "utf-8", + errors="replace", + ) + raise RuntimeError( + f"Grafana API {method} {path}: " + f"HTTP {exc.code}: {detail}" + ) from exc + + +health = request("GET", "/api/health") +if not health or health.get("database") != "ok": + raise RuntimeError( + f"Grafana no está saludable: {health!r}" + ) + +if action == "deploy": + with open( + dashboard_file, + encoding="utf-8", + ) as handle: + dashboard = json.load(handle) + + result = request( + "POST", + "/api/dashboards/db", + { + "dashboard": dashboard, + "folderUid": folder_uid, + "overwrite": True, + "message": ( + "MESAVAULT regression hardening " + "SHADOW v0.3.13" + ), + }, + ) + print(json.dumps( + { + "status": "deployed", + "dashboard_uid": dashboard_uid, + "folder_uid": folder_uid, + "response": result, + }, + ensure_ascii=False, + indent=2, + )) + +elif action == "validate": + result = request( + "GET", + "/api/dashboards/uid/" + + urllib.parse.quote(dashboard_uid), + ) + dashboard = result["dashboard"] + panels = { + panel.get("id"): panel + for panel in dashboard.get("panels", []) + } + + datasource_uids = sorted({ + panel.get("datasource", {}).get("uid") + for panel in dashboard.get("panels", []) + if isinstance( + panel.get("datasource"), + dict, + ) + and panel.get( + "datasource", + {}, + ).get("uid") + }) + + errors = [] + + if len(dashboard.get("panels", [])) != 31: + errors.append( + "El dashboard no tiene 31 paneles" + ) + + if dashboard.get("refresh") != "15s": + errors.append( + "El refresco no está en 15s" + ) + + if datasource_uids != [datasource_uid]: + errors.append( + f"Datasource inesperado: {datasource_uids!r}" + ) + + expected = { + 29: "li_context_regression_latest_run_v1", + 30: "li_context_regression_failures_v1", + 31: "li_context_regression_run_history_v1", + } + + for panel_id, fragment in expected.items(): + sql = ( + panels.get(panel_id, {}) + .get("targets", [{}])[0] + .get("rawSql", "") + ) + if fragment not in sql: + errors.append( + f"Panel {panel_id} no consulta {fragment}" + ) + + output = { + "status": + "ok" if not errors else "error", + "dashboard_uid": + dashboard.get("uid"), + "folder_uid": + result.get( + "meta", + {}, + ).get("folderUid"), + "panel_count": + len( + dashboard.get( + "panels", + [], + ) + ), + "refresh": + dashboard.get("refresh"), + "datasource_uids": + datasource_uids, + "version": + dashboard.get("version"), + "url": + result.get( + "meta", + {}, + ).get("url"), + "regression_panels": { + "latest_run": 29 in panels, + "issues": 30 in panels, + "history": 31 in panels, + }, + "errors": + errors, + } + + print(json.dumps( + output, + ensure_ascii=False, + indent=2, + )) + + if errors: + raise SystemExit(1) + +else: + raise SystemExit( + "Uso: deploy_context_regression_dashboard_v0313.sh " + "{deploy|validate}" + ) +PY diff --git a/ucepsa/edge-oee-demo/ops/manage_context_regression_v0313.sh b/ucepsa/edge-oee-demo/ops/manage_context_regression_v0313.sh new file mode 100755 index 0000000..fa703a9 --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/manage_context_regression_v0313.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTION="${1:-show}" +ROOT="${CONTEXT_REGRESSION_RUNTIME_ROOT:-/srv/mesavault/edge-oee-ucepsa}" +REPO="${CONTEXT_REGRESSION_REPO_ROOT:-/srv/mesavault/40-clients}" +NETWORK="${CONTEXT_REGRESSION_NETWORK:-mv_ucepsa_net}" +POSTGRES_CONTAINER="${CONTEXT_REGRESSION_POSTGRES_CONTAINER:-mv_ucepsa_postgres_hot}" +PGHOST_OVERRIDE="${CONTEXT_REGRESSION_PGHOST_OVERRIDE:-mv_ucepsa_postgres_hot}" + +cd "$ROOT" + +IMAGE="$( + docker inspect \ + mv_ucepsa_odoo_order_state_pg_sink \ + --format '{{.Config.Image}}' +)" + +PGDATABASE_OVERRIDE="${CONTEXT_REGRESSION_PGDATABASE_OVERRIDE:-$( + docker inspect "$POSTGRES_CONTAINER" \ + --format '{{range .Config.Env}}{{println .}}{{end}}' \ + | sed -n 's/^POSTGRES_DB=//p' \ + | head -n 1 +)}" + +if [[ -z "$PGDATABASE_OVERRIDE" ]]; then + echo "ERROR: no se pudo determinar POSTGRES_DB." >&2 + exit 1 +fi + +SOURCE_GIT_COMMIT="$( + git -C "$REPO" rev-parse HEAD 2>/dev/null \ + || printf 'unknown' +)" + +shift || true + +docker run --rm -i \ + --network "$NETWORK" \ + --env-file .env \ + -e "PGHOST=$PGHOST_OVERRIDE" \ + -e "PGDATABASE=$PGDATABASE_OVERRIDE" \ + -e "SOURCE_GIT_COMMIT=$SOURCE_GIT_COMMIT" \ + -v "$ROOT:/app:ro" \ + -w /app \ + --entrypoint python \ + "$IMAGE" \ + tools/context_regression_suite.py \ + "$ACTION" \ + "$@" diff --git a/ucepsa/edge-oee-demo/ops/validate_context_regression_v0313.sql b/ucepsa/edge-oee-demo/ops/validate_context_regression_v0313.sql new file mode 100644 index 0000000..ad965e8 --- /dev/null +++ b/ucepsa/edge-oee-demo/ops/validate_context_regression_v0313.sql @@ -0,0 +1,217 @@ +\pset pager off + +\echo '=== 1. Objetos v0.3.13 ===' + +SELECT + to_regclass( + 'mv_loss_intelligence.context_regression_policies' + ) AS policy_table, + to_regclass( + 'mv_loss_intelligence.context_regression_runs' + ) AS runs_table, + to_regclass( + 'mv_loss_intelligence.context_regression_results' + ) AS results_table, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_regression_latest_run_v1' + ) AS latest_run_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_regression_latest_results_v1' + ) AS latest_results_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_regression_failures_v1' + ) AS failures_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_regression_run_history_v1' + ) AS history_view, + to_regclass( + 'mv_reports_ucepsa_prod.li_context_regression_health_v1' + ) AS health_view; + +\echo '=== 2. Última ejecución ===' + +SELECT * +FROM + mv_reports_ucepsa_prod + .li_context_regression_latest_run_v1; + +\echo '=== 3. Salud de regresión ===' + +SELECT * +FROM + mv_reports_ucepsa_prod + .li_context_regression_health_v1; + +\echo '=== 4. Conteos de resultados coherentes ===' + +SELECT COUNT(*) AS run_count_mismatches +FROM + mv_loss_intelligence.context_regression_runs r +WHERE r.result_count <> + ( + SELECT COUNT(*) + FROM + mv_loss_intelligence + .context_regression_results x + WHERE x.run_id = + r.run_id + ) + OR r.pass_count <> + ( + SELECT COUNT(*) + FROM + mv_loss_intelligence + .context_regression_results x + WHERE x.run_id = + r.run_id + AND x.status = 'PASS' + ) + OR r.warn_count <> + ( + SELECT COUNT(*) + FROM + mv_loss_intelligence + .context_regression_results x + WHERE x.run_id = + r.run_id + AND x.status = 'WARN' + ) + OR r.fail_count <> + ( + SELECT COUNT(*) + FROM + mv_loss_intelligence + .context_regression_results x + WHERE x.run_id = + r.run_id + AND x.status = 'FAIL' + ) + OR r.error_count <> + ( + SELECT COUNT(*) + FROM + mv_loss_intelligence + .context_regression_results x + WHERE x.run_id = + r.run_id + AND x.status = 'ERROR' + ); + +\echo '=== 5. Códigos de test únicos por ejecución ===' + +SELECT COUNT(*) AS duplicate_test_codes +FROM ( + SELECT + run_id, + test_code, + COUNT(*) AS n + FROM + mv_loss_intelligence.context_regression_results + GROUP BY + run_id, + test_code + HAVING COUNT(*) > 1 +) duplicated; + +\echo '=== 6. La última suite está completa ===' + +SELECT + l.run_id, + l.overall_status, + l.result_count, + p.minimum_expected_test_count, + ( + l.result_count >= + p.minimum_expected_test_count + ) AS suite_complete +FROM + mv_reports_ucepsa_prod + .li_context_regression_latest_run_v1 l +JOIN + mv_loss_intelligence.context_regression_policies p + ON p.tenant = + l.tenant + AND p.site = + l.site + AND p.policy_code = + l.policy_code +WHERE p.active; + +\echo '=== 7. Fallos o errores críticos en la última suite ===' + +SELECT COUNT(*) AS latest_fail_or_error_count +FROM + mv_reports_ucepsa_prod + .li_context_regression_latest_results_v1 +WHERE status IN ( + 'FAIL', + 'ERROR' +); + +\echo '=== 8. Contratos críticos conocidos ===' + +SELECT COUNT(*) AS critical_contract_not_passed +FROM + mv_reports_ucepsa_prod + .li_context_regression_latest_results_v1 +WHERE test_code IN ( + 'CANON-001', + 'CANON-002', + 'CANON-003', + 'CANON-004', + 'WO340-001', + 'WO340-002', + 'LEGACY-001', + 'LEGACY-002', + 'CONFLICT-001', + 'CONFLICT-002', + 'CASE-001', + 'DECISION-001', + 'AGENDA-001', + 'SNAPSHOT-001', + 'SHADOW-001' +) + AND status <> 'PASS'; + +\echo '=== 9. Warnings informativos ===' + +SELECT + test_code, + test_group, + test_title, + status, + severity, + actual_json, + detail, + duration_ms +FROM + mv_reports_ucepsa_prod + .li_context_regression_failures_v1; + +\echo '=== 10. Nada oficial ===' + +SELECT + ( + SELECT COUNT(*) + FROM + mv_loss_intelligence.context_regression_runs + WHERE official_eligible + ) AS official_run_rows, + ( + SELECT COUNT(*) + FROM + mv_loss_intelligence.context_regression_results + WHERE official_eligible + ) AS official_result_rows, + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_context_regression_health_v1 + WHERE official_eligible + ) AS official_health_rows, + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod.li_operator_stop_queue_v2 + ) AS official_operator_queue_rows; diff --git a/ucepsa/edge-oee-demo/sql/versions/115_ucepsa_context_regression_hardening_shadow_v0313.sql b/ucepsa/edge-oee-demo/sql/versions/115_ucepsa_context_regression_hardening_shadow_v0313.sql new file mode 100644 index 0000000..b2b5c25 --- /dev/null +++ b/ucepsa/edge-oee-demo/sql/versions/115_ucepsa_context_regression_hardening_shadow_v0313.sql @@ -0,0 +1,501 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS +mv_loss_intelligence.context_regression_policies ( + tenant text NOT NULL, + site text NOT NULL, + policy_code text NOT NULL, + suite_version text NOT NULL, + stale_after_hours numeric(10, 2) NOT NULL DEFAULT 26 + CHECK (stale_after_hours > 0), + performance_warning_ms integer NOT NULL DEFAULT 2000 + CHECK (performance_warning_ms > 0), + minimum_expected_test_count integer NOT NULL DEFAULT 20 + CHECK (minimum_expected_test_count > 0), + active boolean NOT NULL DEFAULT true, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ( + tenant, + site, + policy_code + ) +); + +COMMENT ON TABLE +mv_loss_intelligence.context_regression_policies IS +'Política SHADOW para pruebas de regresión del contexto, arbitraje, gobernanza y guardas oficiales.'; + +INSERT INTO +mv_loss_intelligence.context_regression_policies ( + tenant, + site, + policy_code, + suite_version, + stale_after_hours, + performance_warning_ms, + minimum_expected_test_count, + active, + notes +) +VALUES ( + 'ucepsa', + 'ucepsa_onpremise', + 'CONTEXT_REGRESSION_V1', + '0.3.13', + 26, + 2000, + 20, + true, + 'La suite protege contratos históricos e invariantes estructurales. Las comprobaciones de salud o rendimiento pueden producir WARNING sin ocultar fallos funcionales.' +) +ON CONFLICT ( + tenant, + site, + policy_code +) +DO UPDATE SET + suite_version = + EXCLUDED.suite_version, + stale_after_hours = + EXCLUDED.stale_after_hours, + performance_warning_ms = + EXCLUDED.performance_warning_ms, + minimum_expected_test_count = + EXCLUDED.minimum_expected_test_count, + active = true, + notes = + EXCLUDED.notes, + updated_at = now(); + +CREATE TABLE IF NOT EXISTS +mv_loss_intelligence.context_regression_runs ( + run_id bigserial PRIMARY KEY, + tenant text NOT NULL, + site text NOT NULL, + policy_code text NOT NULL, + suite_version text NOT NULL, + overall_status text NOT NULL + CHECK ( + overall_status IN ( + 'RUNNING', + 'PASS', + 'WARN', + 'FAIL', + 'ERROR' + ) + ), + triggered_by text NOT NULL, + trigger_kind text NOT NULL DEFAULT 'MANUAL' + CHECK ( + trigger_kind IN ( + 'MANUAL', + 'DEPLOYMENT', + 'SCHEDULED', + 'CI' + ) + ), + started_at timestamptz NOT NULL DEFAULT now(), + finished_at timestamptz, + duration_ms numeric(14, 2), + result_count integer NOT NULL DEFAULT 0 + CHECK (result_count >= 0), + pass_count integer NOT NULL DEFAULT 0 + CHECK (pass_count >= 0), + warn_count integer NOT NULL DEFAULT 0 + CHECK (warn_count >= 0), + fail_count integer NOT NULL DEFAULT 0 + CHECK (fail_count >= 0), + skip_count integer NOT NULL DEFAULT 0 + CHECK (skip_count >= 0), + error_count integer NOT NULL DEFAULT 0 + CHECK (error_count >= 0), + source_git_commit text, + source_state_hash text NOT NULL, + notes text, + official_eligible boolean NOT NULL DEFAULT false + CHECK (official_eligible = false), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_context_regression_run_time + CHECK ( + finished_at IS NULL + OR finished_at >= started_at + ) +); + +CREATE INDEX IF NOT EXISTS +idx_context_regression_runs_latest +ON +mv_loss_intelligence.context_regression_runs ( + tenant, + site, + started_at DESC, + run_id DESC +); + +CREATE TABLE IF NOT EXISTS +mv_loss_intelligence.context_regression_results ( + result_id bigserial PRIMARY KEY, + run_id bigint NOT NULL + REFERENCES + mv_loss_intelligence.context_regression_runs ( + run_id + ) + ON DELETE CASCADE, + test_code text NOT NULL, + test_group text NOT NULL, + test_title text NOT NULL, + status text NOT NULL + CHECK ( + status IN ( + 'PASS', + 'WARN', + 'FAIL', + 'SKIP', + 'ERROR' + ) + ), + severity text NOT NULL + CHECK ( + severity IN ( + 'INFO', + 'WARNING', + 'CRITICAL' + ) + ), + expected_json jsonb NOT NULL DEFAULT '{}'::jsonb, + actual_json jsonb NOT NULL DEFAULT '{}'::jsonb, + detail text, + duration_ms numeric(14, 2) NOT NULL + CHECK (duration_ms >= 0), + evidence_json jsonb NOT NULL DEFAULT '{}'::jsonb, + observed_at timestamptz NOT NULL DEFAULT now(), + official_eligible boolean NOT NULL DEFAULT false + CHECK (official_eligible = false), + CONSTRAINT ux_context_regression_run_test + UNIQUE ( + run_id, + test_code + ) +); + +CREATE INDEX IF NOT EXISTS +idx_context_regression_results_status +ON +mv_loss_intelligence.context_regression_results ( + run_id, + status, + severity, + test_code +); + +COMMENT ON TABLE +mv_loss_intelligence.context_regression_runs IS +'Ejecuciones auditadas de la suite de regresión v0.3.13.'; + +COMMENT ON TABLE +mv_loss_intelligence.context_regression_results IS +'Resultado individual de cada contrato de regresión. No altera datos de producción ni elegibilidad oficial.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_regression_latest_run_v1 +AS +SELECT DISTINCT ON ( + tenant, + site +) + run_id, + tenant, + site, + policy_code, + suite_version, + overall_status, + triggered_by, + trigger_kind, + started_at, + finished_at, + duration_ms, + result_count, + pass_count, + warn_count, + fail_count, + skip_count, + error_count, + source_git_commit, + source_state_hash, + notes, + official_eligible +FROM + mv_loss_intelligence.context_regression_runs +ORDER BY + tenant, + site, + started_at DESC, + run_id DESC; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_regression_latest_run_v1 IS +'Última ejecución de regresión por tenant y site.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_regression_latest_results_v1 +AS +SELECT + r.run_id, + r.overall_status, + r.started_at, + r.finished_at, + r.source_git_commit, + x.test_code, + x.test_group, + x.test_title, + x.status, + x.severity, + x.expected_json, + x.actual_json, + x.detail, + x.duration_ms, + x.evidence_json, + x.observed_at, + x.official_eligible +FROM + mv_reports_ucepsa_prod + .li_context_regression_latest_run_v1 r +JOIN + mv_loss_intelligence.context_regression_results x + ON x.run_id = + r.run_id; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_regression_latest_results_v1 IS +'Resultados de la última suite de regresión.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_regression_failures_v1 +AS +SELECT * +FROM + mv_reports_ucepsa_prod + .li_context_regression_latest_results_v1 +WHERE status IN ( + 'WARN', + 'FAIL', + 'ERROR' +) +ORDER BY + CASE status + WHEN 'ERROR' THEN 1 + WHEN 'FAIL' THEN 2 + ELSE 3 + END, + CASE severity + WHEN 'CRITICAL' THEN 1 + WHEN 'WARNING' THEN 2 + ELSE 3 + END, + test_code; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_regression_failures_v1 IS +'Warnings, fallos y errores de la última ejecución.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_regression_run_history_v1 +AS +SELECT + run_id, + tenant, + site, + suite_version, + overall_status, + triggered_by, + trigger_kind, + started_at, + finished_at, + duration_ms, + result_count, + pass_count, + warn_count, + fail_count, + skip_count, + error_count, + source_git_commit, + source_state_hash, + notes, + official_eligible +FROM + mv_loss_intelligence.context_regression_runs +WHERE started_at >= + now() - interval '30 days' +ORDER BY + started_at DESC, + run_id DESC; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_regression_run_history_v1 IS +'Histórico de ejecuciones de regresión de los últimos 30 días.'; + +CREATE OR REPLACE VIEW +mv_reports_ucepsa_prod.li_context_regression_health_v1 +AS +WITH policy AS ( + SELECT * + FROM + mv_loss_intelligence.context_regression_policies + WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND policy_code = + 'CONTEXT_REGRESSION_V1' + AND active + ORDER BY updated_at DESC + LIMIT 1 +), +latest AS ( + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_regression_latest_run_v1 + WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' +) +SELECT + p.tenant, + p.site, + p.policy_code, + p.suite_version + AS configured_suite_version, + p.stale_after_hours, + p.performance_warning_ms, + p.minimum_expected_test_count, + l.run_id, + l.suite_version + AS latest_suite_version, + l.overall_status, + l.started_at, + l.finished_at, + l.result_count, + l.pass_count, + l.warn_count, + l.fail_count, + l.skip_count, + l.error_count, + l.duration_ms, + l.source_git_commit, + CASE + WHEN l.started_at IS NULL + THEN NULL::numeric + ELSE ROUND( + GREATEST( + EXTRACT( + EPOCH FROM ( + now() + - l.started_at + ) + ) / 3600.0, + 0 + )::numeric, + 2 + ) + END AS hours_since_run, + CASE + WHEN l.run_id IS NULL + THEN 'NOT_INITIALIZED' + WHEN l.overall_status IN ( + 'FAIL', + 'ERROR' + ) + THEN 'CRITICAL' + WHEN l.started_at < + now() + - ( + p.stale_after_hours + * interval '1 hour' + ) + THEN 'STALE' + WHEN l.result_count < + p.minimum_expected_test_count + THEN 'INCOMPLETE' + WHEN l.overall_status = + 'WARN' + THEN 'WARNING' + WHEN l.overall_status = + 'PASS' + THEN 'HEALTHY' + ELSE 'RUNNING' + END AS regression_health_status, + CASE + WHEN l.run_id IS NULL + OR l.overall_status IN ( + 'FAIL', + 'ERROR' + ) + OR l.started_at < + now() + - ( + p.stale_after_hours + * interval '1 hour' + ) + OR l.result_count < + p.minimum_expected_test_count + THEN 'CRITICAL' + WHEN l.overall_status = + 'WARN' + THEN 'WARNING' + WHEN l.overall_status = + 'PASS' + THEN 'OK' + ELSE 'INFO' + END AS severity, + false AS official_eligible, + now() AS observed_at +FROM policy p +LEFT JOIN latest l + ON true; + +COMMENT ON VIEW +mv_reports_ucepsa_prod.li_context_regression_health_v1 IS +'Salud de la última suite: estado, antigüedad, completitud y versión.'; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = + 'grafana_ucepsa_ro' + ) THEN + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_regression_latest_run_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_regression_latest_results_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_regression_failures_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_regression_run_history_v1 + TO grafana_ucepsa_ro; + + GRANT SELECT + ON + mv_reports_ucepsa_prod + .li_context_regression_health_v1 + TO grafana_ucepsa_ro; + END IF; +END +$$; + +COMMIT; diff --git a/ucepsa/edge-oee-demo/tools/context_regression_suite.py b/ucepsa/edge-oee-demo/tools/context_regression_suite.py new file mode 100755 index 0000000..5a27204 --- /dev/null +++ b/ucepsa/edge-oee-demo/tools/context_regression_suite.py @@ -0,0 +1,1824 @@ +#!/usr/bin/env python3 +"""Suite de regresión y hardening UCEPSA v0.3.13.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +import time +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal +from typing import Any, Callable + +import psycopg2 +from psycopg2.extras import Json, RealDictCursor + + +VERSION = "0.3.13" +POLICY_CODE = "CONTEXT_REGRESSION_V1" + + +def env_first(*names: str, default: str | None = None) -> str | None: + for name in names: + value = os.getenv(name) + if value not in (None, ""): + return value + return default + + +def connect(): + database = env_first("PGDATABASE", "POSTGRES_DB") + user = env_first("PGUSER", "POSTGRES_USER") + password = env_first("PGPASSWORD", "POSTGRES_PASSWORD") + + missing = [ + name + for name, value in ( + ("PGDATABASE", database), + ("PGUSER", user), + ("PGPASSWORD", password), + ) + if not value + ] + if missing: + raise RuntimeError( + "Faltan variables: " + ", ".join(missing) + ) + + return psycopg2.connect( + host=env_first( + "PGHOST", + default="mv_ucepsa_postgres_hot", + ), + port=int( + env_first("PGPORT", default="5432") + or "5432" + ), + dbname=database, + user=user, + password=password, + connect_timeout=10, + application_name="context_regression_v0313", + ) + + +def json_safe(value: Any) -> Any: + if isinstance(value, dict): + return { + str(key): json_safe(item) + for key, item in value.items() + } + if isinstance(value, (list, tuple, set)): + return [ + json_safe(item) + for item in value + ] + if isinstance(value, (datetime, date)): + return value.isoformat() + if isinstance(value, Decimal): + return str(value) + return value + + +def print_json(value: Any) -> None: + print( + json.dumps( + json_safe(value), + ensure_ascii=False, + indent=2, + ) + ) + + +@dataclass(frozen=True) +class TestCase: + code: str + group: str + title: str + severity: str + failure_status: str + sql: str + expected: dict[str, Any] + detail_pass: str + detail_fail: str + performance_only: bool = False + + +def contract_tests() -> list[TestCase]: + return [ + TestCase( + code="SCHEMA-001", + group="SCHEMA", + title="Objetos canónicos y de gobernanza disponibles", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="Todos los objetos requeridos existen.", + detail_fail="Faltan tablas o vistas requeridas.", + sql=r""" + SELECT COUNT(*) AS violations + FROM ( + VALUES + (to_regclass('mv_loss_intelligence.production_context_sessions')), + (to_regclass('mv_loss_intelligence.odoo_shopfloor_sessions')), + (to_regclass('mv_reports_ucepsa_prod.li_canonical_context_segments_v1')), + (to_regclass('mv_reports_ucepsa_prod.li_stop_canonical_context_shadow_v1')), + (to_regclass('mv_reports_ucepsa_prod.li_context_arbitration_conflicts_v1')), + (to_regclass('mv_reports_ucepsa_prod.li_context_review_case_history_v1')), + (to_regclass('mv_reports_ucepsa_prod.li_context_decision_group_history_v1')), + (to_regclass('mv_reports_ucepsa_prod.li_context_governance_daily_agenda_v1')), + (to_regclass('mv_reports_ucepsa_prod.li_context_governance_daily_summary_v1')), + (to_regclass('mv_loss_intelligence.context_governance_agenda_snapshots')), + (to_regclass('mv_loss_intelligence.context_regression_runs')), + (to_regclass('mv_loss_intelligence.context_regression_results')) + ) AS required(obj) + WHERE obj IS NULL + """, + ), + TestCase( + code="CANON-001", + group="CANONICAL", + title="Intervalos canónicos temporalmente válidos", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="No hay intervalos invertidos ni duraciones negativas.", + detail_fail="Existen segmentos canónicos temporalmente inválidos.", + sql=r""" + SELECT + COUNT(*) FILTER ( + WHERE segment_to <= segment_from + ) + + COUNT(*) FILTER ( + WHERE segment_duration_s < 0 + ) AS violations, + COUNT(*) AS segment_count + FROM + mv_reports_ucepsa_prod + .li_canonical_context_segments_v1 + """, + ), + TestCase( + code="CANON-002", + group="CANONICAL", + title="Arbitraje sin prioridad silenciosa", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="Una fuente produce contexto; cero o varias bloquean la atribución.", + detail_fail="La semántica canónica permite claves ambiguas o fuentes supporting promovidas.", + sql=r""" + SELECT + COUNT(*) FILTER ( + WHERE authoritative_candidate_count = 1 + AND canonical_context_key IS NULL + ) + + COUNT(*) FILTER ( + WHERE authoritative_candidate_count <> 1 + AND canonical_context_key IS NOT NULL + ) + + COUNT(*) FILTER ( + WHERE canonical_context_source IN ( + 'ODOO_ORDER_CONTEXT', + 'INFERRED_SHADOW' + ) + ) + + COUNT(*) FILTER ( + WHERE canonical_context_status = + 'CONTEXT_CONFLICT' + AND NOT review_required + ) AS violations + FROM + mv_reports_ucepsa_prod + .li_canonical_context_segments_v1 + """, + ), + TestCase( + code="CANON-003", + group="CANONICAL", + title="Paros abiertos, ambiguos o no productivos bloqueados", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="Ningún paro no elegible aparece como técnicamente elegible.", + detail_fail="Hay paros abiertos, ambiguos o no productivos promovidos.", + sql=r""" + SELECT + COUNT(*) FILTER ( + WHERE source_status = 'OPEN' + AND technically_eligible + ) + + COUNT(*) FILTER ( + WHERE NOT canonical_productive_context + AND technically_eligible + ) + + COUNT(*) FILTER ( + WHERE authoritative_candidate_count <> 1 + AND technically_eligible + ) AS violations + FROM + mv_reports_ucepsa_prod + .li_stop_canonical_context_shadow_v1 + """, + ), + TestCase( + code="CANON-004", + group="CANONICAL", + title="Slices de paro no superan el paro raw", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="La segmentación conserva la duración del paro.", + detail_fail="La suma de slices supera la duración raw.", + sql=r""" + WITH totals AS ( + SELECT + source_stop_id, + MAX(raw_duration_s) + AS raw_duration_s, + SUM(segment_duration_s) + AS sliced_duration_s + FROM + mv_reports_ucepsa_prod + .li_stop_canonical_context_slices_v1 + GROUP BY source_stop_id + ) + SELECT COUNT(*) AS violations + FROM totals + WHERE sliced_duration_s > + raw_duration_s + 1::numeric + """, + ), + TestCase( + code="LIVE-001", + group="LIVE", + title="Una fila viva por cada cortadora piloto", + severity="CRITICAL", + failure_status="FAIL", + expected={ + "row_count": 3, + "distinct_machine_count": 3, + "official_rows": 0, + }, + detail_pass="CORT-00, CORT-01 y CORT-02 tienen una única fila viva.", + detail_fail="La cardinalidad viva no coincide con las tres cortadoras piloto.", + sql=r""" + SELECT + COUNT(*) AS row_count, + COUNT( + DISTINCT machine_id + ) AS distinct_machine_count, + COUNT(*) FILTER ( + WHERE official_ledger_eligible + ) AS official_rows, + ARRAY_AGG( + machine_id + ORDER BY machine_id + ) AS machines + FROM + mv_reports_ucepsa_prod + .li_machine_canonical_context_live_v1 + """, + ), + TestCase( + code="WO340-001", + group="HISTORICAL_CONTRACT", + title="Workorder 340 cerrado con cantidad final confirmada", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0, "row_count": 1}, + detail_pass="WO 340 conserva cierre, operario y 100 kg finales.", + detail_fail="El contrato histórico del workorder 340 ha cambiado.", + sql=r""" + SELECT + COUNT(*) AS row_count, + COUNT(*) FILTER ( + WHERE NOT ( + workorder_state = 'done' + AND production_state = 'done' + AND session_status = 'CLOSED' + AND started_at = + '2026-07-20 07:36:19+02'::timestamptz + AND ended_at = + '2026-07-20 14:08:41+02'::timestamptz + AND operator_employee_id = 24 + AND operator_name = + 'ESTEBAN VAQUERIZO GARCIA' + AND planned_quantity = 100 + AND declared_quantity = 100 + AND posted_quantity = 100 + AND remaining_planned_quantity = 0 + AND quantity_status = + 'FINAL_POSTED' + AND quantity_confirmation_status = + 'EVIDENCE_CONFIRMED' + AND confidence = 'HIGH' + AND NOT official_eligible + ) + ) AS violations + FROM + mv_loss_intelligence.odoo_shopfloor_sessions + WHERE odoo_workorder_id = 340 + """, + ), + TestCase( + code="WO340-002", + group="HISTORICAL_CONTRACT", + title="WO 340 no se extiende después de su cierre", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="La actividad posterior al cierre no se atribuye al WO 340.", + detail_fail="Existen segmentos canónicos del WO 340 posteriores al cierre.", + sql=r""" + WITH session_340 AS ( + SELECT ended_at + FROM + mv_loss_intelligence.odoo_shopfloor_sessions + WHERE odoo_workorder_id = 340 + LIMIT 1 + ) + SELECT COUNT(*) AS violations + FROM + mv_reports_ucepsa_prod + .li_canonical_context_segments_v1 s + CROSS JOIN session_340 w + WHERE s.machine_id = 'CORT-02' + AND s.odoo_workorder_id = 340 + AND s.segment_from >= + w.ended_at + """, + ), + TestCase( + code="LEGACY-001", + group="HISTORICAL_CONTRACT", + title="Contexto legacy del episodio 1 autorizado", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0, "row_count": 1}, + detail_pass="El episodio 1 conserva su contexto legacy autorizado.", + detail_fail="El contexto retrospectivo del episodio 1 ha cambiado.", + sql=r""" + SELECT + COUNT(*) AS row_count, + COUNT(*) FILTER ( + WHERE NOT ( + machine_id = 'CORT-00' + AND source_type = 'LEGACY_ERP' + AND started_at = + '2026-07-20 12:58:45.457786+02'::timestamptz + AND ended_at = + '2026-07-20 13:23:56.594486+02'::timestamptz + AND status = 'CLOSED' + AND authorization_status = + 'AUTHORIZED' + AND confidence = 'MEDIUM' + AND authorized_by = 'Juan Pablo' + AND NOT official_eligible + ) + ) AS violations + FROM + mv_loss_intelligence.production_context_sessions + WHERE source_ref = + 'SHOPFLOOR_INCIDENT_EPISODE:1' + """, + ), + TestCase( + code="LEGACY-002", + group="HISTORICAL_CONTRACT", + title="Cobertura canónica exacta del contexto legacy", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="El intervalo autorizado queda cubierto por LEGACY_ERP sin huecos ni conflicto.", + detail_fail="La cobertura canónica del intervalo legacy no coincide con el intervalo autorizado.", + sql=r""" + WITH target AS ( + SELECT + started_at, + ended_at, + EXTRACT( + EPOCH FROM ( + ended_at + - started_at + ) + )::numeric AS target_s + FROM + mv_loss_intelligence + .production_context_sessions + WHERE source_ref = + 'SHOPFLOOR_INCIDENT_EPISODE:1' + LIMIT 1 + ), + coverage AS ( + SELECT + COALESCE( + SUM( + EXTRACT( + EPOCH FROM ( + LEAST( + s.segment_to, + t.ended_at + ) + - GREATEST( + s.segment_from, + t.started_at + ) + ) + ) + ) FILTER ( + WHERE s.canonical_context_status = + 'LEGACY_ERP' + AND s.canonical_context_source = + 'LEGACY_ERP' + ), + 0 + )::numeric AS covered_s, + COUNT(*) FILTER ( + WHERE s.canonical_context_status <> + 'LEGACY_ERP' + ) AS wrong_status_segments, + MAX(t.target_s) AS target_s + FROM + mv_reports_ucepsa_prod + .li_canonical_context_segments_v1 s + CROSS JOIN target t + WHERE s.machine_id = 'CORT-00' + AND s.segment_to > + t.started_at + AND s.segment_from < + t.ended_at + ) + SELECT + CASE + WHEN ABS( + covered_s + - target_s + ) <= 1 + AND wrong_status_segments = 0 + THEN 0 + ELSE 1 + END AS violations, + covered_s, + target_s, + wrong_status_segments + FROM coverage + """, + ), + TestCase( + code="CONFLICT-001", + group="HISTORICAL_CONTRACT", + title="Doble inicio 649/698 conservado como conflicto", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="El solape histórico de WO 649 y 698 sigue documentado.", + detail_fail="No existe el conflicto histórico esperado entre WO 649 y 698.", + sql=r""" + SELECT + CASE + WHEN COUNT(*) >= 1 + THEN 0 + ELSE 1 + END AS violations, + COUNT(*) AS conflict_rows, + COALESCE( + SUM(segment_duration_s), + 0 + ) AS conflict_seconds + FROM + mv_reports_ucepsa_prod + .li_context_arbitration_conflicts_v1 + WHERE machine_id = 'CORT-00' + AND conflict_type = + 'MULTIPLE_ODOO_SHOPFLOOR_SESSIONS' + AND authoritative_context_keys @> + ARRAY[ + 'ODOO_SHOPFLOOR:WORKORDER:649', + 'ODOO_SHOPFLOOR:WORKORDER:698' + ]::text[] + """, + ), + TestCase( + code="CONFLICT-002", + group="HISTORICAL_CONTRACT", + title="El conflicto 649/698 nunca recibe prioridad canónica", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="Todos los tramos del doble inicio permanecen bloqueados.", + detail_fail="Algún tramo del conflicto recibió una clave canónica.", + sql=r""" + WITH conflict_window AS ( + SELECT + MIN(segment_from) AS conflict_from, + MAX(segment_to) AS conflict_to + FROM + mv_reports_ucepsa_prod + .li_context_arbitration_conflicts_v1 + WHERE machine_id = 'CORT-00' + AND authoritative_context_keys @> + ARRAY[ + 'ODOO_SHOPFLOOR:WORKORDER:649', + 'ODOO_SHOPFLOOR:WORKORDER:698' + ]::text[] + ) + SELECT COUNT(*) AS violations + FROM + mv_reports_ucepsa_prod + .li_canonical_context_segments_v1 s + CROSS JOIN conflict_window c + WHERE c.conflict_from IS NOT NULL + AND s.machine_id = 'CORT-00' + AND s.segment_to > + c.conflict_from + AND s.segment_from < + c.conflict_to + AND ( + s.canonical_context_status <> + 'CONTEXT_CONFLICT' + OR s.canonical_context_key + IS NOT NULL + OR s.authoritative_candidate_count <= 1 + ) + """, + ), + TestCase( + code="CASE-001", + group="GOVERNANCE", + title="Episodios 7/8/9 agrupados en un caso raíz", + severity="CRITICAL", + failure_status="FAIL", + expected={ + "distinct_case_count": 1, + "evidence_count": 3, + }, + detail_pass="Las tres evidencias del doble inicio producen una sola tarea raíz.", + detail_fail="Los episodios 7/8/9 no están agrupados correctamente.", + sql=r""" + SELECT + COUNT( + DISTINCT case_id + ) AS distinct_case_count, + COUNT(*) AS evidence_count + FROM + mv_loss_intelligence + .context_review_case_evidence + WHERE episode_id IN ( + 7, + 8, + 9 + ) + """, + ), + TestCase( + code="DECISION-001", + group="GOVERNANCE", + title="Casos próximos consolidados en tres decisiones", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="Los grupos 1-4, 5-6 y 7-8 generan una decisión cada uno.", + detail_fail="La consolidación temporal de decisiones ha cambiado.", + sql=r""" + WITH checks AS ( + SELECT + ( + SELECT COUNT( + DISTINCT decision_group_key + ) + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_case_assignments_v1 + WHERE case_id IN ( + 1, + 2, + 3, + 4 + ) + ) AS group_1_4, + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_case_assignments_v1 + WHERE case_id IN ( + 1, + 2, + 3, + 4 + ) + ) AS linked_1_4, + ( + SELECT COUNT( + DISTINCT decision_group_key + ) + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_case_assignments_v1 + WHERE case_id IN ( + 5, + 6 + ) + ) AS group_5_6, + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_case_assignments_v1 + WHERE case_id IN ( + 5, + 6 + ) + ) AS linked_5_6, + ( + SELECT COUNT( + DISTINCT decision_group_key + ) + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_case_assignments_v1 + WHERE case_id IN ( + 7, + 8 + ) + ) AS group_7_8, + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_case_assignments_v1 + WHERE case_id IN ( + 7, + 8 + ) + ) AS linked_7_8 + ) + SELECT + CASE + WHEN group_1_4 = 1 + AND linked_1_4 = 4 + AND group_5_6 = 1 + AND linked_5_6 = 2 + AND group_7_8 = 1 + AND linked_7_8 = 2 + THEN 0 + ELSE 1 + END AS violations, + * + FROM checks + """, + ), + TestCase( + code="DECISION-002", + group="GOVERNANCE", + title="Cada caso raíz pertenece a una única decisión", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="No hay casos raíz duplicados entre decisiones.", + detail_fail="Un caso raíz pertenece a varias decisiones.", + sql=r""" + SELECT COUNT(*) AS violations + FROM ( + SELECT + case_id, + COUNT(*) AS n + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_case_assignments_v1 + GROUP BY case_id + HAVING COUNT(*) > 1 + ) duplicated + """, + ), + TestCase( + code="AGENDA-001", + group="GOVERNANCE", + title="Una fila de agenda por decisión pendiente", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="La agenda no duplica ni omite decisiones pendientes.", + detail_fail="La agenda y el backlog de decisiones no coinciden.", + sql=r""" + WITH counts AS ( + SELECT + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_context_governance_daily_agenda_v1 + ) AS agenda_rows, + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_backlog_v1 + ) AS backlog_rows, + ( + SELECT COUNT(*) + FROM ( + SELECT + decision_group_key + FROM + mv_reports_ucepsa_prod + .li_context_governance_daily_agenda_v1 + GROUP BY decision_group_key + HAVING COUNT(*) > 1 + ) d + ) AS duplicates + ) + SELECT + CASE + WHEN agenda_rows = + backlog_rows + AND duplicates = 0 + THEN 0 + ELSE 1 + END AS violations, + * + FROM counts + """, + ), + TestCase( + code="AGENDA-002", + group="GOVERNANCE", + title="Ranking y plazos de agenda válidos", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="El ranking es continuo y los tramos de atención son válidos.", + detail_fail="La agenda contiene rankings o estados de atención inválidos.", + sql=r""" + WITH ranked AS ( + SELECT + *, + ROW_NUMBER() OVER ( + ORDER BY agenda_rank + ) AS expected_rank + FROM + mv_reports_ucepsa_prod + .li_context_governance_daily_agenda_v1 + ) + SELECT + COUNT(*) FILTER ( + WHERE agenda_rank <> + expected_rank + ) + + COUNT(*) FILTER ( + WHERE attention_status NOT IN ( + 'RECENT', + 'ATTENTION', + 'OVERDUE' + ) + OR pending_age_hours < 0 + OR review_due_at < + first_seen_at + ) AS violations + FROM ranked + """, + ), + TestCase( + code="SNAPSHOT-001", + group="GOVERNANCE", + title="Snapshots de agenda internamente coherentes", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="Las cabeceras y los elementos de snapshot coinciden.", + detail_fail="Un snapshot declara un número de elementos incorrecto.", + sql=r""" + SELECT COUNT(*) AS violations, + COUNT(*) AS snapshot_count + FROM + mv_loss_intelligence + .context_governance_agenda_snapshots s + WHERE s.item_count <> + ( + SELECT COUNT(*) + FROM + mv_loss_intelligence + .context_governance_agenda_snapshot_items i + WHERE i.snapshot_id = + s.snapshot_id + ) + """, + ), + TestCase( + code="SNAPSHOT-002", + group="HISTORICAL_CONTRACT", + title="Snapshot 1 conserva la primera agenda de tres decisiones", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0, "row_count": 1}, + detail_pass="El primer snapshot conserva tres preguntas y modo SHADOW.", + detail_fail="El snapshot 1 no coincide con la primera agenda guardada.", + sql=r""" + SELECT + COUNT(*) AS row_count, + COUNT(*) FILTER ( + WHERE NOT ( + agenda_local_date = + DATE '2026-07-20' + AND generated_by = + 'Víctor Fraile' + AND item_count = 3 + AND source_state_hash IS NOT NULL + AND length( + source_state_hash + ) = 64 + AND NOT official_eligible + ) + ) AS violations + FROM + mv_loss_intelligence + .context_governance_agenda_snapshots + WHERE snapshot_id = 1 + """, + ), + TestCase( + code="REVIEW-001", + group="GOVERNANCE", + title="Conteos pendientes coherentes en decisiones y casos", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="Cada decisión declara exactamente sus casos raíz pendientes.", + detail_fail="El conteo pendiente de alguna decisión no coincide con sus casos.", + sql=r""" + SELECT COUNT(*) AS violations + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_history_v1 d + WHERE d.pending_root_case_count <> + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_cases_v1 c + WHERE c.decision_group_key = + d.decision_group_key + AND c.case_review_status = + 'PENDING' + ) + """, + ), + TestCase( + code="READINESS-001", + group="READINESS", + title="Readiness v3 coherente con sus gates", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="READY solo aparece cuando todos los gates pasan.", + detail_fail="Existe una máquina READY con algún gate fallido.", + sql=r""" + SELECT COUNT(*) AS violations + FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v3 + WHERE readiness_status = + 'READY_FOR_CONTROLLED_SHADOW' + AND NOT ( + technical_gate_pass + AND observation_gate_pass + AND review_gate_pass + AND coverage_gate_pass + ) + """, + ), + TestCase( + code="READINESS-002", + group="READINESS", + title="Consolidar nunca aumenta la carga humana", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="Las decisiones pendientes nunca superan a los casos raíz.", + detail_fail="La consolidación produce más decisiones que casos raíz.", + sql=r""" + SELECT COUNT(*) AS violations + FROM + mv_reports_ucepsa_prod + .li_canonical_context_readiness_gate_v3 + WHERE pending_decision_group_count > + pending_root_case_count + """, + ), + TestCase( + code="SHADOW-001", + group="SHADOW_GUARD", + title="Ninguna capa SHADOW habilita datos oficiales", + severity="CRITICAL", + failure_status="FAIL", + expected={"violations": 0}, + detail_pass="Todas las guardas oficiales permanecen a cero.", + detail_fail="Algún objeto SHADOW contiene filas oficiales.", + sql=r""" + SELECT + ( + SELECT COUNT(*) + FROM + mv_loss_intelligence + .production_context_sessions + WHERE official_eligible + ) + + ( + SELECT COUNT(*) + FROM + mv_loss_intelligence + .odoo_shopfloor_sessions + WHERE official_eligible + ) + + ( + SELECT COUNT(*) + FROM + mv_loss_intelligence + .context_review_cases + WHERE official_eligible + ) + + ( + SELECT COUNT(*) + FROM + mv_loss_intelligence + .context_governance_agenda_snapshots + WHERE official_eligible + ) + + ( + SELECT COUNT(*) + FROM + mv_loss_intelligence + .context_governance_agenda_snapshot_items + WHERE official_eligible + ) + + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_canonical_context_segments_v1 + WHERE official_ledger_eligible + ) + + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_stop_canonical_context_shadow_v1 + WHERE official_ledger_eligible + ) + + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_context_decision_group_history_v1 + WHERE official_eligible + ) + + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_context_governance_daily_summary_v1 + WHERE official_eligible + ) + + ( + SELECT COUNT(*) + FROM + mv_reports_ucepsa_prod + .li_operator_stop_queue_v2 + ) AS violations + """, + ), + TestCase( + code="HEALTH-001", + group="RUNTIME_HEALTH", + title="Sincronización y colectores saludables", + severity="WARNING", + failure_status="WARN", + expected={"unhealthy_count": 0}, + detail_pass="Los componentes de captura y gobernanza están saludables.", + detail_fail="Algún componente operativo no está HEALTHY.", + sql=r""" + SELECT + ( + CASE + WHEN ( + SELECT MAX( + sync_health_status + ) + FROM + mv_reports_ucepsa_prod + .li_shopfloor_sync_health_v1 + ) = 'HEALTHY' + THEN 0 + ELSE 1 + END + ) + + ( + CASE + WHEN ( + SELECT MAX( + collector_health_status + ) + FROM + mv_reports_ucepsa_prod + .li_shopfloor_context_incident_collector_health_v1 + ) = 'HEALTHY' + THEN 0 + ELSE 1 + END + ) + + ( + CASE + WHEN ( + SELECT MAX( + builder_health_status + ) + FROM + mv_reports_ucepsa_prod + .li_context_review_case_builder_health_v1 + ) = 'HEALTHY' + THEN 0 + ELSE 1 + END + ) AS unhealthy_count, + ( + SELECT MAX( + sync_health_status + ) + FROM + mv_reports_ucepsa_prod + .li_shopfloor_sync_health_v1 + ) AS sync_health, + ( + SELECT MAX( + collector_health_status + ) + FROM + mv_reports_ucepsa_prod + .li_shopfloor_context_incident_collector_health_v1 + ) AS collector_health, + ( + SELECT MAX( + builder_health_status + ) + FROM + mv_reports_ucepsa_prod + .li_context_review_case_builder_health_v1 + ) AS root_case_builder_health + """, + ), + TestCase( + code="PERF-001", + group="PERFORMANCE", + title="Consulta de agenda dentro del umbral orientativo", + severity="WARNING", + failure_status="WARN", + expected={"duration_ms_below_policy": True}, + detail_pass="La agenda responde dentro del umbral orientativo.", + detail_fail="La consulta de agenda supera el umbral orientativo.", + performance_only=True, + sql=r""" + SELECT COUNT(*) AS agenda_rows + FROM + mv_reports_ucepsa_prod + .li_context_governance_daily_agenda_v1 + """, + ), + ] + + +def suite_hash(tests: list[TestCase]) -> str: + payload = [ + { + "code": test.code, + "sql": " ".join( + test.sql.split() + ), + "expected": test.expected, + "failure_status": + test.failure_status, + } + for test in tests + ] + return hashlib.sha256( + json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + + +def values_match( + actual: dict[str, Any], + expected: dict[str, Any], +) -> bool: + for key, expected_value in expected.items(): + if key not in actual: + return False + actual_value = actual[key] + if isinstance(expected_value, bool): + if bool(actual_value) is not expected_value: + return False + elif actual_value != expected_value: + return False + return True + + +def load_policy(cursor) -> dict[str, Any]: + cursor.execute( + """ + SELECT * + FROM + mv_loss_intelligence.context_regression_policies + WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + AND policy_code = %s + AND active + LIMIT 1 + """, + (POLICY_CODE,), + ) + row = cursor.fetchone() + if not row: + raise RuntimeError( + f"No existe política activa {POLICY_CODE!r}" + ) + return dict(row) + + +def execute_test( + cursor, + test: TestCase, + performance_warning_ms: int, +) -> dict[str, Any]: + started = time.perf_counter() + savepoint = ( + "regression_" + + test.code.lower().replace("-", "_") + ) + cursor.execute( + f"SAVEPOINT {savepoint}" + ) + + try: + cursor.execute(test.sql) + row = cursor.fetchone() + actual = ( + dict(row) + if row is not None + else {} + ) + duration_ms = ( + time.perf_counter() + - started + ) * 1000.0 + + if test.performance_only: + passed = ( + duration_ms <= + performance_warning_ms + ) + actual[ + "duration_ms_below_policy" + ] = passed + actual[ + "performance_warning_ms" + ] = performance_warning_ms + else: + passed = values_match( + actual, + test.expected, + ) + + status = ( + "PASS" + if passed + else test.failure_status + ) + detail = ( + test.detail_pass + if passed + else test.detail_fail + ) + + cursor.execute( + f"RELEASE SAVEPOINT {savepoint}" + ) + + return { + "test_code": test.code, + "test_group": test.group, + "test_title": test.title, + "status": status, + "severity": test.severity, + "expected_json": test.expected, + "actual_json": actual, + "detail": detail, + "duration_ms": round( + duration_ms, + 2, + ), + "evidence_json": { + "sql_hash": hashlib.sha256( + " ".join( + test.sql.split() + ).encode("utf-8") + ).hexdigest(), + }, + } + + except Exception as error: + duration_ms = ( + time.perf_counter() + - started + ) * 1000.0 + cursor.execute( + f"ROLLBACK TO SAVEPOINT {savepoint}" + ) + cursor.execute( + f"RELEASE SAVEPOINT {savepoint}" + ) + + return { + "test_code": test.code, + "test_group": test.group, + "test_title": test.title, + "status": "ERROR", + "severity": test.severity, + "expected_json": test.expected, + "actual_json": {}, + "detail": str(error), + "duration_ms": round( + duration_ms, + 2, + ), + "evidence_json": { + "error_type": + type(error).__name__, + }, + } + + +def calculate_overall( + results: list[dict[str, Any]], +) -> str: + statuses = { + result["status"] + for result in results + } + if "ERROR" in statuses: + return "ERROR" + if "FAIL" in statuses: + return "FAIL" + if "WARN" in statuses: + return "WARN" + return "PASS" + + +def run_suite( + triggered_by: str, + trigger_kind: str, + notes: str | None, + git_commit: str | None, + dry_run: bool, +) -> dict[str, Any]: + tests = contract_tests() + state_hash = suite_hash(tests) + suite_started = time.perf_counter() + + with connect() as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + policy = load_policy(cursor) + performance_warning_ms = int( + policy[ + "performance_warning_ms" + ] + ) + + run_id = None + if not dry_run: + cursor.execute( + """ + INSERT INTO + mv_loss_intelligence + .context_regression_runs ( + tenant, + site, + policy_code, + suite_version, + overall_status, + triggered_by, + trigger_kind, + source_git_commit, + source_state_hash, + notes, + official_eligible + ) + VALUES ( + 'ucepsa', + 'ucepsa_onpremise', + %s, + %s, + 'RUNNING', + %s, + %s, + %s, + %s, + %s, + false + ) + RETURNING run_id + """, + ( + POLICY_CODE, + VERSION, + triggered_by, + trigger_kind, + git_commit, + state_hash, + notes, + ), + ) + run_id = int( + cursor.fetchone()["run_id"] + ) + connection.commit() + + results = [] + + for test in tests: + result = execute_test( + cursor, + test, + performance_warning_ms, + ) + results.append(result) + + if not dry_run: + cursor.execute( + """ + INSERT INTO + mv_loss_intelligence + .context_regression_results ( + run_id, + test_code, + test_group, + test_title, + status, + severity, + expected_json, + actual_json, + detail, + duration_ms, + evidence_json, + official_eligible + ) + VALUES ( + %s, %s, %s, %s, + %s, %s, %s, %s, + %s, %s, %s, false + ) + """, + ( + run_id, + result["test_code"], + result["test_group"], + result["test_title"], + result["status"], + result["severity"], + Json( + json_safe( + result[ + "expected_json" + ] + ) + ), + Json( + json_safe( + result[ + "actual_json" + ] + ) + ), + result["detail"], + result["duration_ms"], + Json( + result[ + "evidence_json" + ] + ), + ), + ) + connection.commit() + + overall_status = calculate_overall( + results + ) + counts = { + status: sum( + 1 + for result in results + if result["status"] == + status + ) + for status in ( + "PASS", + "WARN", + "FAIL", + "SKIP", + "ERROR", + ) + } + duration_ms = ( + time.perf_counter() + - suite_started + ) * 1000.0 + + if not dry_run: + cursor.execute( + """ + UPDATE + mv_loss_intelligence + .context_regression_runs + SET + overall_status = %s, + finished_at = now(), + duration_ms = %s, + result_count = %s, + pass_count = %s, + warn_count = %s, + fail_count = %s, + skip_count = %s, + error_count = %s + WHERE run_id = %s + """, + ( + overall_status, + round(duration_ms, 2), + len(results), + counts["PASS"], + counts["WARN"], + counts["FAIL"], + counts["SKIP"], + counts["ERROR"], + run_id, + ), + ) + connection.commit() + + return { + "version": VERSION, + "status": ( + "dry_run" + if dry_run + else "completed" + ), + "run_id": run_id, + "overall_status": overall_status, + "duration_ms": round( + duration_ms, + 2, + ), + "result_count": len(results), + "counts": counts, + "source_git_commit": git_commit, + "source_state_hash": state_hash, + "results": results, + } + + +def list_runs(limit: int) -> None: + with connect() as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_regression_run_history_v1 + ORDER BY + started_at DESC, + run_id DESC + LIMIT %s + """, + (limit,), + ) + print_json( + [dict(row) for row in cursor.fetchall()] + ) + + +def show_run(run_id: int | None) -> None: + with connect() as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + if run_id is None: + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_regression_latest_run_v1 + WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + """ + ) + else: + cursor.execute( + """ + SELECT * + FROM + mv_loss_intelligence + .context_regression_runs + WHERE run_id = %s + """, + (run_id,), + ) + + run = cursor.fetchone() + if not run: + raise RuntimeError( + "No existe una ejecución de regresión." + ) + + cursor.execute( + """ + SELECT * + FROM + mv_loss_intelligence + .context_regression_results + WHERE run_id = %s + ORDER BY + CASE status + WHEN 'ERROR' THEN 1 + WHEN 'FAIL' THEN 2 + WHEN 'WARN' THEN 3 + WHEN 'SKIP' THEN 4 + ELSE 5 + END, + test_group, + test_code + """, + (run["run_id"],), + ) + results = [ + dict(row) + for row in cursor.fetchall() + ] + + print_json({ + "run": dict(run), + "results": results, + }) + + +def markdown_report( + run: dict[str, Any], + results: list[dict[str, Any]], +) -> str: + lines = [ + f"# Regresión de contexto UCEPSA — ejecución {run['run_id']}", + "", + f"- Suite: {run['suite_version']}", + f"- Estado: {run['overall_status']}", + f"- Inicio: {run['started_at']}", + f"- Fin: {run['finished_at']}", + f"- Disparada por: {run['triggered_by']}", + f"- Tipo: {run['trigger_kind']}", + f"- Commit: {run.get('source_git_commit') or '—'}", + f"- Duración: {run.get('duration_ms') or '—'} ms", + "- Modo: SHADOW", + "- Ledger oficial: desactivado", + "", + "## Resumen", + "", + f"- PASS: {run['pass_count']}", + f"- WARN: {run['warn_count']}", + f"- FAIL: {run['fail_count']}", + f"- ERROR: {run['error_count']}", + f"- SKIP: {run['skip_count']}", + "", + "## Resultados", + "", + ] + + for result in results: + lines.extend([ + f"### {result['status']} · {result['test_code']}", + "", + f"**{result['test_title']}**", + "", + f"- Grupo: {result['test_group']}", + f"- Severidad: {result['severity']}", + f"- Duración: {result['duration_ms']} ms", + f"- Detalle: {result.get('detail') or '—'}", + "", + "```json", + json.dumps( + json_safe( + result["actual_json"] + ), + ensure_ascii=False, + indent=2, + ), + "```", + "", + ]) + + return "\n".join(lines) + "\n" + + +def export_markdown(run_id: int | None) -> None: + with connect() as connection: + with connection.cursor( + cursor_factory=RealDictCursor, + ) as cursor: + if run_id is None: + cursor.execute( + """ + SELECT * + FROM + mv_reports_ucepsa_prod + .li_context_regression_latest_run_v1 + WHERE tenant = 'ucepsa' + AND site = 'ucepsa_onpremise' + """ + ) + else: + cursor.execute( + """ + SELECT * + FROM + mv_loss_intelligence + .context_regression_runs + WHERE run_id = %s + """, + (run_id,), + ) + run = cursor.fetchone() + if not run: + raise RuntimeError( + "No existe una ejecución." + ) + + cursor.execute( + """ + SELECT * + FROM + mv_loss_intelligence + .context_regression_results + WHERE run_id = %s + ORDER BY + test_group, + test_code + """, + (run["run_id"],), + ) + results = [ + dict(row) + for row in cursor.fetchall() + ] + + sys.stdout.write( + markdown_report( + dict(run), + results, + ) + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Regresión y hardening de contexto " + "UCEPSA." + ) + ) + sub = parser.add_subparsers( + dest="command", + required=True, + ) + + run = sub.add_parser("run") + run.add_argument( + "--triggered-by", + required=True, + ) + run.add_argument( + "--trigger-kind", + choices=( + "MANUAL", + "DEPLOYMENT", + "SCHEDULED", + "CI", + ), + default="MANUAL", + ) + run.add_argument("--notes") + run.add_argument( + "--git-commit", + default=env_first( + "SOURCE_GIT_COMMIT" + ), + ) + + dry = sub.add_parser("dry-run") + dry.add_argument( + "--triggered-by", + default="MESAVAULT", + ) + dry.add_argument( + "--git-commit", + default=env_first( + "SOURCE_GIT_COMMIT" + ), + ) + + list_cmd = sub.add_parser("list") + list_cmd.add_argument( + "--limit", + type=int, + default=20, + ) + + show = sub.add_parser("show") + show.add_argument( + "--run-id", + type=int, + ) + + export = sub.add_parser("export-markdown") + export.add_argument( + "--run-id", + type=int, + ) + + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + if args.command == "run": + result = run_suite( + triggered_by=args.triggered_by, + trigger_kind=args.trigger_kind, + notes=args.notes, + git_commit=args.git_commit, + dry_run=False, + ) + print_json(result) + return ( + 0 + if result["overall_status"] in ( + "PASS", + "WARN", + ) + else 1 + ) + + if args.command == "dry-run": + result = run_suite( + triggered_by=args.triggered_by, + trigger_kind="MANUAL", + notes="Dry-run no persistente.", + git_commit=args.git_commit, + dry_run=True, + ) + print_json(result) + return ( + 0 + if result["overall_status"] in ( + "PASS", + "WARN", + ) + else 1 + ) + + if args.command == "list": + list_runs(args.limit) + return 0 + + if args.command == "show": + show_run(args.run_id) + return 0 + + if args.command == "export-markdown": + export_markdown(args.run_id) + return 0 + + raise RuntimeError("Comando no soportado") + + +if __name__ == "__main__": + raise SystemExit(main())