knut-bifrost 0.3.0

Bifröst — watch lakehouse tables (Iceberg via Spark Connect) and materialise them as a FalkorDB property graph
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
//! **rel2graph CODEGEN** — one [`MappingSpec`] projected onto executable
//! backends.
//!
//! bifröst **is** the rel2graph engine: a relational schema → a *mapping spec*
//! (nodes, merge keys, edges, directions, props) → materialised graph. Codegen
//! is *one tool* of that engine (its siblings: the live CDC listener, the
//! pipeline-config authoring, the schema→mapping inference in
//! [`crate::mapping::MappingSpec::from_arrow_schema`]). This module is the
//! **describe-first, execute-anywhere** half: it lowers a reviewed
//! [`MappingSpec`] into a single canonical [`GraphPlan`] and then *projects*
//! that plan onto a backend.
//!
//! ## The two-projections invariant
//!
//! A [`GraphPlan`] is projected two ways, and **both must agree exactly**:
//!
//! * [`GraphPlan::to_pyspark`] — a PySpark job that loads the schema into the
//!   graph sink (the **Spark** backend projection). Lives here, pure Rust.
//! * a **thund [`Pipeline`]** the all-Rust engine runs on *any*
//!   [`ExecBackend`] (Spark-Connect-SDP **or** native DataFusion) — lives in
//!   `knut-thund` (`knut_thund::rel2graph`, feature `rel2graph`), because that
//!   crate owns the execution IR. It projects from *this same* [`GraphPlan`].
//!
//! bifröst maps rel→graph **once**; thund picks the tech. Any divergence
//! between the two projections (a label, a merge key, an edge direction, the
//! batch size) is a bug — pinned by the `two_projections_agree` test in
//! `knut-thund` (which can see both projections without a dependency cycle:
//! `knut-pipelines` already depends on `knut-bifrost`, so bifröst must not
//! depend on thund; thund depending on bifröst is the acyclic direction).
//!
//! [`MappingSpec`]: crate::mapping::MappingSpec
//! [`Pipeline`]: https://docs.rs/knut-thund
//! [`ExecBackend`]: https://docs.rs/knut-thund

use std::fmt::Write as _;

use crate::error::{BifrostError, Result};
use crate::mapping::{MappingSpec, PipelineStep, SyncMode};

// ═══════════════════════════════════════════════════════════════════════════
// Options + backend/mode enums
// ═══════════════════════════════════════════════════════════════════════════

/// The relational **source plane** a schema is read from. In the intended
/// deployment Iceberg is the *data plane* and Postgres the *schema plane*
/// (the T1 catalog), so both are first-class.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceKind {
    /// A Postgres (or Postgres-catalog-fronted) source.
    Postgres,
    /// An Iceberg table (the streaming-capable data plane).
    Iceberg,
}

impl SourceKind {
    /// The lower-case token used in generated artifacts / RON.
    pub fn token(self) -> &'static str {
        match self {
            SourceKind::Postgres => "postgres",
            SourceKind::Iceberg => "iceberg",
        }
    }
}

/// The graph **sink** a plan targets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SinkKind {
    /// FalkorDB (Cypher `UNWIND … MERGE`, the runtime sink bifröst already
    /// drives in [`crate::sink`]).
    FalkorDb,
    /// GraphAr columnar graph files.
    GraphAr,
}

impl SinkKind {
    /// The lower-case token used in generated artifacts / RON.
    pub fn token(self) -> &'static str {
        match self {
            SinkKind::FalkorDb => "falkordb",
            SinkKind::GraphAr => "graphar",
        }
    }
}

/// Batch vs streaming. Streaming requires an Iceberg source (the changelog
/// scan) — enforced by [`GraphPlan::from_mapping`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanMode {
    /// A bounded, idempotent full/incremental load.
    Batch,
    /// A continuous changelog-driven load (needs Iceberg).
    Stream,
}

impl PlanMode {
    /// The lower-case token used in generated artifacts / RON.
    pub fn token(self) -> &'static str {
        match self {
            PlanMode::Batch => "batch",
            PlanMode::Stream => "stream",
        }
    }
}

/// **Resolution tier** — where a node/edge mapping's authority *came from*, per
/// the rel2graph spec. Recorded on every plan element and surfaced in the
/// WARNINGS provenance block so a reviewer can see how confident each mapping
/// is (T1 catalog fact ▸ T4 inference proposal).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Tier {
    /// **T1** — the upstream Postgres catalog (PK/FK constraints). The answer
    /// for most schemas: Iceberg is the data plane, Postgres the schema plane.
    T1PostgresCatalog,
    /// **T2** — Iceberg identifier-field-ids (the table's declared identity).
    T2IcebergFieldIds,
    /// **T3** — `knut.*` TBLPROPERTIES on the table
    /// (`knut.node.label`/`knut.node.key`/`knut.edge.*`). What a hand-authored
    /// or reviewed [`MappingSpec`] encodes.
    T3TblProperties,
    /// **T4** — inclusion-dependency *inference* (the LEFT-ANTI-JOIN orphan
    /// test). **Proposals only** — emitted COMMENTED-OUT with evidence, never
    /// applied. A T4 element must never appear in the active plan.
    T4InclusionDependency,
}

