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

nordisk/knut

.nornir/reasoning.md

knut — reasoning (f0, why this overall shape)

Class: reasoning. This file holds the why of the whole system — why the seven functions are stacked the way they are, and the system-wide decisions + roads not taken. The descriptive shape lives in design.md, which by rule mentions none of this. Per-function why lives in each *-reasoning.md.

Why a knytpunkt at all (the one payoff)

knut exists to carry graph data the whole way from a columnar lake to a live, queryable graph and back, in one Arrow pipe with no JVM required. Every rival covers part of that arc — GraphAr the files, Neo4j the graph engine, Spark+GraphFrames the compute — but none carries a RecordBatch from an Iceberg snapshot into a queryable FalkorDB graph and back out over Flight SQL without a JVM. The columnar seams (lake ↔ Arrow ↔ Flight) carry no serialization tax — they are Arc bumps on one Arrow tree. The graph store itself is the honest exception: FalkorDB speaks RESP over the valkey socket, so rows are fully serialized into Cypher/bulk blobs on the way in and RESP-decoded into Arrow builders on the way out. That single arc is the reason the seven functions exist and the reason they are Arrow-native end to end everywhere the wire format allows it.

Stack versions (newest, verified 2026-07-18)

knut tracks the newest of each core dependency; the analytics vintage matches korp's standardized Tools stack (Spark 4.1.x + Iceberg 1.11.0 containers).

Component knut pins Newest available Note
Apache Arrow (Rust, arrow/arrow-array/arrow-flight/parquet) 59 (→ 59.3.0) 59.3.0 newest major; whole workspace on one tree
DataFusion (native/bench engine) 55 (→ 55.0.0) 55.0.0 newest; resolves against the same arrow 59, from crates.io — no git rev
tonic / prost (gRPC, Flight + spark-connect) 0.14 (→ 0.14.6) / 0.14 0.14.6 / 0.14.4 prost codec split → tonic-prost 0.14
Spark Connect containers (bifröst + pipelines) 4.1.2 GA (4.2.0 GA via override) 4.2.0 (GA) / 4.1.3 blessed pair = 4.1.2 GA + iceberg-spark-runtime-4.1
Apache Iceberg (Java runtime / container) 1.11.0 1.11.0 iceberg-spark-runtime-4.1_2.13:1.11.0
iceberg-rust (via skade: iceberg-arrow59 fork) 0.11.0 (+ catalog-rest 0.11.1) 0.11.x skade's arrow-59 forks of the official Apache Rust impl (pre-1.0; Java is 1.11.0)
spark-connect (Rust client) 0.2.2 (arrow59 fork) 0.2.2 newest published; fork only bumps arrow 56→59

