knut_bifrost/rel2graph.rs
1//! **The canonical offline rel2graph engine seam.**
2//!
3//! bifröst *is* the rel2graph engine: a [`MappingSpec`](crate::MappingSpec) is
4//! compiled to a [`Transform`](crate::Transform) fabric and applied to a table's
5//! Arrow batches to produce a [`GraphMutation`](crate::GraphMutation) (the
6//! `VertexSet`s + `EdgeSet`s that ARE the graph). The live path runs this per
7//! round inside the [`Listener`](crate::Listener), reading from a
8//! [`Source`](crate::source) and writing to a [`GraphSink`](crate::sink::GraphSink).
9//!
10//! This module exposes the SAME work with **no source and no sink** — you hand it
11//! the per-table Arrow batches, it hands you the `GraphMutation`. It is the one
12//! entrypoint every DB-free consumer of the engine shares:
13//!
14//! * the [correctness + timing bench](../../knut_rel2graph_bench/index.html) drives
15//! it over a seeded synthetic database;
16//! * [`knut-flight`](../../knut_flight/index.html) drives it over an Iceberg /
17//! parquet export to serve a REAL graph as Arrow Flight;
18//! * the networkx file-export drives it to dump a node-list + edge-list + oracle;
19//! * **korp's `iceberg2graph` demo step** calls it to turn the tables it just read
20//! from the lakehouse into the graph it hands the viewer — the same graph the
21//! PySpark projection ([`GraphPlan::to_pyspark`](crate::GraphPlan::to_pyspark))
22//! and the thund projection (`knut_thund::rel2graph::to_thund_pipeline`) would
23//! build, since all three derive from ONE reviewed [`MappingSpec`].
24//!
25//! The point of the seam is that swapping the data behind it never touches the
26//! engine: whatever yields per-table [`RecordBatch`]es — the bench generator, a
27//! parquet directory, a Spark-Connect Iceberg read — implements [`TableSource`]
28//! and feeds the identical [`build_mutation`].
29
30use std::collections::BTreeMap;
31
32use arrow_array::RecordBatch;
33
34use crate::{
35 error::Result, mapping::MappingSpec, mutation::GraphMutation, registry::TransformRegistry,
36 transform::TableContext,
37};
38
39/// A source of Arrow batches keyed by fully-qualified table name — the seam the
40/// offline rel2graph engine reads from.
41///
42/// Implemented by anything that can produce a table's rows as Arrow: the bench's
43/// `GeneratedDb`, the in-memory [`ArrowTables`] (a parquet-directory load or an
44/// Iceberg scan collected into memory), or a bespoke adapter. [`build_mutation`]
45/// asks only for `batches(table)`; an unknown table returns an empty slice and is
46/// skipped, so a source may legitimately carry a superset of the spec's tables.
47pub trait TableSource {
48 /// The batches for `table`, or an empty slice if the source has no such table.
49 fn batches(&self, table: &str) -> &[RecordBatch];
50}
51
52/// **Run the bifröst rel2graph engine offline over `source`.**
53///
54/// Compiles every table's declarative pipeline once, applies the resulting
55/// [`Transform`](crate::Transform)s to that table's batches, and merges every
56/// step's output into one [`GraphMutation`]. This mirrors exactly what the live
57/// [`Listener`](crate::Listener) does per round, minus the source read and the
58/// sink write — so the mutation this returns is the mutation a live load would
59/// MERGE, at the same seam the correctness oracle asserts on.
60///
61/// Tables the `source` does not carry are skipped (empty batches), so a partial
62/// source yields a partial-but-consistent graph.
63///
64/// ```
65/// use knut_bifrost::rel2graph::{build_mutation, ArrowTables};
66/// use knut_bifrost::MappingSpec;
67/// # use arrow_array::{RecordBatch, Int64Array};
68/// # use arrow_schema::{Schema, Field, DataType};
69/// # use std::sync::Arc;
70/// let spec = MappingSpec::from_yaml(r#"
71/// version: 1
72/// tables:
73/// - table: lake.hr.employees
74/// pipeline:
75/// - vertex: { label: Employee, id_column: employee_id, properties: [] }
76/// "#).unwrap();
77/// # let schema = Arc::new(Schema::new(vec![Field::new("employee_id", DataType::Int64, false)]));
78/// # let batch = RecordBatch::try_new(schema, vec![Arc::new(Int64Array::from(vec![1i64, 2, 3]))]).unwrap();
79/// let mut src = ArrowTables::new();
80/// src.insert("lake.hr.employees", vec![batch]);
81/// let mutation = build_mutation(&spec, &src).unwrap();
82/// assert_eq!(mutation.vertices.len(), 1);
83/// ```
84pub fn build_mutation(spec: &MappingSpec, source: &dyn TableSource) -> Result<GraphMutation> {
85 build_mutation_reported(spec, source).map(|(m, _)| m)
86}
87
88/// **What a `build_mutation` run actually read.**
89///
90/// `build_mutation` skips a spec table the source does not carry, which is the right
91/// default (a partial source yields a partial-but-consistent graph) and is also
92/// exactly how an **empty graph comes back looking like a successful build**: point
93/// the source at the wrong namespace and every table is "skipped", the mutation is
94/// empty, and the call returns `Ok`.
95///
96/// This report is the discrimination. It is carried BESIDE the mutation rather than
97/// folded into it, because the mutation is what a sink MERGEs and a report is not.
98#[derive(Debug, Clone, Default, PartialEq, Eq)]
99pub struct BuildReport {
100 /// Spec tables the source carried rows for, in spec order.
101 pub tables_mapped: Vec<String>,
102 /// Spec tables the source carried NO rows for — absent, or present with zero
103 /// rows. These are the ones that silently vanish from an unreported build.
104 pub tables_empty: Vec<String>,
105 /// Total input rows read across every mapped table.
106 pub rows_read: usize,
107 /// Vertex rows in the resulting mutation.
108 pub vertices: usize,
109 /// Edge rows in the resulting mutation.
110 pub edges: usize,
111}
112
113impl BuildReport {
114 /// **The honest one-liner a caller uses instead of trusting `Ok`.**
115 ///
116 /// `Ok(self)` when at least one spec table contributed rows AND the mutation is
117 /// non-empty; otherwise a typed [`crate::error::BifrostError::Source`] naming
118 /// every table that came back empty. A build that mapped nothing is not a graph.
119 pub fn require_non_empty(self) -> Result<Self> {
120 if self.rows_read == 0 || (self.vertices == 0 && self.edges == 0) {
121 return Err(crate::error::BifrostError::Source(format!(
122 "the rel2graph build read {} row(s) from {} table(s) and produced {} vertex \
123 row(s) + {} edge row(s) — that is an EMPTY graph, not a built one. Tables \
124 the source carried nothing for: {:?}. Check the catalog, the namespace and \
125 the table names in the mapping spec.",
126 self.rows_read,
127 self.tables_mapped.len(),
128 self.vertices,
129 self.edges,
130 self.tables_empty,
131 )));
132 }
133 Ok(self)
134 }
135}
136
137/// [`build_mutation`] plus a [`BuildReport`] saying what it read. Same work, same
138/// result — this is the one implementation and `build_mutation` delegates to it, so
139/// the counts can never describe a different run than the mutation.
140pub fn build_mutation_reported(
141 spec: &MappingSpec,
142 source: &dyn TableSource,
143) -> Result<(GraphMutation, BuildReport)> {
144 let registry = TransformRegistry::new();
145 let mut mutation = GraphMutation::default();
146 let mut report = BuildReport::default();
147
148 for tm in &spec.tables {
149 let batches = source.batches(&tm.table);
150 let rows: usize = batches.iter().map(RecordBatch::num_rows).sum();
151 if batches.is_empty() || rows == 0 {
152 report.tables_empty.push(tm.table.clone());
153 continue;
154 }
155 report.tables_mapped.push(tm.table.clone());
156 report.rows_read += rows;
157 let ctx = TableContext {
158 table: tm.table.clone(),
159 };
160 let transforms = tm.compile(®istry)?;
161 for transform in &transforms {
162 let m = transform.apply(&ctx, batches)?;
163 mutation.merge(m);
164 }
165 }
166
167 report.vertices = mutation.vertices.iter().map(|s| s.batch.num_rows()).sum();
168 report.edges = mutation.edges.iter().map(|s| s.batch.num_rows()).sum();
169 Ok((mutation, report))
170}
171
172/// An in-memory [`TableSource`]: a map of fully-qualified table name → Arrow
173/// batches.
174///
175/// This is the adapter that turns "some Arrow I already have" into engine input.
176/// Fill it from a parquet directory, an Iceberg scan, a Spark-Connect read, or by
177/// hand, then drive [`build_mutation`] over real data with no bench and no live
178/// FalkorDB.
179#[derive(Debug, Clone, Default)]
180pub struct ArrowTables {
181 tables: BTreeMap<String, Vec<RecordBatch>>,
182}
183
184impl ArrowTables {
185 /// An empty source.
186 pub fn new() -> Self {
187 Self::default()
188 }
189
190 /// Add (or replace) a table's batches. Returns nothing — see
191 /// [`with_table`](Self::with_table) for the builder form.
192 pub fn insert(&mut self, table: impl Into<String>, batches: Vec<RecordBatch>) {
193 self.tables.insert(table.into(), batches);
194 }
195
196 /// Builder form of [`insert`](Self::insert): add a table, return `self`.
197 pub fn with_table(mut self, table: impl Into<String>, batches: Vec<RecordBatch>) -> Self {
198 self.insert(table, batches);
199 self
200 }
201
202 /// The table names this source carries, in sorted order.
203 pub fn table_names(&self) -> impl Iterator<Item = &str> {
204 self.tables.keys().map(String::as_str)
205 }
206
207 /// Whether the source carries no tables.
208 pub fn is_empty(&self) -> bool {
209 self.tables.is_empty()
210 }
211
212 /// The number of tables carried.
213 pub fn len(&self) -> usize {
214 self.tables.len()
215 }
216}
217
218impl TableSource for ArrowTables {
219 fn batches(&self, table: &str) -> &[RecordBatch] {
220 self.tables.get(table).map(Vec::as_slice).unwrap_or(&[])
221 }
222}
223
224/// A shared reference to a [`TableSource`] is itself a [`TableSource`], so
225/// `&db`/`&tables` can be passed where an owned source is not convenient.
226impl<T: TableSource + ?Sized> TableSource for &T {
227 fn batches(&self, table: &str) -> &[RecordBatch] {
228 (**self).batches(table)
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use arrow_array::Int64Array;
236 use arrow_schema::{DataType, Field, Schema};
237 use std::sync::Arc;
238
239 fn id_batch(col: &str, ids: &[i64]) -> RecordBatch {
240 let schema = Arc::new(Schema::new(vec![Field::new(col, DataType::Int64, false)]));
241 RecordBatch::try_new(schema, vec![Arc::new(Int64Array::from(ids.to_vec()))]).unwrap()
242 }
243
244 #[test]
245 fn arrow_tables_source_drives_the_engine() {
246 let spec = MappingSpec::from_yaml(
247 r#"
248version: 1
249tables:
250 - table: lake.hr.employees
251 pipeline:
252 - vertex: { label: Employee, id_column: employee_id, properties: [] }
253"#,
254 )
255 .unwrap();
256
257 let src = ArrowTables::new().with_table(
258 "lake.hr.employees",
259 vec![id_batch("employee_id", &[1, 2, 3])],
260 );
261 assert_eq!(src.len(), 1);
262
263 let mutation = build_mutation(&spec, &src).unwrap();
264 assert_eq!(
265 mutation.vertices.len(),
266 1,
267 "one VertexSet for the one table"
268 );
269 assert_eq!(mutation.vertices[0].label, "Employee");
270 assert_eq!(mutation.vertices[0].batch.num_rows(), 3);
271 }
272
273 #[test]
274 fn missing_tables_are_skipped_not_errors() {
275 let spec = MappingSpec::from_yaml(
276 r#"
277version: 1
278tables:
279 - table: present.table
280 pipeline:
281 - vertex: { label: A, id_column: id, properties: [] }
282 - table: absent.table
283 pipeline:
284 - vertex: { label: B, id_column: id, properties: [] }
285"#,
286 )
287 .unwrap();
288
289 // Source carries only `present.table`; `absent.table` yields no batches.
290 let src = ArrowTables::new().with_table("present.table", vec![id_batch("id", &[10, 20])]);
291 let mutation = build_mutation(&spec, &src).unwrap();
292 assert_eq!(
293 mutation.vertices.len(),
294 1,
295 "absent table skipped, present table mapped"
296 );
297 assert_eq!(mutation.vertices[0].label, "A");
298 }
299}