impl Tier {
    /// A stable short token (`T1`…`T4`).
    pub fn token(self) -> &'static str {
        match self {
            Tier::T1PostgresCatalog => "T1",
            Tier::T2IcebergFieldIds => "T2",
            Tier::T3TblProperties => "T3",
            Tier::T4InclusionDependency => "T4",
        }
    }

    /// A one-line human description of the tier.
    pub fn describe(self) -> &'static str {
        match self {
            Tier::T1PostgresCatalog => "upstream Postgres catalog (PK/FK constraints)",
            Tier::T2IcebergFieldIds => "Iceberg identifier-field-ids",
            Tier::T3TblProperties => "knut.* TBLPROPERTIES on the table",
            Tier::T4InclusionDependency => {
                "inclusion-dependency inference (LEFT ANTI JOIN orphan test) — PROPOSAL only"
            }
        }
    }
}

/// Knobs for [`GraphPlan::from_mapping`]. Defaults match the rel2graph spec
/// (1000-row UNWIND batches, single coalesced writer, Iceberg→FalkorDB batch).
#[derive(Debug, Clone)]
pub struct CodegenOptions {
    /// The source plane the schema was read from.
    pub source: SourceKind,
    /// The graph sink to load.
    pub sink: SinkKind,
    /// Batch or streaming (streaming needs an Iceberg source).
    pub mode: PlanMode,
    /// Rows per `UNWIND … MERGE` batch (the idempotent write unit).
    pub batch_rows: usize,
    /// Writer concurrency — the `coalesce(N)` partition count the sink client
    /// runs inside.
    pub concurrency: usize,
    /// The single checkpoint / storage location (one per query).
    pub checkpoint: String,
    /// The tier the *declared* mapping elements are attributed to. A reviewed
    /// [`MappingSpec`] is T3 (its `knut.*` properties); a spec derived straight
    /// from an Iceberg schema is T2. The live-catalog T1 resolution is a
    /// worklist item (see the design doc).
    pub declared_tier: Tier,
}

impl Default for CodegenOptions {
    fn default() -> Self {
        CodegenOptions {
            source: SourceKind::Iceberg,
            sink: SinkKind::FalkorDb,
            mode: PlanMode::Batch,
            batch_rows: 1000,
            concurrency: 1,
            checkpoint: "/tmp/rel2graph/checkpoint".to_string(),
            declared_tier: Tier::T3TblProperties,
        }
    }
}

// ═══════════════════════════════════════════════════════════════════════════
// The canonical plan — the ONE object both projections derive from
// ═══════════════════════════════════════════════════════════════════════════

/// A node projection: one merge-key table → one graph node.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodePlan {
    /// The fully-qualified source table.
    pub table: String,
    /// The graph node label (PascalCase singular).
    pub label: String,
    /// The merge key column — node identity.
    pub key: String,
    /// The non-key, non-FK scalar property columns, in **ordinal** order.
    pub props: Vec<String>,
    /// Where this node's authority came from.
    pub tier: Tier,
}

/// An edge projection: a directed relationship `from → to`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EdgePlan {
    /// The source table the edge rows are read from.
    pub table: String,
    /// The tail (child / "many") node label.
    pub from_label: String,
    /// The relationship type (SCREAMING_SNAKE).
    pub rel: String,
    /// The head (parent / "one") node label.
    pub to_label: String,
    /// The tail node's **merge-key property** — the property name the `from`
    /// node is identified/MERGEd by in the graph (its `id_column`). This is the
    /// property NAME on the node, which is NOT necessarily the column the value
    /// is read from — see [`Self::from_col`].
    pub from_key: String,
    /// The head node's **merge-key property** — the property name the `to` node
    /// is identified/MERGEd by in the graph (its `id_column`). For a self-ref /
    /// renamed FK this differs from the column the value is read from
    /// ([`Self::to_col`]): e.g. `Employee-REPORTS_TO->Employee` MERGEs on
    /// `employee_id` but reads the endpoint value from the `manager_id` column.
    pub to_key: String,
    /// The column in `table` the **tail** endpoint VALUE is read from
    /// (`src.column`). Usually equal to [`Self::from_key`]; differs only when the
    /// tail FK column is named differently from the node's key.
    pub from_col: String,
    /// The column in `table` the **head** endpoint VALUE is read from
    /// (`dst.column`, i.e. the FK column). Differs from [`Self::to_key`] for a
    /// self-ref / renamed FK (`manager_id` read, MERGEd on `employee_id`).
    pub to_col: String,
    /// Edge property columns, ordinal order.
    pub props: Vec<String>,
    /// Where this edge's authority came from.
    pub tier: Tier,
}