Gap note: the Rust iceberg line (0.11.x, via skade's iceberg-arrow59 forks) is far behind the Java line (1.11.0) — different version series entirely. knut consumes it via skade and reaches Iceberg 1.11.0 table format through the Spark 4.1.2 container path; the pure-Rust skade read/write path uses the iceberg-arrow59 0.11 forks. Spark itself: knut defaults to 4.1.2 GA (the blessed pair with the 4.1 Iceberg runtime, since no iceberg-spark-runtime-4.2 exists yet while 4.2 is GA) and reaches 4.2.0 GA via KNUT_SPARK_IMAGE / the SPARK_TAG build-arg.

Why one Arrow tree (arrow 59 workspace-wide)

The workspace pins one Arrow major (Cargo.toml [workspace.dependencies] arrow 59). It is chosen, not incidental: orc-rust and spark-connect both bind an Arrow major, so a split tree would mean two incompatible RecordBatch types meeting at every seam. With one tree:

spark-connect carries its own private tonic tree behind the grpc/spark feature gates, so the two tonic majors never mix in one link unit — the types never touch.

The road not taken (now history). Before the workspace unified on one Arrow major, skade was on arrow 57 and the rest of knut on 56; they met only through the Arrow C Data Interface — a one-unsafe transmute of the ABI-stable FFI_ArrowArray/FFI_ArrowSchema structs between the two crates' arrow majors, in both directions (the export to_skade_batch out, the ingest from_skade_batches back — then named bridge_56_to_57/bridge_57_to_56). It worked and was ~2× faster than a parquet temp-file bridge, but it was a standing liability (one unsafe, two majors to keep ABI-compatible). Unifying on one Arrow major (58 then, 59 now) deleted the FFI bridge entirely; to_skade_batch/from_skade_batches survive only as named identity-clone seams so the export path and its round-trip test read the same. Some older bench prose still says "arrow-56↔57 bridge" — that is the historical name for what is now a plain clone.

Why this reuse stack (and not a flat set of crates)

The functions form a strict layered stack (graphar at the leaf; flight/bifrost/popviz above falkordb; thund above pipelines). The discipline: nothing below depends on anything above, and the shared spine is a single key.

Why MERGE, keyed on _gar_id, both endpoints

Tables (and file drops) sync in arbitrary order, so an edge can land before the table that owns one of its endpoints. MERGE-ing both endpoints on every edge means the missing endpoint is created as a placeholder and enriched later by the vertex MERGE on the same id key — order stops mattering. The same property makes crash-replay idempotent: re-running a round MERGEs the same rows to the same nodes. This is why the sink is MERGE, not CREATE, and why per-table state is written only after the sink succeeds (state-after-sink) — a crash replays a round that is safe to replay. Full per-function detail: bifrost-reasoning.md.

Why "Redis" means Valkey 9+

The graph store and the shared SyncState ledger run on Valkey 9+, the open-source, Redis-API/RESP-conformant fork, reached via the redis Rust crate. redis:// in a URL is the wire-protocol scheme (which Valkey implements), not a vendor choice — no Redis-lineage server is used or required. The one wrinkle: FalkorDB's module gates on redis-compat ≥ 8.0 but Valkey 9 self-reports redis_version 7.2.x; the containers/falkordb-valkey9 Containerfile lowers the gate to 7.0 at build time so the module loads on Valkey 9.

Why both GUIs share one introspection contract

Both viewers (knut-pipelines, knut-popviz) expose their entire UI state as JSON from a state_json() that lives in the default (no-gui) build, dumped every frame to an env-var-named file. The reason is testing a GUI without a GUI: a headless run can drive the load/stream and assert on the JSON the screen is derived from — no display, no screenshot diff. That is why state_json() must live outside the gui feature and why the law "any new visible control MUST be added to state_json()" is enforced: an un-dumped control is an un-testable one. Mirrors nornir viz's urdr-threads.

Why heavy stacks are opt-in features

The lean default build is pure model + core, and everything expensive is a feature: grpc (tonic + Spark Connect codegen), gui (egui/winit/glow), skade (arrow-58/iceberg tree), native (DataFusion), spark, wasm, flight, metrics-http. The reason is compile time and airgap-ability: a consumer that only wants GraphAr files pays for none of Spark, Iceberg, or a GPU stack, and a CI that only tests the data layer never pulls a JVM. The cost is a longer feature-flag matrix, which the test matrix and the per-function chapters document.

What each function's reasoning covers

The system-wide why is here; each function keeps its own trade-offs and rejected alternatives:

function its reasoning chapter covers
graphar why five interchangeable formats; corpus model-gaps; retry/multipart
falkordb why UNWIND-batched + column-resolved; why not falkordb_bulk_loader
flight why registered-queries-are-tables; one-config-two-enforcement; execute-once cache
bifrost state-after-sink; registry-not-plugin; the iceberg-delta honest limitation
thund the full dataflow-landscape read; all-three-over-one-IR; Spark 4.1-vs-4.2 reality
pipelines one-model-many-frontends; CLI-parity law; one-adapter-per-engine

Open work and locked decisions with dates: plan.md.