knut_bifrost/codegen.rs
1//! **rel2graph CODEGEN** — one [`MappingSpec`] projected onto executable
2//! backends.
3//!
4//! bifröst **is** the rel2graph engine: a relational schema → a *mapping spec*
5//! (nodes, merge keys, edges, directions, props) → materialised graph. Codegen
6//! is *one tool* of that engine (its siblings: the live CDC listener, the
7//! pipeline-config authoring, the schema→mapping inference in
8//! [`crate::mapping::MappingSpec::from_arrow_schema`]). This module is the
9//! **describe-first, execute-anywhere** half: it lowers a reviewed
10//! [`MappingSpec`] into a single canonical [`GraphPlan`] and then *projects*
11//! that plan onto a backend.
12//!
13//! ## The two-projections invariant
14//!
15//! A [`GraphPlan`] is projected two ways, and **both must agree exactly**:
16//!
17//! * [`GraphPlan::to_pyspark`] — a PySpark job that loads the schema into the
18//! graph sink (the **Spark** backend projection). Lives here, pure Rust.
19//! * a **thund [`Pipeline`]** the all-Rust engine runs on *any*
20//! [`ExecBackend`] (Spark-Connect-SDP **or** native DataFusion) — lives in
21//! `knut-thund` (`knut_thund::rel2graph`, feature `rel2graph`), because that
22//! crate owns the execution IR. It projects from *this same* [`GraphPlan`].
23//!
24//! bifröst maps rel→graph **once**; thund picks the tech. Any divergence
25//! between the two projections (a label, a merge key, an edge direction, the
26//! batch size) is a bug — pinned by the `two_projections_agree` test in
27//! `knut-thund` (which can see both projections without a dependency cycle:
28//! `knut-pipelines` already depends on `knut-bifrost`, so bifröst must not
29//! depend on thund; thund depending on bifröst is the acyclic direction).
30//!
31//! [`MappingSpec`]: crate::mapping::MappingSpec
32//! [`Pipeline`]: https://docs.rs/knut-thund
33//! [`ExecBackend`]: https://docs.rs/knut-thund
34
35use std::fmt::Write as _;
36
37use crate::error::{BifrostError, Result};
38use crate::mapping::{MappingSpec, PipelineStep, SyncMode};
39
40// ═══════════════════════════════════════════════════════════════════════════
41// Options + backend/mode enums
42// ═══════════════════════════════════════════════════════════════════════════
43
44/// The relational **source plane** a schema is read from. In the intended
45/// deployment Iceberg is the *data plane* and Postgres the *schema plane*
46/// (the T1 catalog), so both are first-class.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum SourceKind {
49 /// A Postgres (or Postgres-catalog-fronted) source.
50 Postgres,
51 /// An Iceberg table (the streaming-capable data plane).
52 Iceberg,
53}
54
55impl SourceKind {
56 /// The lower-case token used in generated artifacts / RON.
57 pub fn token(self) -> &'static str {
58 match self {
59 SourceKind::Postgres => "postgres",
60 SourceKind::Iceberg => "iceberg",
61 }
62 }
63}
64
65/// The graph **sink** a plan targets.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum SinkKind {
68 /// FalkorDB (Cypher `UNWIND … MERGE`, the runtime sink bifröst already
69 /// drives in [`crate::sink`]).
70 FalkorDb,
71 /// GraphAr columnar graph files.
72 GraphAr,
73}
74
75impl SinkKind {
76 /// The lower-case token used in generated artifacts / RON.
77 pub fn token(self) -> &'static str {
78 match self {
79 SinkKind::FalkorDb => "falkordb",
80 SinkKind::GraphAr => "graphar",
81 }
82 }
83}
84
85/// Batch vs streaming. Streaming requires an Iceberg source (the changelog
86/// scan) — enforced by [`GraphPlan::from_mapping`].
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum PlanMode {
89 /// A bounded, idempotent full/incremental load.
90 Batch,
91 /// A continuous changelog-driven load (needs Iceberg).
92 Stream,
93}
94
95impl PlanMode {
96 /// The lower-case token used in generated artifacts / RON.
97 pub fn token(self) -> &'static str {
98 match self {
99 PlanMode::Batch => "batch",
100 PlanMode::Stream => "stream",
101 }
102 }
103}
104
105/// **Resolution tier** — where a node/edge mapping's authority *came from*, per
106/// the rel2graph spec. Recorded on every plan element and surfaced in the
107/// WARNINGS provenance block so a reviewer can see how confident each mapping
108/// is (T1 catalog fact ▸ T4 inference proposal).
109#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
110pub enum Tier {
111 /// **T1** — the upstream Postgres catalog (PK/FK constraints). The answer
112 /// for most schemas: Iceberg is the data plane, Postgres the schema plane.
113 T1PostgresCatalog,
114 /// **T2** — Iceberg identifier-field-ids (the table's declared identity).
115 T2IcebergFieldIds,
116 /// **T3** — `knut.*` TBLPROPERTIES on the table
117 /// (`knut.node.label`/`knut.node.key`/`knut.edge.*`). What a hand-authored
118 /// or reviewed [`MappingSpec`] encodes.
119 T3TblProperties,
120 /// **T4** — inclusion-dependency *inference* (the LEFT-ANTI-JOIN orphan
121 /// test). **Proposals only** — emitted COMMENTED-OUT with evidence, never
122 /// applied. A T4 element must never appear in the active plan.
123 T4InclusionDependency,
124}
125
126impl Tier {
127 /// A stable short token (`T1`…`T4`).
128 pub fn token(self) -> &'static str {
129 match self {
130 Tier::T1PostgresCatalog => "T1",
131 Tier::T2IcebergFieldIds => "T2",
132 Tier::T3TblProperties => "T3",
133 Tier::T4InclusionDependency => "T4",
134 }
135 }
136
137 /// A one-line human description of the tier.
138 pub fn describe(self) -> &'static str {
139 match self {
140 Tier::T1PostgresCatalog => "upstream Postgres catalog (PK/FK constraints)",
141 Tier::T2IcebergFieldIds => "Iceberg identifier-field-ids",
142 Tier::T3TblProperties => "knut.* TBLPROPERTIES on the table",
143 Tier::T4InclusionDependency => {
144 "inclusion-dependency inference (LEFT ANTI JOIN orphan test) — PROPOSAL only"
145 }
146 }
147 }
148}
149
150/// Knobs for [`GraphPlan::from_mapping`]. Defaults match the rel2graph spec
151/// (1000-row UNWIND batches, single coalesced writer, Iceberg→FalkorDB batch).
152#[derive(Debug, Clone)]
153pub struct CodegenOptions {
154 /// The source plane the schema was read from.
155 pub source: SourceKind,
156 /// The graph sink to load.
157 pub sink: SinkKind,
158 /// Batch or streaming (streaming needs an Iceberg source).
159 pub mode: PlanMode,
160 /// Rows per `UNWIND … MERGE` batch (the idempotent write unit).
161 pub batch_rows: usize,
162 /// Writer concurrency — the `coalesce(N)` partition count the sink client
163 /// runs inside.
164 pub concurrency: usize,
165 /// The single checkpoint / storage location (one per query).
166 pub checkpoint: String,
167 /// The tier the *declared* mapping elements are attributed to. A reviewed
168 /// [`MappingSpec`] is T3 (its `knut.*` properties); a spec derived straight
169 /// from an Iceberg schema is T2. The live-catalog T1 resolution is a
170 /// worklist item (see the design doc).
171 pub declared_tier: Tier,
172}
173
174impl Default for CodegenOptions {
175 fn default() -> Self {
176 CodegenOptions {
177 source: SourceKind::Iceberg,
178 sink: SinkKind::FalkorDb,
179 mode: PlanMode::Batch,
180 batch_rows: 1000,
181 concurrency: 1,
182 checkpoint: "/tmp/rel2graph/checkpoint".to_string(),
183 declared_tier: Tier::T3TblProperties,
184 }
185 }
186}
187
188// ═══════════════════════════════════════════════════════════════════════════
189// The canonical plan — the ONE object both projections derive from
190// ═══════════════════════════════════════════════════════════════════════════
191
192/// A node projection: one merge-key table → one graph node.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct NodePlan {
195 /// The fully-qualified source table.
196 pub table: String,
197 /// The graph node label (PascalCase singular).
198 pub label: String,
199 /// The merge key column — node identity.
200 pub key: String,
201 /// The non-key, non-FK scalar property columns, in **ordinal** order.
202 pub props: Vec<String>,
203 /// Where this node's authority came from.
204 pub tier: Tier,
205}
206
207/// An edge projection: a directed relationship `from → to`.
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct EdgePlan {
210 /// The source table the edge rows are read from.
211 pub table: String,
212 /// The tail (child / "many") node label.
213 pub from_label: String,
214 /// The relationship type (SCREAMING_SNAKE).
215 pub rel: String,
216 /// The head (parent / "one") node label.
217 pub to_label: String,
218 /// The tail node's **merge-key property** — the property name the `from`
219 /// node is identified/MERGEd by in the graph (its `id_column`). This is the
220 /// property NAME on the node, which is NOT necessarily the column the value
221 /// is read from — see [`Self::from_col`].
222 pub from_key: String,
223 /// The head node's **merge-key property** — the property name the `to` node
224 /// is identified/MERGEd by in the graph (its `id_column`). For a self-ref /
225 /// renamed FK this differs from the column the value is read from
226 /// ([`Self::to_col`]): e.g. `Employee-REPORTS_TO->Employee` MERGEs on
227 /// `employee_id` but reads the endpoint value from the `manager_id` column.
228 pub to_key: String,
229 /// The column in `table` the **tail** endpoint VALUE is read from
230 /// (`src.column`). Usually equal to [`Self::from_key`]; differs only when the
231 /// tail FK column is named differently from the node's key.
232 pub from_col: String,
233 /// The column in `table` the **head** endpoint VALUE is read from
234 /// (`dst.column`, i.e. the FK column). Differs from [`Self::to_key`] for a
235 /// self-ref / renamed FK (`manager_id` read, MERGEd on `employee_id`).
236 pub to_col: String,
237 /// Edge property columns, ordinal order.
238 pub props: Vec<String>,
239 /// Where this edge's authority came from.
240 pub tier: Tier,
241}
242
243/// A **T4 proposal** — an inferred relationship the codegen must NOT apply,
244/// only surface commented-out with its evidence. Carried through the plan so
245/// both projections emit it identically (as a comment), never as live code.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct EdgeProposal {
248 /// The candidate edge (its `tier` is always [`Tier::T4InclusionDependency`]).
249 pub edge: EdgePlan,
250 /// Human-readable evidence (e.g. the orphan-row count of the LEFT ANTI
251 /// JOIN test) a reviewer confirms before promoting it.
252 pub evidence: String,
253}
254
255/// The canonical rel2graph plan: **the single source both projections read**.
256///
257/// Deterministic by construction (see [`GraphPlan::from_mapping`]):
258/// tables schema-then-name, node props ordinal, nodes-before-edges, edges
259/// sorted `(from_label, rel, to_label)`. No timestamps, no randomness, no
260/// hashmap iteration order — the same input is the same bytes, always.
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct GraphPlan {
263 /// Source plane.
264 pub source: SourceKind,
265 /// Graph sink.
266 pub sink: SinkKind,
267 /// Batch/streaming.
268 pub mode: PlanMode,
269 /// Rows per `UNWIND … MERGE` batch.
270 pub batch_rows: usize,
271 /// Writer concurrency (`coalesce(N)`).
272 pub concurrency: usize,
273 /// The single checkpoint / storage location.
274 pub checkpoint: String,
275 /// Nodes, in canonical order.
276 pub nodes: Vec<NodePlan>,
277 /// Edges, in canonical order.
278 pub edges: Vec<EdgePlan>,
279 /// T4 inference proposals — emitted commented-out, never applied.
280 pub proposals: Vec<EdgeProposal>,
281 /// Provenance + honesty notes, closing every artifact.
282 pub warnings: Vec<String>,
283}
284
285impl GraphPlan {
286 /// **Lower a reviewed [`MappingSpec`] into the canonical plan.**
287 ///
288 /// Walks tables in schema-then-name order; each `vertex` step becomes a
289 /// [`NodePlan`], each `edge`/`fk_edges` step a [`EdgePlan`] (direction =
290 /// `src → dst`, the child→parent FK direction). `custom` steps are opaque
291 /// and cannot be projected to a declarative graph load, so they are
292 /// SURFACED in [`warnings`](Self::warnings), never silently dropped.
293 ///
294 /// Errors on the blocking gaps the spec names: a streaming plan on a
295 /// non-Iceberg source, or a node with no merge key.
296 pub fn from_mapping(spec: &MappingSpec, opts: &CodegenOptions) -> Result<GraphPlan> {
297 if opts.mode == PlanMode::Stream && opts.source != SourceKind::Iceberg {
298 return Err(BifrostError::Other(format!(
299 "MODE/SOURCE conflict: streaming needs an Iceberg source, got {:?}",
300 opts.source
301 )));
302 }
303
304 let mut nodes: Vec<NodePlan> = Vec::new();
305 let mut edges: Vec<EdgePlan> = Vec::new();
306 let mut warnings: Vec<String> = Vec::new();
307
308 // Tables schema-then-name (namespace before leaf), deterministically.
309 let mut tables: Vec<&crate::mapping::TableMapping> = spec.tables.iter().collect();
310 tables.sort_by(|a, b| table_sort_key(&a.table).cmp(&table_sort_key(&b.table)));
311
312 for tm in tables {
313 if tm.mode == SyncMode::Cdc && opts.mode == PlanMode::Batch {
314 warnings.push(format!(
315 "table `{}` declares CDC sync but the plan mode is batch — the changelog \
316 is read as a full insert load (see MODE)",
317 tm.table
318 ));
319 }
320 for step in &tm.pipeline {
321 match step {
322 PipelineStep::Vertex { vertex } => {
323 if vertex.id_column.is_empty() {
324 return Err(BifrostError::Other(format!(
325 "node `{}` (table `{}`) has no merge key",
326 vertex.label, tm.table
327 )));
328 }
329 nodes.push(NodePlan {
330 table: tm.table.clone(),
331 label: vertex.label.clone(),
332 key: vertex.id_column.clone(),
333 props: vertex.properties.clone(),
334 tier: opts.declared_tier,
335 });
336 }
337 PipelineStep::Edge { edge: t } | PipelineStep::FkEdges { fk_edges: t } => {
338 edges.push(EdgePlan {
339 table: tm.table.clone(),
340 from_label: t.src.label.clone(),
341 rel: t.edge_type.clone(),
342 to_label: t.dst.label.clone(),
343 from_key: t
344 .src
345 .id_column
346 .clone()
347 .unwrap_or_else(|| t.src.column.clone()),
348 to_key: t
349 .dst
350 .id_column
351 .clone()
352 .unwrap_or_else(|| t.dst.column.clone()),
353 // The VALUE is always read from the endpoint's own
354 // `column` (the transform fabric reads `src.column`
355 // / `dst.column`); only the MERGE *property name*
356 // comes from `id_column`. Keeping these separate is
357 // what makes self-ref / renamed FKs correct.
358 from_col: t.src.column.clone(),
359 to_col: t.dst.column.clone(),
360 props: t.properties.clone(),
361 tier: opts.declared_tier,
362 });
363 }
364 PipelineStep::Sql { .. } => {
365 warnings.push(format!(
366 "table `{}` uses a `sql:` step — its output is decided by the query \
367 at run time and cannot be projected to a declarative graph load; wire \
368 it by hand on the target backend (NOT dropped)",
369 tm.table
370 ));
371 }
372 PipelineStep::Custom { custom } => {
373 warnings.push(format!(
374 "table `{}` uses custom transform `{custom}` — an opaque Rust step \
375 that cannot be projected to a declarative graph load; wire it by \
376 hand on the target backend (NOT dropped)",
377 tm.table
378 ));
379 }
380 }
381 }
382 }
383
384 // DETERMINISM: edges sorted (from_label, rel, to_label). Nodes keep the
385 // schema-then-name table order (one merge-key table → one node); props
386 // stay ordinal.
387 edges.sort_by(|a, b| {
388 (&a.from_label, &a.rel, &a.to_label).cmp(&(&b.from_label, &b.rel, &b.to_label))
389 });
390
391 // Provenance summary — the WARNINGS block leads with it.
392 warnings.insert(
393 0,
394 format!(
395 "provenance: {} node(s) + {} edge(s) resolved at tier {} ({}); {} T4 proposal(s) \
396 held back (commented-out, never applied)",
397 nodes.len(),
398 edges.len(),
399 opts.declared_tier.token(),
400 opts.declared_tier.describe(),
401 0
402 ),
403 );
404
405 Ok(GraphPlan {
406 source: opts.source,
407 sink: opts.sink,
408 mode: opts.mode,
409 batch_rows: opts.batch_rows,
410 concurrency: opts.concurrency,
411 checkpoint: opts.checkpoint.clone(),
412 nodes,
413 edges,
414 proposals: Vec::new(),
415 warnings,
416 })
417 }
418
419 /// Attach T4 inference proposals (from an inclusion-dependency pass run
420 /// elsewhere). They are stamped [`Tier::T4InclusionDependency`] and will
421 /// only ever be emitted COMMENTED-OUT. Returns `self` for chaining.
422 pub fn with_proposals(mut self, proposals: impl IntoIterator<Item = EdgeProposal>) -> Self {
423 for mut p in proposals {
424 p.edge.tier = Tier::T4InclusionDependency;
425 self.proposals.push(p);
426 }
427 // Re-summarise the proposal count in the provenance line.
428 if let Some(first) = self.warnings.first_mut() {
429 if first.starts_with("provenance:") {
430 *first = format!(
431 "provenance: {} node(s) + {} edge(s) resolved; {} T4 proposal(s) held back \
432 (commented-out, never applied)",
433 self.nodes.len(),
434 self.edges.len(),
435 self.proposals.len()
436 );
437 }
438 }
439 self
440 }
441
442 /// A canonical, human-readable digest of the plan's **labels, merge keys,
443 /// edge directions and batch size** — the *contract* both projections must
444 /// honour. The two-projections-agree test compares what it can parse back
445 /// out of each artifact against this. Deterministic and stable.
446 pub fn contract(&self) -> String {
447 let mut s = String::new();
448 let _ = writeln!(s, "batch_rows={}", self.batch_rows);
449 for n in &self.nodes {
450 let _ = writeln!(s, "node {}={}", n.label, n.key);
451 }
452 for e in &self.edges {
453 let _ = writeln!(s, "edge {}-{}->{}", e.from_label, e.rel, e.to_label);
454 }
455 s
456 }
457
458 // ───────────────────────────────────────────────────────────────────────
459 // Projection (a): the PySpark job — the Spark backend
460 // ───────────────────────────────────────────────────────────────────────
461
462 /// **Projection (a): a PySpark job** loading the schema into the graph
463 /// sink. Obeys the rel2graph Python hard rules: a FAIR scheduler, indexes
464 /// created at startup, **nodes-before-edges**, the sink client constructed
465 /// *inside* the partition function, a `coalesce()` before
466 /// `foreachPartition`, and idempotent `UNWIND $rows AS r MERGE` in
467 /// `batch_rows`-sized chunks. Streaming wraps the same per-partition writer
468 /// in a single `foreachBatch` with one `checkpointLocation`.
469 ///
470 /// Deterministic: same plan ⇒ byte-identical Python.
471 pub fn to_pyspark(&self) -> String {
472 let mut s = String::new();
473 let p = &mut s;
474 let _ = writeln!(
475 p,
476 "# ── rel2graph codegen · Spark backend projection ──────────────────"
477 );
478 let _ = writeln!(
479 p,
480 "# source={} sink={} mode={} batch_rows={} concurrency={}",
481 self.source.token(),
482 self.sink.token(),
483 self.mode.token(),
484 self.batch_rows,
485 self.concurrency
486 );
487 let _ = writeln!(
488 p,
489 "# GENERATED — one MappingSpec, two projections; edits belong upstream."
490 );
491 let _ = writeln!(p, "from pyspark.sql import SparkSession");
492 let _ = writeln!(p);
493 let _ = writeln!(p, "BATCH_ROWS = {}", self.batch_rows);
494 let _ = writeln!(p, "CHECKPOINT = {:?}", self.checkpoint);
495 let _ = writeln!(p);
496
497 // Sink client + per-batch MERGE helpers, built INSIDE the partition fn.
498 let _ = writeln!(p, "def _sink():");
499 let _ = writeln!(
500 p,
501 " # sink client constructed INSIDE the partition fn (never shipped from the driver)"
502 );
503 match self.sink {
504 SinkKind::FalkorDb => {
505 let _ = writeln!(p, " from falkordb import FalkorDB");
506 let _ = writeln!(
507 p,
508 " return FalkorDB(host='localhost', port=6379).select_graph('rel2graph')"
509 );
510 }
511 SinkKind::GraphAr => {
512 let _ = writeln!(p, " import pygraphar # columnar graph writer");
513 let _ = writeln!(p, " return pygraphar.Writer('rel2graph')");
514 }
515 }
516 let _ = writeln!(p);
517
518 // Node writer.
519 let _ = writeln!(p, "def _write_nodes(label, key, rows):");
520 let _ = writeln!(p, " g = _sink(); buf = []");
521 let _ = writeln!(p, " def flush():");
522 let _ = writeln!(p, " if not buf: return");
523 p.push_str(
524 " g.query('UNWIND $rows AS r MERGE (n:`' + label + '` {`' + key + '`: r[\"' + key + '\"]}) SET n += r', {'rows': buf})\n",
525 );
526 let _ = writeln!(p, " buf.clear()");
527 let _ = writeln!(p, " for row in rows:");
528 let _ = writeln!(p, " buf.append(row.asDict())");
529 let _ = writeln!(p, " if len(buf) >= BATCH_ROWS: flush()");
530 let _ = writeln!(p, " flush()");
531 let _ = writeln!(p);
532
533 // Edge writer.
534 let _ = writeln!(p, "def _write_edges(fl, rel, tl, fk, tk, rows):");
535 let _ = writeln!(p, " g = _sink(); buf = []");
536 let _ = writeln!(p, " def flush():");
537 let _ = writeln!(p, " if not buf: return");
538 p.push_str(
539 " g.query('UNWIND $rows AS r MERGE (a:`' + fl + '` {`' + fk + '`: r.src}) MERGE (b:`' + tl + '` {`' + tk + '`: r.dst}) MERGE (a)-[e:`' + rel + '`]->(b) SET e += r.props', {'rows': buf})\n",
540 );
541 let _ = writeln!(p, " buf.clear()");
542 let _ = writeln!(p, " for row in rows:");
543 let _ = writeln!(
544 p,
545 " d = row.asDict(); props = {{k: v for k, v in d.items() if k not in ('src', 'dst')}}"
546 );
547 let _ = writeln!(
548 p,
549 " buf.append({{'src': d['src'], 'dst': d['dst'], 'props': props}})"
550 );
551 let _ = writeln!(p, " if len(buf) >= BATCH_ROWS: flush()");
552 let _ = writeln!(p, " flush()");
553 let _ = writeln!(p);
554
555 // Startup indexes — one per node label+key.
556 let _ = writeln!(p, "def ensure_indexes(g):");
557 if self.nodes.is_empty() {
558 let _ = writeln!(p, " pass # no nodes");
559 }
560 for n in &self.nodes {
561 let _ = writeln!(
562 p,
563 " g.query('CREATE INDEX FOR (n:`{}`) ON (n.`{}`)') # {}",
564 n.label,
565 n.key,
566 n.tier.token()
567 );
568 }
569 let _ = writeln!(p);
570
571 // main(): FAIR scheduler, nodes before edges.
572 let _ = writeln!(p, "def main():");
573 let _ = writeln!(
574 p,
575 " spark = SparkSession.builder.config('spark.scheduler.mode', 'FAIR').getOrCreate()"
576 );
577 let _ = writeln!(p, " ensure_indexes(_sink())");
578 let stream = self.mode == PlanMode::Stream;
579 let reader = if stream { "readStream.table" } else { "table" };
580
581 let _ = writeln!(p, " # ── nodes (before edges) ──");
582 for n in &self.nodes {
583 let cols = node_select_cols(n);
584 let df = format!("df_node_{}", sanitize(&n.label));
585 let _ = writeln!(
586 p,
587 " {df} = spark.{reader}({:?}).selectExpr({cols})",
588 n.table
589 );
590 if stream {
591 let _ = writeln!(
592 p,
593 " {df}.writeStream.option('checkpointLocation', CHECKPOINT + '/node_{}').foreachBatch(",
594 sanitize(&n.label)
595 );
596 let _ = writeln!(
597 p,
598 " lambda b, _: b.coalesce({}).foreachPartition(lambda rows: _write_nodes({:?}, {:?}, rows))).start()",
599 self.concurrency, n.label, n.key
600 );
601 } else {
602 let _ = writeln!(
603 p,
604 " {df}.coalesce({}).foreachPartition(lambda rows: _write_nodes({:?}, {:?}, rows))",
605 self.concurrency, n.label, n.key
606 );
607 }
608 }
609
610 let _ = writeln!(p, " # ── edges ──");
611 for e in &self.edges {
612 let cols = edge_select_cols(e);
613 let df = format!(
614 "df_edge_{}_{}_{}",
615 sanitize(&e.from_label),
616 sanitize(&e.rel),
617 sanitize(&e.to_label)
618 );
619 let _ = writeln!(
620 p,
621 " {df} = spark.{reader}({:?}).selectExpr({cols})",
622 e.table
623 );
624 if stream {
625 let _ = writeln!(
626 p,
627 " {df}.writeStream.option('checkpointLocation', CHECKPOINT + '/edge_{}_{}_{}').foreachBatch(",
628 sanitize(&e.from_label),
629 sanitize(&e.rel),
630 sanitize(&e.to_label)
631 );
632 let _ = writeln!(
633 p,
634 " lambda b, _: b.coalesce({}).foreachPartition(lambda rows: _write_edges({:?}, {:?}, {:?}, {:?}, {:?}, rows))).start()",
635 self.concurrency, e.from_label, e.rel, e.to_label, e.from_key, e.to_key
636 );
637 } else {
638 let _ = writeln!(
639 p,
640 " {df}.coalesce({}).foreachPartition(lambda rows: _write_edges({:?}, {:?}, {:?}, {:?}, {:?}, rows))",
641 self.concurrency, e.from_label, e.rel, e.to_label, e.from_key, e.to_key
642 );
643 }
644 }
645 if stream {
646 let _ = writeln!(p, " spark.streams.awaitAnyTermination()");
647 }
648 let _ = writeln!(p);
649 let _ = writeln!(p, "if __name__ == '__main__':");
650 let _ = writeln!(p, " main()");
651
652 // T4 proposals — commented out with evidence, never applied.
653 self.emit_proposals_py(p);
654 // WARNINGS block closes the artifact.
655 self.emit_warnings(p, "#");
656 s
657 }
658
659 fn emit_proposals_py(&self, p: &mut String) {
660 if self.proposals.is_empty() {
661 return;
662 }
663 let _ = writeln!(p);
664 let _ = writeln!(
665 p,
666 "# ── T4 PROPOSALS (inclusion-dependency inference) — NOT applied ──"
667 );
668 let _ = writeln!(
669 p,
670 "# Confirm the evidence, then add upstream in the MappingSpec. Never auto-applied."
671 );
672 for pr in &self.proposals {
673 let e = &pr.edge;
674 let _ = writeln!(
675 p,
676 "# ({}:`{}`)-[:`{}`]->({}:`{}`) evidence: {}",
677 sanitize(&e.from_label),
678 e.from_label,
679 e.rel,
680 sanitize(&e.to_label),
681 e.to_label,
682 pr.evidence
683 );
684 }
685 }
686
687 /// The WARNINGS block, prefixed with `line_prefix` (e.g. `#` for Python,
688 /// `//` for RON). Closes every artifact — provenance first, then notes.
689 pub fn emit_warnings(&self, p: &mut String, line_prefix: &str) {
690 let _ = writeln!(p);
691 let _ = writeln!(p, "{line_prefix} ── WARNINGS ──");
692 if self.warnings.is_empty() {
693 let _ = writeln!(p, "{line_prefix} (none)");
694 }
695 for w in &self.warnings {
696 let _ = writeln!(p, "{line_prefix} {w}");
697 }
698 }
699}
700
701// ── helpers ─────────────────────────────────────────────────────────────────
702
703/// Schema-then-name sort key: `(namespace, leaf)` so `a.z` precedes `b.a` on
704/// namespace but `s.accounts` precedes `s.users` on leaf. Deterministic.
705fn table_sort_key(table: &str) -> (String, String) {
706 match table.rsplit_once('.') {
707 Some((ns, leaf)) => (ns.to_string(), leaf.to_string()),
708 None => (String::new(), table.to_string()),
709 }
710}
711
712/// Python identifier-safe form of a label/rel for a variable name.
713fn sanitize(s: &str) -> String {
714 s.chars()
715 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
716 .collect()
717}
718
719/// `selectExpr(...)` column list for a node: key first, then ordinal props,
720/// each backtick-quoted.
721fn node_select_cols(n: &NodePlan) -> String {
722 let mut cols = vec![format!("\"`{}`\"", n.key)];
723 cols.extend(n.props.iter().map(|c| format!("\"`{c}`\"")));
724 cols.join(", ")
725}
726
727/// `selectExpr(...)` column list for an edge: `from_col AS src`, `to_col AS
728/// dst`, then ordinal props (as themselves), each backtick-quoted.
729///
730/// The endpoint VALUES are read from the FK columns (`from_col`/`to_col`), NOT
731/// the merge-key property names (`from_key`/`to_key`) — they differ for a
732/// self-ref / renamed FK (`manager_id AS dst`, MERGEd on `employee_id`).
733fn edge_select_cols(e: &EdgePlan) -> String {
734 let mut parts = vec![
735 format!("\"`{}` AS src\"", e.from_col),
736 format!("\"`{}` AS dst\"", e.to_col),
737 ];
738 for c in &e.props {
739 parts.push(format!("\"`{c}`\""));
740 }
741 parts.join(", ")
742}
743
744// ═══════════════════════════════════════════════════════════════════════════
745// Tests — canonical plan + the PySpark (Spark backend) projection. The
746// *two-projections-agree* invariant is pinned in `knut-thund` (which can see
747// the thund `Pipeline` projection too); here we pin determinism, ordering,
748// provenance, and the Python hard rules.
749// ═══════════════════════════════════════════════════════════════════════════
750
751#[cfg(test)]
752mod tests {
753 use super::*;
754 use crate::mapping::MappingSpec;
755
756 /// A two-table social schema: users (with a company FK) + companies, whose
757 /// declaration order is deliberately "wrong" (users before companies, edge
758 /// listed before the plain node) so the canonical ordering has work to do.
759 fn sample_spec() -> MappingSpec {
760 let yaml = r#"
761version: 1
762tables:
763 - table: lake.social.users
764 pipeline:
765 - vertex: { label: User, id_column: user_id, properties: [name, age] }
766 - fk_edges:
767 type: WORKS_AT
768 src: { label: User, column: user_id }
769 dst: { label: Company, column: company_id }
770 properties: [since]
771 - table: lake.social.companies
772 pipeline:
773 - vertex: { label: Company, id_column: company_id, properties: [name] }
774"#;
775 MappingSpec::from_yaml(yaml).expect("sample yaml parses")
776 }
777
778 #[test]
779 fn plan_is_canonical_and_deterministic() {
780 let spec = sample_spec();
781 let opts = CodegenOptions::default();
782 let a = GraphPlan::from_mapping(&spec, &opts).unwrap();
783 let b = GraphPlan::from_mapping(&spec, &opts).unwrap();
784 // Byte-identical PySpark on repeat — no timestamps/random/hashmap order.
785 assert_eq!(a.to_pyspark(), b.to_pyspark(), "codegen is deterministic");
786
787 // Tables schema-then-name: `companies` (leaf) sorts before `users`, so
788 // its node comes first even though it was declared last.
789 assert_eq!(a.nodes.len(), 2);
790 assert_eq!(a.nodes[0].label, "Company");
791 assert_eq!(a.nodes[1].label, "User");
792 // Props stay ordinal.
793 assert_eq!(a.nodes[1].props, vec!["name", "age"]);
794
795 // Edge: direction User -[WORKS_AT]-> Company, keys carried.
796 assert_eq!(a.edges.len(), 1);
797 let e = &a.edges[0];
798 assert_eq!(
799 (e.from_label.as_str(), e.rel.as_str(), e.to_label.as_str()),
800 ("User", "WORKS_AT", "Company")
801 );
802 assert_eq!(e.from_key, "user_id");
803 assert_eq!(e.to_key, "company_id");
804 }
805
806 #[test]
807 fn pyspark_obeys_the_python_hard_rules() {
808 let plan = GraphPlan::from_mapping(&sample_spec(), &CodegenOptions::default()).unwrap();
809 let py = plan.to_pyspark();
810
811 assert!(py.contains("spark.scheduler.mode"), "FAIR scheduler set");
812 assert!(py.contains("'FAIR'"));
813 assert!(py.contains("ensure_indexes"), "indexes created at startup");
814 assert!(py.contains("CREATE INDEX FOR (n:`User`) ON (n.`user_id`)"));
815 assert!(
816 py.contains("def _sink():"),
817 "sink client built inside partition fn"
818 );
819 assert!(
820 py.contains(".coalesce(1).foreachPartition("),
821 "coalesce before foreachPartition"
822 );
823 assert!(
824 py.contains("UNWIND $rows AS r MERGE"),
825 "idempotent UNWIND MERGE"
826 );
827 assert!(py.contains("if len(buf) >= BATCH_ROWS:"), "batched writes");
828 assert!(py.contains("BATCH_ROWS = 1000"));
829
830 // REGRESSION GUARD: the Cypher `MERGE (n:… {`key`: …})` carries REAL
831 // SINGLE braces `{`/`}` in the emitted text. These are literal `{` in
832 // the source; emitting them via `format!`/`writeln!` (which read `{` as
833 // a placeholder) is a compile error that has bitten the kube-feature
834 // build — so they are built with a raw `push_str`, and this pins that
835 // the single-brace Cypher survives to the output verbatim.
836 assert!(
837 py.contains("MERGE (n:`' + label + '` {`' + key + '`: r[\"' + key + '\"]})"),
838 "node MERGE keeps its single-brace Cypher: {py}"
839 );
840 assert!(
841 py.contains("MERGE (a:`' + fl + '` {`' + fk + '`: r.src})"),
842 "edge MERGE keeps its single-brace Cypher"
843 );
844 assert!(
845 py.contains("{'rows': buf}"),
846 "the params dict uses single braces"
847 );
848 // And never the doubled braces a `format!` escape would leave behind.
849 assert!(!py.contains("{{"), "no doubled `{{` leaked into the output");
850 assert!(!py.contains("}}"), "no doubled `}}` leaked into the output");
851
852 // Nodes-before-edges: both node writers precede the edge writer in main().
853 let main = &py[py.find("def main()").unwrap()..];
854 let node_at = main.find("_write_nodes").unwrap();
855 let edge_at = main.find("_write_edges").unwrap();
856 assert!(node_at < edge_at, "nodes are loaded before edges");
857
858 // WARNINGS block closes the artifact, provenance first.
859 assert!(py.trim_end().contains("── WARNINGS ──"));
860 assert!(py.contains("provenance:"));
861 }
862
863 #[test]
864 fn streaming_needs_iceberg_and_wraps_one_foreachbatch() {
865 let spec = sample_spec();
866 // Stream on Postgres is refused up front.
867 let bad = CodegenOptions {
868 source: SourceKind::Postgres,
869 mode: PlanMode::Stream,
870 ..Default::default()
871 };
872 assert!(
873 GraphPlan::from_mapping(&spec, &bad).is_err(),
874 "stream needs iceberg"
875 );
876
877 let ok = CodegenOptions {
878 source: SourceKind::Iceberg,
879 mode: PlanMode::Stream,
880 ..Default::default()
881 };
882 let py = GraphPlan::from_mapping(&spec, &ok).unwrap().to_pyspark();
883 assert!(py.contains("readStream.table"), "streaming reader");
884 assert!(
885 py.contains("foreachBatch("),
886 "foreachBatch wraps the per-partition writer"
887 );
888 assert!(
889 py.contains("checkpointLocation"),
890 "one checkpointLocation per query"
891 );
892 assert!(py.contains("awaitAnyTermination"));
893 }
894
895 #[test]
896 fn custom_step_is_surfaced_not_dropped() {
897 let yaml = r#"
898version: 1
899tables:
900 - table: lake.x.t
901 pipeline:
902 - vertex: { label: T, id_column: id }
903 - custom: my_special_transform
904"#;
905 let spec = MappingSpec::from_yaml(yaml).unwrap();
906 let plan = GraphPlan::from_mapping(&spec, &CodegenOptions::default()).unwrap();
907 assert_eq!(plan.nodes.len(), 1);
908 assert!(
909 plan.warnings
910 .iter()
911 .any(|w| w.contains("my_special_transform") && w.contains("NOT dropped")),
912 "the opaque custom step is surfaced in WARNINGS: {:?}",
913 plan.warnings
914 );
915 }
916
917 #[test]
918 fn t4_proposals_are_emitted_commented_out_never_applied() {
919 let plan = GraphPlan::from_mapping(&sample_spec(), &CodegenOptions::default())
920 .unwrap()
921 .with_proposals([EdgeProposal {
922 edge: EdgePlan {
923 table: "lake.social.users".into(),
924 from_label: "User".into(),
925 rel: "REFERRED_BY".into(),
926 to_label: "User".into(),
927 from_key: "user_id".into(),
928 to_key: "user_id".into(),
929 from_col: "user_id".into(),
930 to_col: "referrer_id".into(),
931 props: vec![],
932 tier: Tier::T1PostgresCatalog, // will be overwritten to T4
933 },
934 evidence: "LEFT ANTI JOIN found 0 orphan referrer_id rows (n=10000)".into(),
935 }]);
936 // Stamped T4 regardless of what the caller passed.
937 assert_eq!(plan.proposals[0].edge.tier, Tier::T4InclusionDependency);
938 let py = plan.to_pyspark();
939 assert!(py.contains("T4 PROPOSALS"), "proposals section present");
940 assert!(py.contains("REFERRED_BY"), "the proposal is shown");
941 assert!(py.contains("evidence:"), "with its evidence");
942 // But NEVER as an active edge writer.
943 assert!(
944 !py.contains("_write_edges(\"User\", \"REFERRED_BY\""),
945 "a T4 proposal is never emitted as a live edge load"
946 );
947 }
948}