/// A **T4 proposal** — an inferred relationship the codegen must NOT apply,
/// only surface commented-out with its evidence. Carried through the plan so
/// both projections emit it identically (as a comment), never as live code.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EdgeProposal {
    /// The candidate edge (its `tier` is always [`Tier::T4InclusionDependency`]).
    pub edge: EdgePlan,
    /// Human-readable evidence (e.g. the orphan-row count of the LEFT ANTI
    /// JOIN test) a reviewer confirms before promoting it.
    pub evidence: String,
}

/// The canonical rel2graph plan: **the single source both projections read**.
///
/// Deterministic by construction (see [`GraphPlan::from_mapping`]):
/// tables schema-then-name, node props ordinal, nodes-before-edges, edges
/// sorted `(from_label, rel, to_label)`. No timestamps, no randomness, no
/// hashmap iteration order — the same input is the same bytes, always.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphPlan {
    /// Source plane.
    pub source: SourceKind,
    /// Graph sink.
    pub sink: SinkKind,
    /// Batch/streaming.
    pub mode: PlanMode,
    /// Rows per `UNWIND … MERGE` batch.
    pub batch_rows: usize,
    /// Writer concurrency (`coalesce(N)`).
    pub concurrency: usize,
    /// The single checkpoint / storage location.
    pub checkpoint: String,
    /// Nodes, in canonical order.
    pub nodes: Vec<NodePlan>,
    /// Edges, in canonical order.
    pub edges: Vec<EdgePlan>,
    /// T4 inference proposals — emitted commented-out, never applied.
    pub proposals: Vec<EdgeProposal>,
    /// Provenance + honesty notes, closing every artifact.
    pub warnings: Vec<String>,
}

