Published

Junction F3 — equivalence harness (paridad query-semántica cross-engine)

Connect any source, model it as an ontology, transform it, and operationalize it, analytics, automation and machine learning, under one governed, self-hostable roof. --- Most teams stitch the...

Junction F3 — equivalence harness (paridad query-semántica cross-engine)

Entregable de F3 (2026-07-28). Companion de junction.md · junction-execution-map.md · junction-f2-engine-adapter.md. F3 es el gate no negociable: nada sirve tráfico autoritativo por la puerta hasta que un diferencial demuestre que los motores plurales devuelven lo mismo. Y es lo que puebla capabilities().features, que F2 dejó vacío a propósito.


1 · Qué es F3 realmente (y qué NO, tras F3-RETIRO)

La spec previa (compute-integration.md §4·fase 2) decía "diferencial e2e PG↔Karma por query". Está superseded en sus dos extremos:

  • El lado PG ya no existe para live-SQL: F3-RETIRO borró dialect.ts/translateQuery/jsonbCast. No hay un ejecutor SQL de PG contra el que diferenciar.
  • El lado Karma tampoco existe todavía: sigue inerte (KARMA_SQL_URL ausente, F2 lo declara available:false).

El eje diferencial REAL de hoy es el que la pluralidad de F2 acaba de hacer posible:

        LA MISMA tabla física Iceberg (Lakekeeper + R2)
                    ╱                      ╲
   DuckDB (SQL)  ←──┤   ¿devuelven LO MISMO? ├──→  ml-runner (ops estructuradas)
   duck-server      ╲                      ╱        /lakehouse/read

Eso no es una comparación de conveniencia: es la validación de la tesis central de Junction — motores plurales sobre una cintura estrecha. Si SELECT * FROM ds_x (DuckDB) y handle.read() (ml-runner) discrepan sobre los mismos bytes, la cintura no es una cintura.

Segundo eje, vivo mientras dure la migración: substrato pg (JSONB en dataset_rows) ↔ substrato iceberg. Eso ya lo cubre lib/lakehouse/parity.ts a nivel count por dataset; F3 lo eleva a nivel fila/tipo.


2 · La pieza que se construye (y por qué ESA)

La tentación es escribir un script que llame a dos servicios y compare. Sería inútil fuera de esa llamada. La pieza reusable es el COMPARADOR, no el script.

lib/compute/equivalence.ts — un núcleo PURO: dos resultados tabulares entran, un veredicto estructurado sale. Cero red, cero env, cero motor. Testeable al 100% sin servicios vivos, y reusable por el harness e2e, por el canary, por el shadow de F4 y por el capability-probe.

Es el mismo patrón que ya funcionó: contract.ts = solo tipos, junction.ts = composer puro. La lógica difícil de la paridad no es la I/O — es la normalización.

Los 5 problemas reales que el comparador debe resolver

Ninguno es opcional; los cinco producen falsos positivos que matan un harness:

  1. Orden. Sin ORDER BY, DuckDB e Iceberg no garantizan el mismo orden. Comparar posicionalmente da mismatch en resultados idénticos → sort-before-compare canónico (orden total sobre la fila serializada) salvo que la query traiga ORDER BY, donde el orden es parte del contrato.
  2. Floats. 0.1+0.2 no es 0.30000000000000004 en todos los motores. → tolerancia relativa configurable (no absoluta: escala con la magnitud).
  3. Formato de cable. duck-server serializa BIGINT/DECIMAL/binary como string (precisión); PG/ml-runner devuelven number. Comparar 5 con "5" da mismatch espurio. → coerción canónica por valor, no por tipo declarado.
  4. No-determinismo. now(), random(), uuid(), LIMIT sin ORDER BY: un diff aquí no es un bug. → detectar y marcar UNCOMPARABLE, nunca MISMATCH. Un harness que grita en falso se ignora, y entonces no protege nada.
  5. Nulls y tipos vacíos. null vs undefined vs columna ausente vs ''. → política explícita y única.

El veredicto

Ternario, no booleano — la distinción es lo que hace el harness accionable:

VerdictSignificaAcción
MATCHEquivalentes bajo la normalizaciónEl feature entra en la whitelist
MISMATCHDiscrepan de verdadBloquea el flip; el diff dice dónde
UNCOMPARABLENo-determinismo detectadoNi prueba ni refuta; se excluye del corpus

