Skip to main content

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}