impl GraphPlan {
    /// **Lower a reviewed [`MappingSpec`] into the canonical plan.**
    ///
    /// Walks tables in schema-then-name order; each `vertex` step becomes a
    /// [`NodePlan`], each `edge`/`fk_edges` step a [`EdgePlan`] (direction =
    /// `src → dst`, the child→parent FK direction). `custom` steps are opaque
    /// and cannot be projected to a declarative graph load, so they are
    /// SURFACED in [`warnings`](Self::warnings), never silently dropped.
    ///
    /// Errors on the blocking gaps the spec names: a streaming plan on a
    /// non-Iceberg source, or a node with no merge key.
    pub fn from_mapping(spec: &MappingSpec, opts: &CodegenOptions) -> Result<GraphPlan> {
        if opts.mode == PlanMode::Stream && opts.source != SourceKind::Iceberg {
            return Err(BifrostError::Other(format!(
                "MODE/SOURCE conflict: streaming needs an Iceberg source, got {:?}",
                opts.source
            )));
        }

        let mut nodes: Vec<NodePlan> = Vec::new();
        let mut edges: Vec<EdgePlan> = Vec::new();
        let mut warnings: Vec<String> = Vec::new();

        // Tables schema-then-name (namespace before leaf), deterministically.
        let mut tables: Vec<&crate::mapping::TableMapping> = spec.tables.iter().collect();
        tables.sort_by(|a, b| table_sort_key(&a.table).cmp(&table_sort_key(&b.table)));

        for tm in tables {
            if tm.mode == SyncMode::Cdc && opts.mode == PlanMode::Batch {
                warnings.push(format!(
                    "table `{}` declares CDC sync but the plan mode is batch — the changelog \
                     is read as a full insert load (see MODE)",
                    tm.table
                ));
            }
            for step in &tm.pipeline {
                match step {
                    PipelineStep::Vertex { vertex } => {
                        if vertex.id_column.is_empty() {
                            return Err(BifrostError::Other(format!(
                                "node `{}` (table `{}`) has no merge key",
                                vertex.label, tm.table
                            )));
                        }
                        nodes.push(NodePlan {
                            table: tm.table.clone(),
                            label: vertex.label.clone(),
                            key: vertex.id_column.clone(),
                            props: vertex.properties.clone(),
                            tier: opts.declared_tier,
                        });
                    }
                    PipelineStep::Edge { edge: t } | PipelineStep::FkEdges { fk_edges: t } => {
                        edges.push(EdgePlan {
                            table: tm.table.clone(),
                            from_label: t.src.label.clone(),
                            rel: t.edge_type.clone(),
                            to_label: t.dst.label.clone(),
                            from_key: t
                                .src
                                .id_column
                                .clone()
                                .unwrap_or_else(|| t.src.column.clone()),
                            to_key: t
                                .dst
                                .id_column
                                .clone()
                                .unwrap_or_else(|| t.dst.column.clone()),
                            // The VALUE is always read from the endpoint's own
                            // `column` (the transform fabric reads `src.column`
                            // / `dst.column`); only the MERGE *property name*
                            // comes from `id_column`. Keeping these separate is
                            // what makes self-ref / renamed FKs correct.
                            from_col: t.src.column.clone(),
                            to_col: t.dst.column.clone(),
                            props: t.properties.clone(),
                            tier: opts.declared_tier,
                        });
                    }
                    PipelineStep::Sql { .. } => {
                        warnings.push(format!(
                            "table `{}` uses a `sql:` step — its output is decided by the query \
                             at run time and cannot be projected to a declarative graph load; wire \
                             it by hand on the target backend (NOT dropped)",
                            tm.table
                        ));
                    }
                    PipelineStep::Custom { custom } => {
                        warnings.push(format!(
                            "table `{}` uses custom transform `{custom}` — an opaque Rust step \
                             that cannot be projected to a declarative graph load; wire it by \
                             hand on the target backend (NOT dropped)",
                            tm.table
                        ));
                    }
                }
            }
        }

        // DETERMINISM: edges sorted (from_label, rel, to_label). Nodes keep the
        // schema-then-name table order (one merge-key table → one node); props
        // stay ordinal.
        edges.sort_by(|a, b| {
            (&a.from_label, &a.rel, &a.to_label).cmp(&(&b.from_label, &b.rel, &b.to_label))
        });

        // Provenance summary — the WARNINGS block leads with it.
        warnings.insert(
            0,
            format!(
                "provenance: {} node(s) + {} edge(s) resolved at tier {} ({}); {} T4 proposal(s) \
                 held back (commented-out, never applied)",
                nodes.len(),
                edges.len(),
                opts.declared_tier.token(),
                opts.declared_tier.describe(),
                0
            ),
        );

        Ok(GraphPlan {
            source: opts.source,
            sink: opts.sink,
            mode: opts.mode,
            batch_rows: opts.batch_rows,
            concurrency: opts.concurrency,
            checkpoint: opts.checkpoint.clone(),
            nodes,
            edges,
            proposals: Vec::new(),
            warnings,
        })
    }

    /// Attach T4 inference proposals (from an inclusion-dependency pass run
    /// elsewhere). They are stamped [`Tier::T4InclusionDependency`] and will
    /// only ever be emitted COMMENTED-OUT. Returns `self` for chaining.
    pub fn with_proposals(mut self, proposals: impl IntoIterator<Item = EdgeProposal>) -> Self {
        for mut p in proposals {
            p.edge.tier = Tier::T4InclusionDependency;
            self.proposals.push(p);
        }
        // Re-summarise the proposal count in the provenance line.
        if let Some(first) = self.warnings.first_mut() {
            if first.starts_with("provenance:") {
                *first = format!(
                    "provenance: {} node(s) + {} edge(s) resolved; {} T4 proposal(s) held back \
                     (commented-out, never applied)",
                    self.nodes.len(),
                    self.edges.len(),
                    self.proposals.len()
                );
            }
        }
        self
    }

    /// A canonical, human-readable digest of the plan's **labels, merge keys,
    /// edge directions and batch size** — the *contract* both projections must
    /// honour. The two-projections-agree test compares what it can parse back
    /// out of each artifact against this. Deterministic and stable.
    pub fn contract(&self) -> String {
        let mut s = String::new();
        let _ = writeln!(s, "batch_rows={}", self.batch_rows);
        for n in &self.nodes {
            let _ = writeln!(s, "node {}={}", n.label, n.key);
        }
        for e in &self.edges {
            let _ = writeln!(s, "edge {}-{}->{}", e.from_label, e.rel, e.to_label);
        }
        s
    }

    // ───────────────────────────────────────────────────────────────────────
    // Projection (a): the PySpark job — the Spark backend
    // ───────────────────────────────────────────────────────────────────────