3 · Capability-check: cómo se puebla features sin fe

F2 dejó capabilities().features: [] en los 4 motores porque declararlo sin evidencia es exactamente el anti-patrón que junction.md §9·11 prohíbe ("harness-diferencial-antes-de-servir").

F3 aporta el mecanismo: un corpus de sondas SQL_FEATURE_PROBES — pares {feature, sql} que ejercitan una capacidad concreta (CTE, window, GROUPING SETS, QUALIFY, lateral join, regex, JSON, timestamps con zona, MERGE…). El harness las corre contra cada motor; lo que ejecuta y coincide entra en la whitelist de ese motor.

SQL_FEATURE_PROBES ──(harness, run real)──► features[] ──► capabilities() ──► capa-3 elige motor

Así la lista blanca es derivada de una ejecución, no escrita a mano. Y supportsFeatures(engine, needed) se vuelve una consulta determinista en vez de intentar-y-medir.


4 · El landmine que F3 desactiva: la cintura NO es única (verificado)

junction.md §3 avisaba de que la fórmula FQN estaba duplicada TS≡Python "confirmada 1:1 a mano". Al verificarlo (2026-07-28) resultó que ya NO coinciden en el default:

  • lib/warehouse/query/fqn.ts:15DEFAULT_ICEBERG_NAMESPACE = 'datasets' hardcodeado, SIN fallback a env. Es el path DuckDB.
  • 11 sitios del resto del stack derivan el default de process.env.LAKEHOUSE_NAMESPACE: junction.ts:89, read-client.ts:89,227, ingest-client.ts:59, iceberg-native-write.ts:105, checksum-client.ts:49, fanout-client.ts:92,118,161, lakehouse-landing.ts:18, sync-worker.ts:27 — y en Python routers/lakehouse.py:479,653.

Consecuencia: para cualquier dataset con iceberg_namespace NULL, si LAKEHOUSE_NAMESPACE se pone a algo distinto de datasets, DuckDB lee/escribe una tabla física DISTINTA que ml-runner — en silencio. Hoy está latente (el env vale datasets), pero es un knob soportado que 11 sitios respetan y uno ignora. Con DuckDB ya registrado como motor, esta es exactamente la corrupción cross-engine que la cintura debía impedir.

F3 lo cierra: una única resolución del namespace por defecto + un test de conformidad que falla si algún path lo deriva distinto. Es el prerequisito de §D del execution-map, aterrizado en su forma concreta y verificada.


5 · Plan de F3 — ✅ EJECUTADO (2026-07-28)

  • F3.0 · el comparador puro ✅lib/compute/equivalence.ts: normalización (orden, floats, cable, nulls) + guard de no-determinismo + veredicto ternario con diff estructurado que reporta la causa raíz (column-set → column-order → row-count → cell-value), no el síntoma. equivalence.test.ts = 34 casos cubriendo los 5 problemas de §2.
  • F3.1 · capability probes ✅lib/compute/feature-probes.ts: 25 sondas SQL_FEATURE_PROBES + supportsFeatures(). 10 tests.
  • F3.2 · unificar la cintura ✅defaultIcebergNamespace() en fqn.ts, consumido por los 8 ficheros / 11 sitios que antes cada uno derivaba su propio default; + fqn-conformance.test.ts (10 casos) que falla si vuelven a divergir.
  • F3.3 · el harness e2e ✅ (escrito; lo ejecuta el owner)scripts/duckdb/f3-equivalence-harness.ts: eje A (diferencial DuckDB↔ml-runner por dataset) + eje B (las 25 sondas → whitelist). Solo lectura: no escribe, no crea, no borra. Emite la línea features: [...] lista para pegar.
  • Verificación: tsc 0 · 1117/1117 tests.
DUCK_SERVER_URL=https://duck-production-ef4a.up.railway.app DUCK_JWT_SECRET=<el de Railway> npx dotenv -e .env.local -- npx tsx scripts/duckdb/f3-equivalence-harness.ts bronze_orders bronze_customers

6 · RESULTADO DE LA EJECUCIÓN EN VIVO (2026-07-28) — el gate está VERDE

