rel2graph CODEGEN — one MappingSpec, two backend projections (2026-07-29)
bifröst is the rel2graph engine. One concept, many tools: codegen · pipeline-config authoring · the live CDC listener · schema→mapping inference. This doc folds Rickard's authoritative
rel2graph-codegenprompt into the real knut code, framed as bifröst = rel2graph and codegen = one of its tools. It reconciles the spec's proposed RON shape against the code that already exists, and records what was filled additively vs. what is honestly still a worklist.
See also rel2graph-design.md (the analyze→propose→execute engine placement) and thund-design.md / thund-reasoning.md (the execution IR + backends).
0. The core idea (reconciled with the coordinator's steer)
bifröst maps a relational schema to a graph once — a reviewed [MappingSpec], distilled to the canonical GraphPlan (labels, merge keys, edge directions, props, batch). That ONE plan is then projected onto a backend. bifröst is backend-agnostic: the two codegen outputs are both just projections of the one plan:
| Projection | What | Where it lives | Runs on |
| (a) PySpark job | a Spark job doing UNWIND … MERGE into the graph sink |
knut_bifrost::codegen::GraphPlan::to_pyspark (pure Rust) |
Spark 4.1/4.2 |
(b) pipeline.ron |
a real thund Pipeline |
knut_thund::rel2graph::to_thund_pipeline (feature rel2graph) |
any thund ExecBackend — SparkBackend→SDP or NativeBackend→DataFusion |
The pipeline.ron is not a bespoke second runtime and not the spec's flat Pipeline{…} struct — it is a knut_thund::ir::Pipeline (datasets + flows) that thund's pluggable ExecBackend already runs. "bifrost mapping → thund → Spark|Native" is one path; thund picks the tech.
The invariant: both projections derive from the same GraphPlan and must agree exactly on labels, merge keys, edge read columns, edge directions and batch size. Any divergence is a bug — pinned by knut_thund::rel2graph::tests::two_projections_agree (2-table) and, on the full 12-table schema down to endpoint read columns + merge keys, by the bench's twelve_table_projections_agree_down_to_keys (§2.1).
Why the projection lives where it lives (dependency direction)
knut-pipelines already depends on knut-bifrost (its airflow feature), and knut-thund depends on knut-pipelines. So bifröst must not depend on thund (that would be a cycle bifröst → thund → knut-pipelines → bifröst). The acyclic direction is thund → bifröst: therefore the GraphPlan → thund Pipeline projection + the two-projections-agree test live in thund (behind the optional rel2graph feature, default-features = false on bifröst), while the pure PySpark projection + the canonical GraphPlan live in bifröst. korp consumes bifröst with default features — unchanged; the new dep is opt-in.
1. What bifröst's codegen already did vs the spec (gap table)
Before this change (the "RON codegen seam", commit 1add167):
| Spec capability | Pre-existing code | Status before | Now |
| schema → candidate mapping | MappingSpec::from_arrow_schema → SchemaMappingCandidate (honest: every col mapped or unmapped; guesses checklist) |
DONE (T2-ish) | kept, unchanged |
| mapping as RON, round-trip | MappingSpec::{from,to,load}_ron beside from_yaml |
DONE | kept |
| junction/self-ref/composite rules | EdgeTransform self-key handling in transform.rs |
partial | doc'd; concat-key = worklist |
| emit a PySpark job | — | MISSING | GraphPlan::to_pyspark (Python hard rules) |
emit pipeline.ron (all-Rust path) |
thund IR existed, no rel→graph bridge | MISSING | to_thund_pipeline (real thund Pipeline) |
| two projections must AGREE | — | MISSING | two_projections_agree test |
| T1–T4 tier resolution + provenance | flat guesses: Vec<String> |
partial | Tier enum on every node/edge + provenance WARNINGS line |
| T4 proposals commented-out, never applied | — | MISSING | EdgeProposal + with_proposals, emitted as comments only |
| determinism (byte-identical) | RON derive order only | partial | canonical GraphPlan ordering + a determinism test |
| WARNINGS block closes every artifact | unmapped/guesses surfaced |
partial | emit_warnings on both artifacts |
| CDC / streaming listener | cdc.rs, listener.rs, SyncMode::Cdc |
DONE (the runtime tool) | referenced, not duplicated |
| Cypher runtime MERGE | sink.rs UNWIND … MERGE |
DONE | the emitted PySpark mirrors it |
| clarification protocol (INTERACTIVE) | — | MISSING | worklist (§5) |
| live catalog reflection (T1 PG, T2 iceberg field-ids, T4 anti-join) | — | MISSING | worklist (§5) — the types exist; the DB passes do not |
Do-not-remake note: the CDC engine, the FalkorDB/GraphAr sinks, the Spark Connect client, MappingSpec, from_arrow_schema, and the thund IR/backends all already exist and were reused, not rebuilt. Codegen is a thin, additive projection layer over them.
2. RON field-name reconciliation (spec proposed → REAL)
The spec proposes a flat Pipeline{source,sink,mode,batch_rows,concurrency,nodes,edges,indexes}. That struct does not exist in the code, and per the coordinator it should not — the runnable artifact is a thund Pipeline. The spec's fields survive, but as real names spread across the canonical GraphPlan, the thund Pipeline, and each thund Dataset's knut.* properties. Use the real names; every mismatch is flagged here.
| Spec (proposed) | Meaning | REAL home / name |
Pipeline{…} (flat) |
the whole spec | mismatch → the mapping is MappingSpec{version, tables[]}; the runnable is knut_thund::ir::Pipeline{name, storage, datasets[], flows[]}. The flat struct is not materialized; GraphPlan is the in-memory canonical carrier. |
source |
postgres/iceberg | CodegenOptions.source → GraphPlan.source → Dataset prop knut.graph.source |
sink |
falkordb/graphar | GraphPlan.sink → knut.graph.sink; also the Dataset format (falkordb-node/-edge) |
mode |
batch/stream | GraphPlan.mode → thund Dataset.materialization (Full/Incremental) |
batch_rows |
UNWIND batch size | GraphPlan.batch_rows → PySpark BATCH_ROWS and Dataset prop knut.graph.batch_rows (the value the two projections must agree on) |
concurrency |
writer parallelism | GraphPlan.concurrency → PySpark coalesce(N). mismatch: no thund IR field yet — carried in the plan + PySpark only (§5 worklist). |
nodes[].label |
node label | VertexTransform.label → NodePlan.label → knut.node.label; Dataset named node__<Label> |
nodes[].merge_key |
node identity | mismatch: real name is id_column (VertexTransform.id_column) → NodePlan.key → knut.node.key |
nodes[].props |
properties | VertexTransform.properties → NodePlan.props (ordinal) → knut.node.props (comma-joined) |
edges[].rel |
relationship type | mismatch: RON key is type (EdgeTransform #[serde(rename="type")], Rust field edge_type) → EdgePlan.rel → knut.edge.rel |
edges[].direction |
from→to | not a field: direction is always src → dst (child→parent FK). from_label=src.label, to_label=dst.label → knut.edge.from/knut.edge.to |
edges[].from_key/to_key |
endpoint merge keys (node identity property MERGEd on) | EndpointSpec.id_column (falls back to .column) → EdgePlan.from_key/to_key → knut.edge.from_key/to_key |
| (endpoint read columns) | the FK columns the endpoint VALUES are read from | EndpointSpec.column → EdgePlan.from_col/to_col → knut.edge.from_col/to_col. Distinct from the merge keys — see §2.1. |
indexes[] |
startup indexes | mismatch: not stored — derived as one index per node (label, key). Emitted by PySpark ensure_indexes(); implied on the thund side by the node key property (a graph ExecBackend reads it — §5). |
| provenance / WARNINGS | tier per element | Tier enum on NodePlan/EdgePlan + GraphPlan.warnings (provenance line first) → knut.graph.tier |
2.1 The endpoint-key STANDARD — read column vs merge key (self-ref/renamed-FK correctness)
An edge endpoint has two distinct attributes, and conflating them is a correctness bug for any FK whose column name differs from the referenced PK (self-referential edges; any renamed FK like manager_id → employee_id):
- read column (
from_col/to_col=EndpointSpec.column) — the column in the edge table the endpoint VALUE is read from. ForEmployee-[:REPORTS_TO]->Employeethe head value is read frommanager_id. - merge key (
from_key/to_key=id_column ?? column) — the property NAME the endpoint node is identified/MERGEd by in the graph. For REPORTS_TO both endpoints MERGE on the Employee node keyemployee_id.
The transform fabric (EdgeTransform::apply) always had this right — it reads src.column/dst.column and carries node_id_column() as the MATCH property. The codegen projection originally carried a single to_key = id_column ?? column and used it for BOTH roles, so selectExpr emitted employee_id AS dst — reading each employee's own id and collapsing REPORTS_TO into a self-loop of every employee to itself. Both projections shared the field, so they "agreed" with each other while both diverging from the data.
The standard, now enforced end-to-end: EdgePlan (and the knut.edge.* props) carry from_col/to_col (read) AND from_key/to_key (merge) as separate fields. The PySpark selectExpr reads from_col AS src, to_col AS dst; _write_edges(…, from_key, to_key, …) MERGEs on the node keys. The thund projection projects the FK columns (from_col/to_col) in the flow, and the native graph-sink renames those read columns to src/dst while taking the merge keys as EdgeSet.{src,dst}_id_column. They coincide for a plain FK (order_id read AND MERGEd) and correctly diverge for a self-ref / renamed FK. Pinned by the bench's twelve_table_projections_agree_down_to_keys (parity down to read columns + merge keys) and native's edge_sink_self_ref_reads_fk_column_merges_on_node_key.
3. The additive implementation
3.1 knut_bifrost::codegen (pure Rust, always compiled)
SourceKind(Postgres|Iceberg),SinkKind(FalkorDb|GraphAr),PlanMode(Batch|Stream),Tier(T1..T4),CodegenOptions(spec defaults: 1000 rows, coalesce 1, Iceberg→FalkorDB batch).NodePlan/EdgePlan/EdgeProposal/GraphPlan— the canonical carrier.GraphPlan::from_mapping(&MappingSpec, &CodegenOptions) -> Result<GraphPlan>— walks tables schema-then-name,vertex→node,edge/fk_edges→edge (directionsrc→dst),custom→SURFACED in WARNINGS (never dropped). Errors on the spec's blocking gaps: stream on a non-Iceberg source; a node with no merge key.GraphPlan::to_pyspark()— projection (a). The Python hard rules: FAIR scheduler;ensure_indexesat startup; nodes-before-edges; sink client built inside the partition fn;coalesce(N)beforeforeachPartition; idempotentUNWIND $rows AS r MERGEinBATCH_ROWSchunks; streaming wraps the same writer in oneforeachBatchwith onecheckpointLocation.GraphPlan::with_proposals(...)— T4 proposals stampedTier::T4InclusionDependency, emitted commented-out with evidence, never as live code.GraphPlan::contract()— the canonical labels/keys/directions/batch digest the invariant compares against.
3.2 knut_thund::rel2graph (feature rel2graph → dep knut-bifrost + dsl)
to_thund_pipeline(&GraphPlan) -> Result<Pipeline>— projection (b). OneDatasetper node (node__<Label>) / edge (edge__<From>__<Rel>__<To>), eachOutputType::Sinkwith the graph target inknut.*properties, fed by a projectionFlowover the source table (pruned to key+props for a node /from_col+to_col+props for an edge — the FK VALUE columns, not the merge-key names; see §2.1). Streaming ⇒Materialization::Incremental.validate()d before return.to_pipeline_ron(&GraphPlan) -> Result<String>— thepipeline.ron(thund DSL), round-trips throughauthoring::dsl::from_ron.- The
prop::*constants name theknut.*property namespace a future graphExecBackendreads.
3.3 Tests (real, deterministic, no cheating)
- bifröst
codegen::tests(5): canonical ordering + byte-identical determinism; the Python hard rules; the single-brace Cypher regression guard (the literal{/}that broke the kube build if emitted throughformat!— built via rawpush_str, asserted present, no{{/}}leak); streaming needs Iceberg + oneforeachBatch; custom step surfaced; T4 proposals commented-out never applied. - thund
rel2graph::tests(3):two_projections_agree— node (label,key) parsed out of the PySpark index statements vs. read from the Pipelineknut.*properties; edge (from,rel,to) parsed from_write_edges(...)call sites vs. Pipeline properties; batch fromBATCH_ROWSvs. property — all three asserted equal, both non-vacuous. Plus: the Pipeline is real thund IR (validates, nodes-before-edges, round-trips RON); streaming ⇒ incremental datasets. - Scaled parity —
knut-rel2graph-bench'stwelve_table_projections_agree_down_to_keysre-runs the two-projections invariant on the full 12-table GraphPlan and extends it to the endpoint read columns AND merge keys (the 7-tuplefrom,rel,to,from_col,to_col,from_key,to_key, parsed independently from the PySparkselectExpr+_write_edgesand the thund props). This is what proves the self-ref/composite cases agree, not just the 2-table toy. The native seam is locked byedge_sink_self_ref_reads_fk_column_merges_on_node_key(REPORTS_TO readsmanager_id, MERGEs onemployee_id, no self-loop).
4. Determinism (the spec's byte-identical law)
GraphPlan::from_mapping canonicalizes: tables schema-then-name ((namespace, leaf) key), node props ordinal, nodes-before-edges (two vecs), edges sorted (from_label, rel, to_label). No timestamps, no randomness; the one HashMap in the source (rename) never reaches the plan. GraphPlan fields are stable; thund Dataset.properties is a BTreeMap (sorted). plan_is_canonical_and_deterministic pins byte-identical PySpark on repeat; the thund RON is stable via the BTreeMap + declaration order.
5. Honest worklist — spec-but-not-yet-implemented
Everything below is described by the code (types/enums/props exist) but the behavior is a follow-up. Nothing here is faked green.
- Live catalog reflection (T1/T2/T4 resolution).
Tieris recorded, but a plan's tier is currently whateverCodegenOptions.declared_tiersays (default T3 = the reviewed mapping). The real passes — T1 Postgresinformation_schemaPK/FK, T2 Iceberg identifier-field-ids, T4 inclusion-dependencyLEFT ANTI JOINorphan test — are not wired.GraphPlanaccepts T4EdgeProposals from outside; the pass that computes them (a live DB query) is TODO. - A graph-sink
ExecBackend. (Largely LANDED.) TheNativeBackend(builtnative + rel2graph, i.e. thefalkordbfeature) now turns eachfalkordb-node/-edgeOutputType::Sinkdataset into a realUNWIND … MERGEload viaknut_bifrost::sink::FalkorDbSink— nodes-before-edges, both endpoints MERGEd, composite/hashed keys, parallel edges, typed projection, and the read-column vs merge-key split of §2.1. TheSparkBackendstructurally cannot (an SDP flow is a SQL relation, an SDP output a table — nowhere for a Cypher closure), soSparkBackend::checkrefuses a graph-sink pipeline up front with an actionable message ("use NativeBackend or the PySpark projection"). Remaining: the live FalkorDB round-trip is exercised only under an#[ignore]d container test; GraphAr sink write is still description-only. concurrencyhas no thund IR home. Carried inGraphPlan+ PySparkcoalesce(N); mapping it onto a thundDataset/Flowknob is TODO.- Clarification protocol (INTERACTIVE). The spec's ≤6-question QUESTIONS block on a blocking gap is not emitted; today
from_mappingerrors on the two hard gaps (no merge key; stream/source conflict) and SURFACES the rest in WARNINGS. INTERACTIVE=false "nodes-only + T4-commented" degrade path is TODO. - Mapping rule coverage. Junction-table collapse, composite-PK
concat_ws('|',…) as _key, and the full type-mapping table (numeric→double precision warning, timestamp→epoch-millis, json→stringified) are documented in the spec and partially present intransform.rs/from_arrow_schema; codegen consumes whatever the reviewedMappingSpecalready encodes and does not yet re-derive them. - CLI surface. No
bifrost codegensubcommand yet; the API is library-only (GraphPlan,to_pyspark,to_thund_pipeline). Both projections + the native graph-sink construction are reachable from a real invocation viacargo run -p knut-thund --example rel2graph_native --features native,rel2graph(not only the bench/tests). The third, directly-materialised form (GraphMutation) is the engine seam every DB-free consumer calls.
6. FIVE ZEROS
Codegen is pure Rust string/IR building: zero rayon, zero shelling, zero cpu-wait (synchronous, no spin), zero copy beyond the owned strings an emitted artifact inherently is, Rust 100%. The one deliberate non-Rust output is the emitted PySpark text — a described Spark artifact, not a shelled process.