    /// **Projection (a): a PySpark job** loading the schema into the graph
    /// sink. Obeys the rel2graph Python hard rules: a FAIR scheduler, indexes
    /// created at startup, **nodes-before-edges**, the sink client constructed
    /// *inside* the partition function, a `coalesce()` before
    /// `foreachPartition`, and idempotent `UNWIND $rows AS r MERGE` in
    /// `batch_rows`-sized chunks. Streaming wraps the same per-partition writer
    /// in a single `foreachBatch` with one `checkpointLocation`.
    ///
    /// Deterministic: same plan ⇒ byte-identical Python.
    pub fn to_pyspark(&self) -> String {
        let mut s = String::new();
        let p = &mut s;
        let _ = writeln!(
            p,
            "# ── rel2graph codegen · Spark backend projection ──────────────────"
        );
        let _ = writeln!(
            p,
            "# source={} sink={} mode={} batch_rows={} concurrency={}",
            self.source.token(),
            self.sink.token(),
            self.mode.token(),
            self.batch_rows,
            self.concurrency
        );
        let _ = writeln!(
            p,
            "# GENERATED — one MappingSpec, two projections; edits belong upstream."
        );
        let _ = writeln!(p, "from pyspark.sql import SparkSession");
        let _ = writeln!(p);
        let _ = writeln!(p, "BATCH_ROWS = {}", self.batch_rows);
        let _ = writeln!(p, "CHECKPOINT = {:?}", self.checkpoint);
        let _ = writeln!(p);

        // Sink client + per-batch MERGE helpers, built INSIDE the partition fn.
        let _ = writeln!(p, "def _sink():");
        let _ = writeln!(
            p,
            "    # sink client constructed INSIDE the partition fn (never shipped from the driver)"
        );
        match self.sink {
            SinkKind::FalkorDb => {
                let _ = writeln!(p, "    from falkordb import FalkorDB");
                let _ = writeln!(
                    p,
                    "    return FalkorDB(host='localhost', port=6379).select_graph('rel2graph')"
                );
            }
            SinkKind::GraphAr => {
                let _ = writeln!(p, "    import pygraphar  # columnar graph writer");
                let _ = writeln!(p, "    return pygraphar.Writer('rel2graph')");
            }
        }
        let _ = writeln!(p);

        // Node writer.
        let _ = writeln!(p, "def _write_nodes(label, key, rows):");
        let _ = writeln!(p, "    g = _sink(); buf = []");
        let _ = writeln!(p, "    def flush():");
        let _ = writeln!(p, "        if not buf: return");
        p.push_str(
            "        g.query('UNWIND $rows AS r MERGE (n:`' + label + '` {`' + key + '`: r[\"' + key + '\"]}) SET n += r', {'rows': buf})\n",
        );
        let _ = writeln!(p, "        buf.clear()");
        let _ = writeln!(p, "    for row in rows:");
        let _ = writeln!(p, "        buf.append(row.asDict())");
        let _ = writeln!(p, "        if len(buf) >= BATCH_ROWS: flush()");
        let _ = writeln!(p, "    flush()");
        let _ = writeln!(p);

        // Edge writer.
        let _ = writeln!(p, "def _write_edges(fl, rel, tl, fk, tk, rows):");
        let _ = writeln!(p, "    g = _sink(); buf = []");
        let _ = writeln!(p, "    def flush():");
        let _ = writeln!(p, "        if not buf: return");
        p.push_str(
            "        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",
        );
        let _ = writeln!(p, "        buf.clear()");
        let _ = writeln!(p, "    for row in rows:");
        let _ = writeln!(
            p,
            "        d = row.asDict(); props = {{k: v for k, v in d.items() if k not in ('src', 'dst')}}"
        );
        let _ = writeln!(
            p,
            "        buf.append({{'src': d['src'], 'dst': d['dst'], 'props': props}})"
        );
        let _ = writeln!(p, "        if len(buf) >= BATCH_ROWS: flush()");
        let _ = writeln!(p, "    flush()");
        let _ = writeln!(p);

        // Startup indexes — one per node label+key.
        let _ = writeln!(p, "def ensure_indexes(g):");
        if self.nodes.is_empty() {
            let _ = writeln!(p, "    pass  # no nodes");
        }
        for n in &self.nodes {
            let _ = writeln!(
                p,
                "    g.query('CREATE INDEX FOR (n:`{}`) ON (n.`{}`)')  # {}",
                n.label,
                n.key,
                n.tier.token()
            );
        }
        let _ = writeln!(p);

        // main(): FAIR scheduler, nodes before edges.
        let _ = writeln!(p, "def main():");
        let _ = writeln!(
            p,
            "    spark = SparkSession.builder.config('spark.scheduler.mode', 'FAIR').getOrCreate()"
        );
        let _ = writeln!(p, "    ensure_indexes(_sink())");
        let stream = self.mode == PlanMode::Stream;
        let reader = if stream { "readStream.table" } else { "table" };

        let _ = writeln!(p, "    # ── nodes (before edges) ──");
        for n in &self.nodes {
            let cols = node_select_cols(n);
            let df = format!("df_node_{}", sanitize(&n.label));
            let _ = writeln!(
                p,
                "    {df} = spark.{reader}({:?}).selectExpr({cols})",
                n.table
            );
            if stream {
                let _ = writeln!(
                    p,
                    "    {df}.writeStream.option('checkpointLocation', CHECKPOINT + '/node_{}').foreachBatch(",
                    sanitize(&n.label)
                );
                let _ = writeln!(
                    p,
                    "        lambda b, _: b.coalesce({}).foreachPartition(lambda rows: _write_nodes({:?}, {:?}, rows))).start()",
                    self.concurrency, n.label, n.key
                );
            } else {
                let _ = writeln!(
                    p,
                    "    {df}.coalesce({}).foreachPartition(lambda rows: _write_nodes({:?}, {:?}, rows))",
                    self.concurrency, n.label, n.key
                );
            }
        }

        let _ = writeln!(p, "    # ── edges ──");
        for e in &self.edges {
            let cols = edge_select_cols(e);
            let df = format!(
                "df_edge_{}_{}_{}",
                sanitize(&e.from_label),
                sanitize(&e.rel),
                sanitize(&e.to_label)
            );
            let _ = writeln!(
                p,
                "    {df} = spark.{reader}({:?}).selectExpr({cols})",
                e.table
            );
            if stream {
                let _ = writeln!(
                    p,
                    "    {df}.writeStream.option('checkpointLocation', CHECKPOINT + '/edge_{}_{}_{}').foreachBatch(",
                    sanitize(&e.from_label),
                    sanitize(&e.rel),
                    sanitize(&e.to_label)
                );
                let _ = writeln!(
                    p,
                    "        lambda b, _: b.coalesce({}).foreachPartition(lambda rows: _write_edges({:?}, {:?}, {:?}, {:?}, {:?}, rows))).start()",
                    self.concurrency, e.from_label, e.rel, e.to_label, e.from_key, e.to_key
                );
            } else {
                let _ = writeln!(
                    p,
                    "    {df}.coalesce({}).foreachPartition(lambda rows: _write_edges({:?}, {:?}, {:?}, {:?}, {:?}, rows))",
                    self.concurrency, e.from_label, e.rel, e.to_label, e.from_key, e.to_key
                );
            }
        }
        if stream {
            let _ = writeln!(p, "    spark.streams.awaitAnyTermination()");
        }
        let _ = writeln!(p);
        let _ = writeln!(p, "if __name__ == '__main__':");
        let _ = writeln!(p, "    main()");

        // T4 proposals — commented out with evidence, never applied.
        self.emit_proposals_py(p);
        // WARNINGS block closes the artifact.
        self.emit_warnings(p, "#");
        s
    }