Corrido contra duck-production-ef4a.up.railway.app + ml-runner-production.up.railway.app sobre el Warehouse real.

EjeResultado
B · sondas25/25 soportadas, 0 divergencias. Incluidas las tres que junction.md §10 marcaba frágiles en Substrait: cte-recursive, exists-predicate, grouping-sets. → whitelist cableada en lib/compute/engines/duckdb.ts
A · cross-engine8/9 datasets MATCH (200 filas × 4–9 columnas cada uno: bronze_order_items/customers/orders/order_payments/order_reviews/products/sellers, usuarios_pruebas). 1 marcado VACUO.

Control negativo ejecutado: se comprobó que el harness SÍ detecta una divergencia inyectada (MISMATCH sobre 1 vs 2) antes de dar por bueno el 25/25. Un gate que no se ha visto fallar no es un gate.

Los 3 hallazgos reales de la primera ejecución

1 · DuckDB y ml-runner NO serializan igual los timestamps. 2017-09-19 09:45:35+00:00 (DuckDB, separador espacio) vs 2017-09-19T09:45:35+00:00 (ml-runner, ISO). Mismo instante, distinta grafía — pero sin normalizarlo toda columna de timestamp daba MISMATCH (991 celdas en bronze_orders) y el harness era inservible. Resuelto en el comparador con canonicalTimestamp(), bajo una regla estricta: se normaliza GRAFÍA, nunca SEMÁNTICA

  • ✅ se normaliza: separador T, Z+00:00-00:00, ceros finales de la fracción (.960000.96);
  • NO se normaliza un offset no-cero a UTC: que dos motores discrepen en la zona horaria es la divergencia más cara que existe, y taparla sería el peor fallo posible del harness.

Queda como deuda de producto (no de corrección): las dos superficies muestran el mismo timestamp con distinta forma. El dato es el mismo; la presentación no.

2 · Un ✓ sobre 0 filas no es evidencia. logs_sistema "coincidía" porque ambos motores devolvían 0 filas. El harness lo daba por verde. Ahora se marca VACUO aparte y no cuenta como evidencia — un harness que miente por omisión es peor que no tenerlo.

3 · Deriva del control-plane (hallazgo colateral, para el owner). logs_sistema declara datasets.row_count = 5000 pero tiene 0 filas en Iceberg Y 0 en dataset_rows. Los motores concuerdan, así que no es divergencia cross-engine: es el control-plane afirmando filas que no existen en ningún substrato. Es precisamente el caso de uso del reconcile-from-catalog daemon (f4 §3.1), ratificado y aún sin construir.

Un bug del propio harness, cazado en la 1ª ejecución

resolveDataset usaba .or(id.eq.<nombre>,name.eq.<nombre>): como datasets.id es uuid, comparar un nombre contra él hace fallar la consulta entera en PostgREST, y el .or() se lleva por delante también la rama por nombre — con el error silenciado, se reportaba "no existe en el control-plane" para 6 datasets que sí existían. Corregido: se ramifica por forma (UUID vs nombre) y el error se propaga en vez de tragarse.

Dos decisiones de diseño que el código fijó (y por qué)

  1. '007' NO es 7. El comparador numeriza strings porque duck-server serializa BIGINT/DECIMAL como texto — pero un test cazó que la primera versión colapsaba '007'7, lo que daría falso MATCH entre una columna VARCHAR de SKUs y una INT. Regla final: relleno con ceros ⇒ es un código, se conserva string; escala de DECIMAL ('1.50'1.5) es serialización, se numeriza; BIGINT que no cabe en un double se conserva string. Principio: un falso MISMATCH es recuperable; un falso MATCH autoriza un flip incorrecto.
  2. supportsFeatures con whitelist vacía responde "no consta", no "sí". Devuelve {supported:false, undeclared:true} — la distinción entre no consta y consta que no es lo que permitirá a la capa-3 degradar con criterio en vez de tratar todo negativo igual.

Lo que F3 NO hace: no enruta nada (eso es F4), no puebla features con datos inventados (los pone la ejecución de F3.3), y no toca los run<T>.


Doc vivo. F3 = el gate de correctitud de Junction. Siguiente: F4 (absorber el sql-editor por runQuery — primer consumidor de SqlEngineAdapter — y cerrar el bypass duck-direct en el mismo PR).