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

nordisk/knut

.nornir/graphdb-design.md

knut-graphdb — one graph client, folded from two

Class: design. The trait every consumer reads a graph endpoint through, and what the 2026-09-01 fold of korp's GraphStore into knut's GraphDb changed. Source: crates/knut-graphdb/ — GraphDb, Cypher, FalkorBackend, CozoBackend.

The sin it fixes

There were two FalkorDB clients, each holding half a seam:

knut GraphDb korp GraphStore
list the graphs on an endpoint — graph_names
open one for its catalogue — open
read a scene query (Cypher in the signature) read (no query language)
expand a node neighbours —
shortest path shortest_path —
write rows upsert —
colour the result — scene + Legend

Neither was complete, and because graphar_flight::FalkorExecutor knew only GRAPH.QUERY, korp additionally opened three redis::Clients of its own — for GRAPH.LIST, GRAPH.DELETE and GRAPH.CONFIG GET. A FalkorDB read could therefore be issued from four different files with four different error vocabularies.

They are one trait now, and it lives in knut. korp → knut; knut → korp is zero, guarded by crates/knut-graphdb/tests/no_korp_dependency.rs (18 manifests + 269 sources, 3.6 MB, reported).

The trait

kind · endpoint · graph_names · open · read · neighbours · shortest_path · upsert

Five are required (kind, endpoint, graph_names, open, read); the rest are provided and refuse honestly with GraphError::Unsupported. The smallest complete backend is five functions long and contains no stub that can panic.

A GraphDb is a CLIENT. It takes an endpoint and talks to it. It never starts a process and it never knows containers exist.

Cypher is optional, and it is not on the base trait at all

read(graph, limit) takes a graph name and a row cap. No query language appears anywhere in a base signature. A backend with no query language — a GraphAr file set, a knut projection, an in-memory scene, Cozo — is a complete implementor rather than a degraded one: it has no method to stub, so it has no unimplemented!() to panic from. Cypher is the separate Cypher extension trait plus a CypherSupport advertisement, and no_cypher_here() is the typed refusal a caller holding only a &dyn GraphDb gets.

That claim is checked by the type system, not by a comment. Move cypher onto GraphDb as a required method and CozoBackend stops compiling with E0046.

open() LISTS BEFORE IT PROBES, and that order is load bearing

FalkorDB creates a graph key on the first GRAPH.QUERY against an unknown name. An open that probed first would therefore make the probe itself the reason the answer is "yes": the existence check could never fire, and a typo'd graph name would leave a stray empty graph on the endpoint.

Observed live by korp's graph-trait lane on 2026-08-31 and re-observed on 2026-09-01 while proving the guard red: a probe planted before the check made open("knut_absent_2283864") return Ok(GraphSchema { node_count: 0, .. }) — the blank pane that was really a 404 — and left knut_absent_2283864 in GRAPH.LIST afterwards. It costs one extra round trip per open. Correctness wins.

Empty ≠ unreachable ≠ missing ≠ timed out

Four distinct GraphError variants, never prose:

answer type
reachable, zero graphs Ok(vec![])
unreachable endpoint GraphError::Connect
the graph is not there GraphError::NotFound
past the time bound GraphError::Timeout
the backend has no query language GraphError::NoQueryLanguage

A backend that folds a connect failure into an empty list is wrong on this seam.

Colour is NOT here, and that is deliberate

A scene's colour legend stayed korp-side (korp::graphstore::GraphScene, built from korp::graphnav::label_rgb). Three reasons, in order of weight:

  1. It would import an ontology into a base crate. The hues are pinned to korp's own labels — Person, Vessel, PoliceRecord — which is exactly the dependency GraphNode::natural_key exists to avoid: knut hands back the raw key and korp resolves it. A palette keyed by korp's labels is the same cycle wearing a hat.
  2. A second consumer would inherit the wrong palette. knut's other graph consumers (thund, popviz, knut-flight) have their own label vocabularies.
  3. The legend belongs with the thing that paints it. korp's is built by construction from the labels the 2D facett_graphnav canvas and the 3D facett_graph3d cloud actually paint, and korp's own test asserts the seam's legend equals the hue the 3D path draws. Moved here, that check could not exist.

What this crate owes a renderer is the labels. The renderer owns the colours.

The backends

backend feature reaches Cypher
FalkorBackend falkordb (default) one FalkorDB / Valkey endpoint, optionally over mutual TLS yes
CozoBackend cozo an embedded CozoDB mem store — no container, no redis, no socket no, and it is complete

CozoBackend became genuinely multi-graph in the fold: its two stored relations are keyed by graph name, so it answers graph_names / open / read the way a real endpoint does. korp gained a container-free graph backend it did not have.

One client, provably

graphar_flight::FalkorExecutor is the only file in the constellation that builds a redis::Client for graph work, and it now carries the whole verb set — GRAPH.QUERY, GRAPH.LIST, GRAPH.DELETE, GRAPH.CONFIG GET — precisely so no caller has to open a connection to send one. That is what the second client was.

korp/tests/graphstore_one_falkor_client.rs scans both repos' production sources (233 files, 8.97 MB, reported) and asserts: korp speaks zero FalkorDB, exactly one file serves the graph seam, and the only other speaker is graphar-falkordb's bulk loader — a different protocol (GRAPH.BULK), named so a third cannot appear silently.

What is NOT folded

graphar-falkordb's bulk loader stays its own client. It speaks the binary GRAPH.BULK insert format, not the query protocol, and it is a writer of whole datasets rather than a reader of scenes. Folding it would put two unrelated protocols behind one type.