    fn emit_proposals_py(&self, p: &mut String) {
        if self.proposals.is_empty() {
            return;
        }
        let _ = writeln!(p);
        let _ = writeln!(
            p,
            "# ── T4 PROPOSALS (inclusion-dependency inference) — NOT applied ──"
        );
        let _ = writeln!(
            p,
            "# Confirm the evidence, then add upstream in the MappingSpec. Never auto-applied."
        );
        for pr in &self.proposals {
            let e = &pr.edge;
            let _ = writeln!(
                p,
                "# ({}:`{}`)-[:`{}`]->({}:`{}`)  evidence: {}",
                sanitize(&e.from_label),
                e.from_label,
                e.rel,
                sanitize(&e.to_label),
                e.to_label,
                pr.evidence
            );
        }
    }

    /// The WARNINGS block, prefixed with `line_prefix` (e.g. `#` for Python,
    /// `//` for RON). Closes every artifact — provenance first, then notes.
    pub fn emit_warnings(&self, p: &mut String, line_prefix: &str) {
        let _ = writeln!(p);
        let _ = writeln!(p, "{line_prefix} ── WARNINGS ──");
        if self.warnings.is_empty() {
            let _ = writeln!(p, "{line_prefix} (none)");
        }
        for w in &self.warnings {
            let _ = writeln!(p, "{line_prefix} {w}");
        }
    }
}

// ── helpers ─────────────────────────────────────────────────────────────────

/// Schema-then-name sort key: `(namespace, leaf)` so `a.z` precedes `b.a` on
/// namespace but `s.accounts` precedes `s.users` on leaf. Deterministic.
fn table_sort_key(table: &str) -> (String, String) {
    match table.rsplit_once('.') {
        Some((ns, leaf)) => (ns.to_string(), leaf.to_string()),
        None => (String::new(), table.to_string()),
    }
}

/// Python identifier-safe form of a label/rel for a variable name.
fn sanitize(s: &str) -> String {
    s.chars()
        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
        .collect()
}

