Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/knut

.nornir/rel2graph-codegen-design.md

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-codegen prompt 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):

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)

3.2 knut_thund::rel2graph (feature rel2graph → dep knut-bifrost + dsl)

3.3 Tests (real, deterministic, no cheating)


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.

  1. Live catalog reflection (T1/T2/T4 resolution). Tier is recorded, but a plan's tier is currently whatever CodegenOptions.declared_tier says (default T3 = the reviewed mapping). The real passes — T1 Postgres information_schema PK/FK, T2 Iceberg identifier-field-ids, T4 inclusion-dependency LEFT ANTI JOIN orphan test — are not wired. GraphPlan accepts T4 EdgeProposals from outside; the pass that computes them (a live DB query) is TODO.
  2. A graph-sink ExecBackend. (Largely LANDED.) The NativeBackend (built native + rel2graph, i.e. the falkordb feature) now turns each falkordb-node/-edge OutputType::Sink dataset into a real UNWIND … MERGE load via knut_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. The SparkBackend structurally cannot (an SDP flow is a SQL relation, an SDP output a table — nowhere for a Cypher closure), so SparkBackend::check refuses 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.
  3. concurrency has no thund IR home. Carried in GraphPlan + PySpark coalesce(N); mapping it onto a thund Dataset/Flow knob is TODO.
  4. Clarification protocol (INTERACTIVE). The spec's ≤6-question QUESTIONS block on a blocking gap is not emitted; today from_mapping errors 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.
  5. 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 in transform.rs/from_arrow_schema; codegen consumes whatever the reviewed MappingSpec already encodes and does not yet re-derive them.
  6. CLI surface. No bifrost codegen subcommand 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 via cargo 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.