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_URLausente, F2 lo declaraavailable: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:
- 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 traigaORDER BY, donde el orden sí es parte del contrato. - Floats.
0.1+0.2no es0.30000000000000004en todos los motores. → tolerancia relativa configurable (no absoluta: escala con la magnitud). - Formato de cable. duck-server serializa BIGINT/DECIMAL/binary como string (precisión); PG/ml-runner devuelven number. Comparar
5con"5"da mismatch espurio. → coerción canónica por valor, no por tipo declarado. - No-determinismo.
now(),random(),uuid(),LIMITsinORDER BY: un diff aquí no es un bug. → detectar y marcarUNCOMPARABLE, nuncaMISMATCH. Un harness que grita en falso se ignora, y entonces no protege nada. - Nulls y tipos vacíos.
nullvsundefinedvs columna ausente vs''. → política explícita y única.
El veredicto
Ternario, no booleano — la distinción es lo que hace el harness accionable:
| Verdict | Significa | Acción |
|---|---|---|
MATCH | Equivalentes bajo la normalización | El feature entra en la whitelist |
MISMATCH | Discrepan de verdad | Bloquea el flip; el diff dice dónde |
UNCOMPARABLE | No-determinismo detectado | Ni 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:15—DEFAULT_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 Pythonrouters/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 sondasSQL_FEATURE_PROBES+supportsFeatures(). 10 tests. - F3.2 · unificar la cintura ✅ —
defaultIcebergNamespace()enfqn.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íneafeatures: [...]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.
| Eje | Resultado |
|---|---|
| B · sondas | 25/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-engine | 8/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é)
'007'NO es7. 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.supportsFeaturescon 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).