/// `selectExpr(...)` column list for a node: key first, then ordinal props,
/// each backtick-quoted.
fn node_select_cols(n: &NodePlan) -> String {
    let mut cols = vec![format!("\"`{}`\"", n.key)];
    cols.extend(n.props.iter().map(|c| format!("\"`{c}`\"")));
    cols.join(", ")
}

/// `selectExpr(...)` column list for an edge: `from_col AS src`, `to_col AS
/// dst`, then ordinal props (as themselves), each backtick-quoted.
///
/// The endpoint VALUES are read from the FK columns (`from_col`/`to_col`), NOT
/// the merge-key property names (`from_key`/`to_key`) — they differ for a
/// self-ref / renamed FK (`manager_id AS dst`, MERGEd on `employee_id`).
fn edge_select_cols(e: &EdgePlan) -> String {
    let mut parts = vec![
        format!("\"`{}` AS src\"", e.from_col),
        format!("\"`{}` AS dst\"", e.to_col),
    ];
    for c in &e.props {
        parts.push(format!("\"`{c}`\""));
    }
    parts.join(", ")
}

// ═══════════════════════════════════════════════════════════════════════════
// Tests — canonical plan + the PySpark (Spark backend) projection. The
// *two-projections-agree* invariant is pinned in `knut-thund` (which can see
// the thund `Pipeline` projection too); here we pin determinism, ordering,
// provenance, and the Python hard rules.
// ═══════════════════════════════════════════════════════════════════════════

#[cfg(test)]
mod tests {
    use super::*;
    use crate::mapping::MappingSpec;

    /// A two-table social schema: users (with a company FK) + companies, whose
    /// declaration order is deliberately "wrong" (users before companies, edge
    /// listed before the plain node) so the canonical ordering has work to do.
    fn sample_spec() -> MappingSpec {
        let yaml = r#"
version: 1
tables:
  - table: lake.social.users
    pipeline:
      - vertex: { label: User, id_column: user_id, properties: [name, age] }
      - fk_edges:
          type: WORKS_AT
          src: { label: User,    column: user_id }
          dst: { label: Company, column: company_id }
          properties: [since]
  - table: lake.social.companies
    pipeline:
      - vertex: { label: Company, id_column: company_id, properties: [name] }
"#;
        MappingSpec::from_yaml(yaml).expect("sample yaml parses")
    }

    #[test]
    fn plan_is_canonical_and_deterministic() {
        let spec = sample_spec();
        let opts = CodegenOptions::default();
        let a = GraphPlan::from_mapping(&spec, &opts).unwrap();
        let b = GraphPlan::from_mapping(&spec, &opts).unwrap();
        // Byte-identical PySpark on repeat — no timestamps/random/hashmap order.
        assert_eq!(a.to_pyspark(), b.to_pyspark(), "codegen is deterministic");

        // Tables schema-then-name: `companies` (leaf) sorts before `users`, so
        // its node comes first even though it was declared last.
        assert_eq!(a.nodes.len(), 2);
        assert_eq!(a.nodes[0].label, "Company");
        assert_eq!(a.nodes[1].label, "User");
        // Props stay ordinal.
        assert_eq!(a.nodes[1].props, vec!["name", "age"]);

        // Edge: direction User -[WORKS_AT]-> Company, keys carried.
        assert_eq!(a.edges.len(), 1);
        let e = &a.edges[0];
        assert_eq!(
            (e.from_label.as_str(), e.rel.as_str(), e.to_label.as_str()),
            ("User", "WORKS_AT", "Company")
        );
        assert_eq!(e.from_key, "user_id");
        assert_eq!(e.to_key, "company_id");
    }

    #[test]
    fn pyspark_obeys_the_python_hard_rules() {
        let plan = GraphPlan::from_mapping(&sample_spec(), &CodegenOptions::default()).unwrap();
        let py = plan.to_pyspark();

        assert!(py.contains("spark.scheduler.mode"), "FAIR scheduler set");
        assert!(py.contains("'FAIR'"));
        assert!(py.contains("ensure_indexes"), "indexes created at startup");
        assert!(py.contains("CREATE INDEX FOR (n:`User`) ON (n.`user_id`)"));
        assert!(
            py.contains("def _sink():"),
            "sink client built inside partition fn"
        );
        assert!(
            py.contains(".coalesce(1).foreachPartition("),
            "coalesce before foreachPartition"
        );
        assert!(
            py.contains("UNWIND $rows AS r MERGE"),
            "idempotent UNWIND MERGE"
        );
        assert!(py.contains("if len(buf) >= BATCH_ROWS:"), "batched writes");
        assert!(py.contains("BATCH_ROWS = 1000"));

        // REGRESSION GUARD: the Cypher `MERGE (n:… {`key`: …})` carries REAL
        // SINGLE braces `{`/`}` in the emitted text. These are literal `{` in
        // the source; emitting them via `format!`/`writeln!` (which read `{` as
        // a placeholder) is a compile error that has bitten the kube-feature
        // build — so they are built with a raw `push_str`, and this pins that
        // the single-brace Cypher survives to the output verbatim.
        assert!(
            py.contains("MERGE (n:`' + label + '` {`' + key + '`: r[\"' + key + '\"]})"),
            "node MERGE keeps its single-brace Cypher: {py}"
        );
        assert!(
            py.contains("MERGE (a:`' + fl + '` {`' + fk + '`: r.src})"),
            "edge MERGE keeps its single-brace Cypher"
        );
        assert!(
            py.contains("{'rows': buf}"),
            "the params dict uses single braces"
        );
        // And never the doubled braces a `format!` escape would leave behind.
        assert!(!py.contains("{{"), "no doubled `{{` leaked into the output");
        assert!(!py.contains("}}"), "no doubled `}}` leaked into the output");

        // Nodes-before-edges: both node writers precede the edge writer in main().
        let main = &py[py.find("def main()").unwrap()..];
        let node_at = main.find("_write_nodes").unwrap();
        let edge_at = main.find("_write_edges").unwrap();
        assert!(node_at < edge_at, "nodes are loaded before edges");

        // WARNINGS block closes the artifact, provenance first.
        assert!(py.trim_end().contains("── WARNINGS ──"));
        assert!(py.contains("provenance:"));
    }

    #[test]
    fn streaming_needs_iceberg_and_wraps_one_foreachbatch() {
        let spec = sample_spec();
        // Stream on Postgres is refused up front.
        let bad = CodegenOptions {
            source: SourceKind::Postgres,
            mode: PlanMode::Stream,
            ..Default::default()
        };
        assert!(
            GraphPlan::from_mapping(&spec, &bad).is_err(),
            "stream needs iceberg"
        );

        let ok = CodegenOptions {
            source: SourceKind::Iceberg,
            mode: PlanMode::Stream,
            ..Default::default()
        };
        let py = GraphPlan::from_mapping(&spec, &ok).unwrap().to_pyspark();
        assert!(py.contains("readStream.table"), "streaming reader");
        assert!(
            py.contains("foreachBatch("),
            "foreachBatch wraps the per-partition writer"
        );
        assert!(
            py.contains("checkpointLocation"),
            "one checkpointLocation per query"
        );
        assert!(py.contains("awaitAnyTermination"));
    }

    #[test]
    fn custom_step_is_surfaced_not_dropped() {
        let yaml = r#"
version: 1
tables:
  - table: lake.x.t
    pipeline:
      - vertex: { label: T, id_column: id }
      - custom: my_special_transform
"#;
        let spec = MappingSpec::from_yaml(yaml).unwrap();
        let plan = GraphPlan::from_mapping(&spec, &CodegenOptions::default()).unwrap();
        assert_eq!(plan.nodes.len(), 1);
        assert!(
            plan.warnings
                .iter()
                .any(|w| w.contains("my_special_transform") && w.contains("NOT dropped")),
            "the opaque custom step is surfaced in WARNINGS: {:?}",
            plan.warnings
        );
    }

    #[test]
    fn t4_proposals_are_emitted_commented_out_never_applied() {
        let plan = GraphPlan::from_mapping(&sample_spec(), &CodegenOptions::default())
            .unwrap()
            .with_proposals([EdgeProposal {
                edge: EdgePlan {
                    table: "lake.social.users".into(),
                    from_label: "User".into(),
                    rel: "REFERRED_BY".into(),
                    to_label: "User".into(),
                    from_key: "user_id".into(),
                    to_key: "user_id".into(),
                    from_col: "user_id".into(),
                    to_col: "referrer_id".into(),
                    props: vec![],
                    tier: Tier::T1PostgresCatalog, // will be overwritten to T4
                },
                evidence: "LEFT ANTI JOIN found 0 orphan referrer_id rows (n=10000)".into(),
            }]);
        // Stamped T4 regardless of what the caller passed.
        assert_eq!(plan.proposals[0].edge.tier, Tier::T4InclusionDependency);
        let py = plan.to_pyspark();
        assert!(py.contains("T4 PROPOSALS"), "proposals section present");
        assert!(py.contains("REFERRED_BY"), "the proposal is shown");
        assert!(py.contains("evidence:"), "with its evidence");
        // But NEVER as an active edge writer.
        assert!(
            !py.contains("_write_edges(\"User\", \"REFERRED_BY\""),
            "a T4 proposal is never emitted as a live edge load"
        );
    }
}