Changelog

All notable changes to KGLite will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

[0.16.23] - 2026-09-04

Added

  • A blueprint declares its inputs once, in a files: section. Each entry names an input ({"path": "disease.csv", "format": "csv"}) that node specs and junction edges reference with "file": "<name>", so a file read by several specs is written down once instead of at every one of them. "csv": "x.csv" stays valid and is exactly shorthand for a files entry named x.csv, so the two spellings build the same graph and mix freely. The formats this release reads are csv, delimited, xlsx and frame. The build refuses rather than guessing when a spec sets both csv and file, when file names an entry that is not declared, when an entry has no path or an unreadable format (both errors list what is accepted), and when an entry’s name is already a csv shorthand for a different file — the two would claim one input name. A stray key inside an entry is a warning that names the accepted keys for that entry’s format. A compute: op over a non-CSV input is refused at load time: compute reads and rewrites CSV files directly, and would otherwise skip the op and report success.

  • "format": "delimited" — the text tables public data actually ships. A files entry declaring it reads a file whose columns are separated by a delimiter of any length, with quote, header, columns, skip_lines, comment_prefix, line_suffix, encoding and prefix_strip as optional knobs. That covers NCBI’s taxonomy dumps ("delimiter": "\t|\t" with "line_suffix": "\t|", so the trailer never becomes a phantom last column), a CSV under a licence preamble ("skip_lines": 1 or "comment_prefix": "#"), headerless TSVs ("header": false with "columns": [...]), and ids carrying a namespace ("prefix_strip": {"compound": "cpd:"}, applied before typing). A single-character delimiter is read by the same csv reader the csv format uses, so quoting behaves identically; a longer one is read line by line with no quoting, and a quote beside it is refused rather than ignored. A UTF-8 BOM is stripped either way. encoding reads utf-8 (the default) and latin-1; anything else is refused by name. Rows land rectangular exactly as a CSV’s do — short rows null-padded, fields past the header’s width dropped, an empty cell null — and a warning’s row number counts data rows, after the preamble, comments and header are gone.

  • "format": "xlsx" — a worksheet as a blueprint input. A files entry declaring it reads one sheet of an Excel workbook, named by sheet (a sheet name, or its position in the tab order counting from 0; default the first) with header_row naming the physical row the column names are on, counting from 1 (default 1) — so the title block published supplements put above their header is dropped unread. unpivot ({"id_columns": [...], "name_to": "...", "value_to": "..."}) turns a wide result matrix into rows: the id columns stay columns and every other column becomes one row per input row carrying its header and its cell, which is the junction table such a matrix was always describing. An empty cell produces no unpivoted row — a sparse screen matrix must not turn “not measured” into an edge. Excel stores every number as a float, so a whole-number cell is written as an integer (260, not 260.0) below 2^53, which is what keeps source_fk/target_fk joins matching; dates land as 2024-03-01 and gain a time only when the cell has one; booleans as true/false; blank cells, and cells the spreadsheet itself could not compute (#DIV/0!), are null, the latter with one warning per column naming the sheet and the cell. Row numbers count data rows below the header. The reader is behind the xlsx Cargo feature — the Python wheel always has it, and a Rust build without it refuses the format by name and says which feature to add.

  • from_blueprint(..., frames={"name": df}) — an in-memory table as a blueprint input. A files entry declaring {"format": "frame"} takes no path; its rows come from frames["<entry name>"], and are consumed exactly like a read file: same specs, filters, chunked junction loading, dedupe regime and warnings, so one blueprint builds the same graph from a CSV or from a frame of the same data. Columns are coerced to the types the blueprint declares; where it declares none, the frame’s own dtype is kept, so a float column of whole numbers stays a float and costs no inference pass. An empty string is a null, as it is in a CSV. Column types outside the blueprint’s vocabulary (datetime-with-time-of-day, dicts) land as text with one warning per column naming them. Files stream, frames do not — a frame is materialised before the build. polars and pyarrow tables are accepted through .to_pandas(). A declared frame that was not supplied, and a supplied frame that was not declared, each raise a ValueError naming it.

Changed

  • Rust kglite::api::blueprint grew the input surface. build takes a fourth argument, BuildInputs (the frames a caller hands over; pass BuildInputs::default() for a file-only build), and from_blueprint is the whole lifecycle — build, then the resolved save destination — with BuildReport::render_text rendering the progress summary the Python wrapper prints. Blueprint.files / FileSpec, NodeSpec.file, JunctionEdge.file (its csv is now Option) and input_name() mirror the files: section. The C ABI’s kglite_blueprint_build is unchanged. cargo semver-checks against 0.16.22: build takes 4 parameters instead of 3, and five struct-literal-constructible types gained a public field (Blueprint.files, NodeSpec.file, JunctionEdge.file, FlatSpec.input, BuildReport.edges_actual) — a Rust embedder constructing those by literal adds the field; a ..Default::default() tail already covers Blueprint, NodeSpec and FileSpec.

  • Blueprint build warnings are Python UserWarnings, as the documentation has said since 0.9.1. They were written straight to stderr, so nothing a caller could set up — warnings.simplefilter, warnings.catch_warnings, the logging.captureWarnings recipe the docstring prints — had any effect on them. Each entry in the build report is now its own warning, whatever verbose is; the “N blueprint warning(s) — pass verbose=True for details” summary line is gone, because a count is not something a filter or a handler can act on. Build errors still print to stderr unchanged.

Fixed

  • Vector search no longer loses whole regions of a multi-core HNSW build. The parallel index build could leave one-way edges — a neighbour pointing at a vector that pointed nowhere back — stranding up to 88 of 600 vectors where no query could reach them, so an approximate search silently returned a distant block instead of the nearest neighbours. Two causes: a vector overwrote its own neighbour list, discarding reciprocal edges other threads had just added, and the build handed each worker a contiguous block of slots, seeding several mutually invisible regions of the graph at once. Recall no longer depends on the machine’s core count. Indexes already on disk are unaffected in format but were built by the old path — rebuild one (build_index) to get the repaired topology.

[0.16.22] - 2026-09-03

Added

  • One junction relationship over a union of target types. A blueprint junction edge’s target (and a from_records connection spec’s target_type) now takes a list of node types as well as a single one, with an optional target_type_column naming the column/field that holds each row’s target type. Without that column the declared types are probed for the row’s target id and the first that has it wins; an id none has takes the first declared type, where the existing missing-endpoint handling vivifies its stub. A relationship whose range is an abstract class no longer needs one relationship name per concrete type, so it can be declared and audited as one ontology rule. Junction edges only: an fk_edges entry still points at one target type.

  • A per-field evidence census in the ontology audit. CALL ontology_audit({by: 'property'}) fans the required_properties and property_types rules into one row per declared property — violations the edges failing it, total the relationship’s edges, pct the share lacking it — including properties nothing fails, so a complete field is visible as such. Every other rule keeps its aggregate row with a Null property. Note the two breakdowns differ in kind: domain_class partitions a rule (its rows sum back to violations), while property is a census — an edge missing three declared properties counts under all three, so its rows sum to at least the aggregate, never back to it.

  • edge_property_violation() now yields properties, the full list of declared properties each flagged edge fails. UNWIND it for a per-field tally.

  • Properties on a blueprint FK edge. An fk_edges entry now reads properties, property_types and rename, the same three keys a junction edge reads: the named columns of the source node’s own CSV row are attached to the edge the row produces. Only rows that produced an edge contribute — a row whose FK cell was null drops with its properties rather than shifting them onto the next edge. Declaring a column here never keeps it off the node; skipped is still what does that. A declared column the CSV does not have is reported and the edge is built without it.

  • labels on a blueprint node spec and a from_records node spec. A list of secondary labels stamped on every node of the type, so a query over a union of types names one label instead of each type. Stamped after the node and edge phases, so a provisional stub vivified for an endpoint no record or CSV supplied carries them too — a blueprint owns every node of the types it declares. The type’s own name in the list is a no-op, not a duplicate.

  • Unknown keys in a blueprint spec are now reported. A key the loader does not read — at the top level, in settings, on a node or sub_nodes entry, or on an fk_edges / junction_edges entry — was dropped by the parser and the build reported success on a graph the author had not described; a misspelled "lables" cost every label it carried and said nothing. Each one now becomes a build-report warning with a near-miss suggestion. It stays a warning, not an error, because blueprints in the wild carry stray keys and must keep building. (from_records, whose key sets have always been closed, still raises.)

  • "list" / "array" property type for blueprint columns. A CSV column whose cells are JSON arrays (["a","b"]) now loads as a list, in a node spec’s properties and in a junction edge’s property_types, so WHERE 'x' IN n.synonyms works without re-parsing a string per row. Cells must be JSON arrays; there is deliberately no delimiter option. A non-array cell is kept whole as a one-element list — right for a lone value, wrong for a|b — so the build report now warns per column when such a cell contains |, ; or ,, naming the count and the first offending row and cell. CSV export still writes a list as its JSON text and declares it "string" in the generated blueprint; declare "list" in the re-import blueprint to get a list back.

Changed

  • Rust API: JunctionEdge::target is now Vec<String> (it was String) and the struct carries a target_type_column field. JSON blueprints are unaffected — a plain "target": "Disease" string still parses, into a one-element list.

  • ontology_audit() gained a property column and edge_property_violation() a properties column. Both are additions to the yielded column set: a bare CALL returns one more column than before, and a query pinning the exact column set needs updating. Existing YIELD lists, row counts and every other column are unchanged — property on edge_property_violation() still names one property (now defined as the first of properties), and its one-row-per-flagged-edge identity with the scorecard’s violations + exempted still holds.

  • Rust API: the blueprint spec structs gain fields, so a struct literal that constructs one exhaustively no longer compiles. Blueprint, Settings, NodeSpec, FkEdge and JunctionEdge each carry an extra map holding the keys the parser does not read (the source of the new unknown-key warnings), NodeSpec carries labels, and FkEdge carries properties, property_types and rename. Add ..Default::default() where the struct has it, or use FkEdge::plain(target, fk). Deserialization is unaffected.

Fixed

  • from_records no longer discards a node spec’s labels when its records list is empty. A type whose nodes all arrive as vivified edge endpoints declares no records of its own; that spec returned before its labels reached the stamping pass, so the stubs carried only their type name and MATCH (n:Text) found nothing — no warning, no error. The declaration now stands whether or not the spec supplies rows, matching the blueprint builder, which keeps every spec through its own label phase.

  • A declared MCP skills pack that does not exist now fails the boot. One bad path in a manifest’s skills: list failed the whole registry build, and the server booted anyway with every skill gone — the bundled methodology included — while the graph tools answered normally and --selftest printed PASSED. The boot error names what was written and where it resolved to. A content fault in a file that does exist (bad frontmatter, oversized body) stays a warning. --selftest also prints the number of skills the session serves, so an opted-in deployment that resolved nothing is visible; zero is reported, not failed, because skills: true with files added later is a legitimate configuration.

  • Ranked retrieval on a property with no index answered zero rows instead of raising. RETURN text_bm25(n, 'p', $q) has always refused a property with no text index, naming build_text_index; the documented fast path WHERE text_bm25(n, 'p', $q) > 0 ... ORDER BY text_bm25(...) DESC LIMIT k came back empty and silent, and RETURN count(n) over the same predicate came back 0. Both shapes are served by a fused scan whose WHERE filter drops any row whose predicate will not evaluate — right for a row-level failure, wrong for a missing index, which is wrong for every row and can never become right. vector_score() on an unknown embedding store shared the defect and is fixed with it. (text_score() did not: its embedder check runs before any row is scanned.)

  • from_records() ignored an on_missing_endpoint written in the spec. It is a documented top-level spec key, but the Python entry point inserted its own argument default over it unconditionally, so a spec asking for "drop" or "error" silently vivified stubs instead. The argument now defaults to None and is applied only when passed — where it still overrides the spec — and the spec’s own key is honoured otherwise. A caller passing the argument, or neither spelling, sees no change.

  • Relationship alternation [:A|B] was rejected inside EXISTS { }, count { } and size(...). MATCH (n)-[:A|B]->() has always parsed, but the same pattern in a subquery failed with “Unexpected token in EXISTS pattern: |” — the subquery’s pattern re-serializer had no | case, so the token never reached the pattern parser that understands alternation. Both spellings (EXISTS { pattern } and EXISTS { MATCH pattern }) now return what the equivalent MATCH returns.

  • A CSV-less blueprint node type with a numeric foreign key ended up with two nodes per value. The synthesised type’s id column was always typed as text, while the FK-edge frame types the same values numerically, so the edge matched nothing, vivified a provisional stub, and left a "10" node beside a 10 one — with every edge pointing at the stub. MATCH (c:City) RETURN count(c) returned double, and joins through the real node returned nothing. The synthesised id column now uses exactly the rule the edge frame uses. Text keys were never affected.

  • from_blueprint() lost parallel edges past the first chunk of a streamed edge CSV. The junction-edge loader streams its CSV in chunks (KGLITE_BLUEPRINT_JUNCTION_CHUNK_SIZE, default 100k rows) and the streamed FK-edge loader chunks its node CSV, but each chunk decided on its own whether the connection type was new. The first chunk registered the type, so every later chunk switched to merge-by-endpoints and folded its rows onto the edges the first chunk had created — silently dropping rows and overwriting the surviving edges’ properties with later rows’ values. A 117,160-row junction CSV loaded 109,065 edges; raising the chunk size restored all 117,160. The regime is now decided once per CSV and held for every chunk, so the chunk size bounds peak RAM without changing the graph. Unchanged: a second load into an already-loaded connection type (a second spec or CSV feeding the same edge type) still merges by endpoints, as before.

[0.16.21] - 2026-09-02

Changed

  • A fired query deadline now raises CypherTimeoutError, not CypherExecutionError. KgError::CypherTimeout was mapped by every binding and constructed by none: except kglite.CypherTimeoutError around a slow query caught nothing, and Bolt’s TransactionTimedOut, the C CypherTimeout status and HTTP 408 were unreachable. Both are siblings under CypherError, so except kglite.CypherError is unaffected; callers matching CypherExecutionError specifically for a timeout must add CypherTimeoutError.

  • KgError::CypherTimeout gains a message field carrying the abort site’s hint, and its elapsed_ms/limit_ms are now real measurements (rendered only when measurable). Rust callers constructing or destructuring the variant exhaustively need updating.

  • save_graph(force=true) now requires the write opt-in. force re-encodes the served .kgl and moves its identity, so every peer serving the same graph pays a full re-read — it is offered only where mutations are (--writable / extensions.writable: true) and refused on a server that registers save_graph alone. A plain save_graph still publishes unsaved changes and unpersisted boot configuration from such a server, which is why builtins.save_graph: true exists on its own; save_graph_as is unaffected. The tool description and the “Nothing to save” no-op stop offering force where it would be refused.

  • A save_graph that wrote only boot configuration now says so — wrote manifest ontology (N classes, M managed labels); no data changes — instead of reporting a node count nothing moved.

  • The writer lease’s <path>.lock-owner record writes since= and released= in second-precision UTC (Z), the same form the MCP <active_graph> footer prints. Older records carry a local offset and still parse; no migration.

  • from_records() now refuses a spec key it does not read instead of ignoring it. The accepted sets are closed — nodes, connections, on_missing_endpoint at the top level, type/id_field/title_field/ conflict_handling/records per node spec, and type/source_type/ source_id_field/target_type/target_id_field/records per connection spec — and an unknown key raises with a “did you mean” suggestion where one is close, matching the ontology and schema parsers. A spec written with "relationships" previously built zero relationships and reported success.

  • Cypher guide: size max_work_units from a count(*) probe with headroom — it is a hard refusal, not a soft cap — and note that a budget set alongside a deadline is usually the guard that fires. Same guidance in the max_work_units docstrings.

  • Cypher guide: new “Bulk ingest” section documenting UNWIND $rows AS row CREATE (...) as the batched-write route, with the per-statement costs it avoids.

Added

  • ServerExtensions::read_only() pins an embedded MCP server read-only: --writable and extensions.writable: true both stay inert for the life of the process, and boot logs one warning naming the spelling it overrode. For a binary that owns argv but not the manifest and must guarantee an immutable served graph.

  • kglite::api::introspection::TypeCapabilities — the per-node-type capability flags (ts / loc / geo / vec) that describe() renders — is now public, as an opaque type read through has_timeseries() / has_location() / has_geometry() / has_embeddings() / flags_csv(), together with compute_type_capabilities and compute_type_capabilities_for. Consumers drawing their own schema view no longer re-derive the flags from the timeseries/spatial/embedding configs.

Fixed

  • A timed-out write reported “Query interrupted” instead of “Query timed out”: the mutation engine ran its own poller that collapsed deadline and cancel into one message. Both engines now share one check, so a mutation timeout is a timeout and a cancelled mutation is a cancellation.

  • A refused save_graph / save_graph_as no longer tells a clean server that “your unsaved changes are still here and still queryable” — a save is refusable with nothing unsaved (a force re-encode, a configuration publish), and the clean case now says “Nothing was changed here”.

[0.16.20] - 2026-09-02

Changed

  • A released writer lease appends released=<time> to its .lock-owner record. The sidecar beside a graph names whichever process holds the write lease (pid=, since=, and an optional label=); until now it said nothing when that process gave the lease back, so a human opening the file after a clean exit read a live-looking “held since” for a lease nobody was holding. The record now carries the release moment as a fourth line, which also makes the crash case legible by contrast: a process killed mid-hold never writes one, so a record with no released= is either a live holder or a crash. What the record has never been is the liveness check — that was and remains the OS lock on <path>.lock, which the kernel releases whether or not the holder got to write anything, and which is what a refusal is decided by. Deleting the sidecar still releases nothing and only discards the name.

  • The MCP identity footer and <active_graph> header report load N and file saved <T> instead of generation N. The renamed counter is the same number it always was — how many graphs this server process has installed since boot — but calling it a generation invited two readings it cannot support: two servers on one file report different values for the same bytes, a server’s own save does not move its own, and the word already means the disk mode’s on-disk generations/ directories, which every process does see. load is now stated as server-local (Load N on this server. from reload_graph), and the identity servers can compare is the new file saved field — the served path’s publish time, taken off the filesystem, present on the footer, the header (file_saved="…") and the activation summary. It is omitted where there is no publish moment to report: a workspace graph, and a legacy flat disk directory rewritten in place. Tooling parsing · generation from a footer must switch to · load , and anything reading the generation="…" header attribute to load="…"; there is no compatibility spelling. kglite::api::io::GraphFileIdentity gains the modified() accessor this is rendered from.

  • save_graph on a clean MCP server no longer rewrites the file. A save with nothing unsaved to write used to publish anyway, moving the .kgl’s size, mtime and inode — and every peer server bound to the same file then paid a full re-read on its next call to serve a graph whose bytes had not changed (half a second at 133 MB, once per peer, on every such save). It now returns Nothing to save: <path> and leaves the file alone. The one thing a clean save legitimately carried still gets through: a server that applied a manifest ontology at boot (extensions.ontology, declared or materialized) holds configuration the version counter cannot see, and its first save writes that to disk exactly as before — the second one is the no-op. For the remaining case, rewriting a clean file on purpose to re-encode it with the running library version, save_graph takes a new force=true argument.

  • The optional fastembed embedder backend moved from fastembed 5 to fastembed 6. Nothing changes for a KGLite user: the same model names resolve to the same weights, and the backend is still opt-in behind the fastembed Cargo feature (off in the Python wheel). fastembed 6’s one breaking change is upstream error handling — a typed fastembed::Error replaces its anyhow error — which reaches KGLite only as slightly different wording inside an embedder failure message. Building the workspace with that feature now requires flate2 >= 1.0.30, indexmap >= 2.6.0 and rustls >= 0.23.22, because fastembed 6 reaches ureq 3 through hf-hub 0.5 and cargo unifies those crates across the tree.

Added

  • extensions.writable: true enables MCP writes from the manifest, the same statement --writable makes on the command line: either surface alone opens cypher_query to mutations and registers save_graph plus the lifecycle tools, so a wrapper that owns the manifest but not the argv of the server it spawns can serve a write-enabled graph. builtins.save_graph: true alone registers only save_graph — it lets the server persist what it loaded (a boot-time ontology materialization, say) and leaves cypher_query read-only. That has always been the behaviour; the operator docs described it as write-enabling, and the refusal an agent got named neither spelling. A read-only server’s mutation refusal now names both --writable and extensions.writable: true, and any extensions: key this server does not read is reported at boot with the known set beside it, so a misspelled key is visible instead of silently leaving the server read-only.

Fixed

  • Every read on a write-enabled MCP server now carries the active graph: identity footer. The write-enabled cypher_query recognised a read and ran it, but returned the rendered rows without the footer the read-only server appends — so on a --writable server an agent got 4 row(s): or a bare No results. with nothing naming the graph that answered, which is the one line that tells it the active root is not the one it meant. Reads now delegate to the read path itself, so the answer is byte-identical whichever way the operator started the server.

[0.16.19] - 2026-09-01

Changed

  • The MCP server takes the served .kgl’s writer lease at its first unsaved change, not at boot. A write-enabled server (--writable, or builtins.save_graph: true) used to lock the file for its whole lifetime, so the first of several MCP clients booted from one manifest took the graph and every later one died at spawn with “Server disconnected” — the operator’s only way out was to run them all read-only. Such a server now opens lease-free, acquires the lease on the first mutating cypher_query (a 250 ms bounded wait, then a refusal), and hands it back at save_graph, save_graph_as, or reload_graph(discard_unsaved=true). Several write-enabled servers can therefore serve one graph and arbitrate per write instead of per process. This covers disk-graph directories too: a directory carrying a CURRENT pointer is republished atomically — the save stages a new generation and swings the pointer, never rewriting the generation another reader has mapped, and no generation is ever deleted — so it is served lease-free and locked only for its dirty window. At the engine level the directory’s own .kglite.lock is likewise released at each publish and re-taken by the next mutation, instead of being held until the process exits. Two targets still lock from the open, because waiting is not safe for them: a path this server creates, and a legacy flat directory (a pre-generations disk graph with CSR files at the root and no CURRENT), whose files a rebuild rewrites in place under live mappings — that one keeps the pre-generations behaviour unchanged, including its pinned lease.

  • A --graph server serving a regular .kgl re-reads it automatically. Every tool call stats the served file and re-reads it — single-flight, off-lock, through the normal open path — when its identity differs from the one the graph was loaded or last saved from, so a clean server never answers from, and never writes onto, a snapshot older than the file was at the time of the call. A server holding unsaved changes never auto-reloads: it attaches a divergence warning and leaves the choice to save_graph_as / reload_graph(discard_unsaved=true). A server never re-reads the file it just saved. A failed re-read keeps the loaded graph serving and is retried only when the file’s identity changes again and at least 5 s have passed, so a file that stays broken is never retried automatically; reload_graph always tries. A disk-graph directory carrying a CURRENT pointer is refreshed the same way — the pointer is the graph’s identity, so a peer’s published generation arrives on the next tool call, at the cost of one open and read per call instead of a bare stat. Only a legacy flat directory (no CURRENT) is left with reload_graph as its refresh path. Freshness follows the file a publish writes rather than only the one the open read, so a graph this server created is refreshed from its first save_graph onwards, and save_graph_as moves the refresh target to the new path — left on the old one, a peer rewriting that file would have replaced the agent’s just-saved graph on the next call. Operators: each peer save now costs every other server one full re-read on its next tool call (seconds on a ~100 MB graph, with concurrent calls waiting behind it) — the price of the freshness guarantee, and a reason to keep served graphs on local storage.

  • extensions.graph_watch is retired. The refresh it opted into is now unconditional. The key is still parsed — a non-boolean value still fails boot — but any boolean only logs a retirement warning at boot and arms nothing.

  • save_graph refuses to overwrite a file that changed on disk since this server loaded or last saved it, instead of silently discarding whatever the other writer published. There is no merge: save_graph_as to another path keeps the unsaved work, reload_graph(discard_unsaved=true) drops it. reload_graph likewise refuses to discard unsaved changes without that flag, and load_graph / create_graph refuse outright while the server is dirty.

  • save_graph_as releases the source file’s lease when it writes to a different path — the graph is not going back there, and leaving the original locked would keep the jam that call exists to escape. To the bound path it behaves as save_graph, lost-update check included.

  • Refused writes and saves name the holder, not just a pid: "Claude Desktop" (pid 4711, since …), and they state that nothing was changed, that the graph is still readable, and which call gets the agent unstuck.

  • The cypher_query footer, the <active_graph> header and the activation summary carry the graph generation and write stateclean, or unsaved changes lease held since <T> — so a lease parked by a write that died mid-call is visible on every response instead of only to the next writer. The write acknowledgement gains the same footer the read path already had.

  • A .kgl written by a newer kglite is reported as “restart this server” rather than as a retryable reload failure: no re-read of that file can succeed until the binary is newer.

  • CLI: every save path goes through the shared ownership helper. --save, --save-on-exit, migrate and the shell’s .save share one lease-and-identity implementation, so all of them refuse a lost update the same way. .save on a file another process is writing now fails after 250 ms with a refusal naming the holder, instead of blocking the shell for up to 30 s.

Added

  • --lease-label / KGLITE_LEASE_LABEL (MCP server). The name this server publishes while it holds the graph’s writer lease, so a peer refused a write is told which client is mid-write. Defaults to the parent process’s name — usually the MCP client that spawned the server, which is how four clients sharing one manifest still name themselves apart.

  • reload_graph(discard_unsaved=true). The one spelling for dropping unsaved changes: it restores the pre-write snapshot, releases the lease, and re-reads the file. Without the flag a dirty reload_graph is refused.

  • kglite::api::io::WriteOwnership (with BeginWrite, Discarded, WriteRefusal and LAZY_LEASE_ACQUIRE_TIMEOUT) — the read-modify-publish state machine every path-backed binding otherwise reimplements over GraphWriterLease + GraphFileIdentity: take the lease no earlier than the first unsaved change, refuse rather than overwrite a file somebody else replaced, and roll back to a publishable state when a write fails.

  • GraphWriterLease::acquire_labeled and LeaseHolder.label. The <path>.lock-owner record gains a third label= line after pid= / since= (additive: an older reader ignores it), and refusals render it. acquire / acquire_ex and the C ABI are unchanged. Rust embedders that build LeaseHolder with a struct literal must add the new field (make semver-check: constructible_struct_adds_field, the one major-class finding of this release; shipped as a patch per this project’s policy).

  • kglite::api::make_dir_graph_mut_preserving_lineage — mutable access without the version bump, for writes that are configuration rather than data (installing a declared ontology, materializing its labels at boot), so a freshly opened graph is not reported as carrying unsaved changes.

Fixed

  • A disk-graph directory’s identity is its CURRENT pointer, not its root directory. GraphFileIdentity folded the root directory’s own size and mtime into a disk graph’s identity, and a writer’s first mutation mints .working-<pid>-*/ and .kglite.lock inside that root — so a server writing a disk directory changed its own identity and its save_graph was then refused as a lost update, while a clean server re-read the whole graph whenever a peer merely started a mutation. The identity is now shaped by the path: a regular file is its metadata, a generation directory is its CURRENT pointer (metadata plus bytes, replaced by every publish), a legacy flat directory is the directory’s own inode, and a missing path is its own value.

[0.16.18] - 2026-08-31

Fixed

  • extensions.csv_http_server binds an OS-assigned port again. The Rust MCP server hard-coded 8765 as the default, so a machine running several MCP clients off one manifest (Claude Desktop, Claude Code, Codex) served only the first to boot — every later process died at bind before answering initialize. Omitting port: (or setting port: 0) now binds 127.0.0.1:0, and the kernel-assigned port is what the boot summary and every FORMAT CSV URL report. This restores the behaviour documented since 0.9.29 and lost in the Python-to-Rust rewrite. An explicit non-zero port: is unchanged.

  • A CSV listener that cannot start no longer kills the server. A failed bind or an uncreatable dir: logs a warning and disables the extension; the server finishes booting and registers every graph tool. A malformed csv_http_server: value is still a fatal manifest error.

  • A manifest source_root: / source_roots: that does not resolve no longer aborts boot. The server logs a warning, reports source tools: unavailable (…) in its boot summary and in the agent’s instructions, and serves every graph tool; read_source / grep / list_source report no active source root until the path is fixed. In --graph mode a failed declaration is not replaced by the .kgl’s parent directory — the operator named a root, and substituting another is the silent-wrong-root failure the explicit-YAML-wins rule exists to prevent. Source roots are resolved per entry (mcp-methods 0.4.7): the declared roots that exist are served and only the missing ones are dropped, each named in the boot summary, in the agent’s instructions, and — when no root resolved at all — in the source tools’ own reply.

  • A bundled: repo_management override no longer fails boot in modes where the framework does not register the tool. mcp-methods 0.4.7 registers repo_management only for kind: github workspaces, so a manifest shared across modes may customise a tool a given boot never had; the override is now ignored with a warning instead of an “unknown route” boot failure. Overrides naming genuinely unknown routes still fail at boot.

Changed

  • The boot summary line carries the peripheral state: csv_http: http://127.0.0.1:<port> or csv_http: disabled (<reason>), plus source tools: unavailable (…) when no declared root resolved, or source tools: N root(s) serving, unresolved: when only some did.

  • FORMAT CSV on a server whose csv_http_server was configured but failed to bind returns the inline CSV plus a notice naming the bind failure, instead of the “ask the operator to enable extensions.csv_http_server” line meant for a server where the extension was never configured.

  • mcp-methods pin bumped to 0.4.7 (was 0.4.6). Picks up the per-root lenient source-root resolver and with_unresolved_source_roots (both used above), the framework-side boot-degradation audit, and the github-only repo_management registration. The bump alone reworded two rustdoc links in set_root_dir’s schema text (interface-contract baseline refreshed; no tool added, removed, or renamed).

  • kglite-mcp-server --selftest reports a declared source root as its own check — green when it resolves, a non-failing yellow line naming the missing path when it does not — and a handshake that gets no response now quotes the child’s last stderr lines as the cause instead of only the symptom.

[0.16.17] - 2026-08-31

Changed

  • Smaller dependency tree for Rust consumers: geo is now pulled with default-features = false. geo’s default earcut/spade features (triangulation/Voronoi — five transitive packages, including a second hashbrown build) gated APIs no kglite code path calls; its multithreading feature is kept. No kglite API or Cypher behavior changes — spatial functions (geom_union, intersects, distance, …) are unaffected. If your crate relied on kglite to activate geo’s default features for your own direct geo dependency, declare those features yourself.

  • Documented how to shrink kglite’s debug-build footprint (a dev-profile rlib is ~336 MB, two-thirds DWARF): the crate README and the Rust embedding guide now carry a measured [profile.dev.package."*"] debug = "line-tables-only" recipe (rlib −43%, dependent debug tree −22%) with its workspace-member gotcha.

[0.16.16] - 2026-08-31

Removed

  • QueryDiagnostics::timed_out (Rust) / the timed_out key in ResultView.diagnostics (Python). Naming it precisely, since a caller reading it needs to know which key went: the removed field is timed_out, and every other diagnostics key — elapsed_ms, timeout_ms, row_limit, total_rows, warnings — is unchanged. The field had no writer anywhere in the engine: it read false on every result ever returned, while its doc comment promised “the result rows are the partial set materialised before cancellation” — semantics that do not exist, because a fired deadline returns Err("Query timed out…") / raises CypherTimeoutError and never yields rows. A caller branching on it was branching on a constant. Callers that want the deadline that was in force read timeout_ms, which is real and stays.

Added

  • kglite::api::fluent::FilterCondition. make_traversal’s filter_target / filter_connection and filter_nodesconditions all take HashMap<String, FilterCondition>, but the facade never re-exported the enum — so a downstream reaching the engine through kglite::api::* only (the sealed path the boundary principle asks for) could pass those parameters only as None or an empty map.

  • GraphML export emits a label key. Gephi, yEd and Cytoscape all read attr.name="label" as an element’s display name; kglite wrote the readable name under its own title key, which no reader looks at, so an import rendered synthetic n0/n1 ids. Nodes now carry node_label (the node title) and edges edge_label (the connection type), both declared as attr.name="label". The existing id / title / type / connection_type / properties keys are unchanged — a reader already consuming them keeps working. GEXF already did this correctly.

Changed

  • The three publish workflows are one release.yml. publish_crates.yml, build_wheels.yml and build_cli_wheels.yml each read the version out of Cargo.toml, each decided independently whether that version was already published, and each polled ci.yml with its own copy of the same wait loop — three copies free to drift, and the grep | cut empty-version hazard had to be fixed in three places. There is now one version-check job, one ci-gate, and every publish leg consumes them. Operators: a push to main now shows two runs (CI, Release) instead of four, and scripts/wait_for_release_ci.py expects those two.

  • The tag and GitHub Release are a terminal job gated on the whole artifact set. They used to live inside the PyPI-wheels publish job, which is how 0.15.3 published five crates, failed its wheel leg, and left no v0.15.3 tag and no GitHub Release for two days while every registry query answered “0.15.3”. tag-release now needs: all three publish legs and pairs each with its own publish decision, so a leg that skipped because a build failed can no longer be mistaken for a leg that had nothing to ship.

  • PyPI Trusted Publishing moved to release.yml. Trusted Publishing binds to the workflow filename, so the kglite and kglite-cli projects each needed a release.yml publisher added before this landed. The old build_wheels.yml / build_cli_wheels.yml publishers are now inert and can be removed from both PyPI projects once the first release.yml run has published successfully — not before.

Fixed

  • describe() type badges now advertise loc and geo independently. A type that declares both lat/lon columns and a WKT geometry field carries both facts, but geo suppressed loc, so the badge read geometry-only. Reported downstream on a dataset where 37 of 38 types declare both: reading the badge, they were about to parse WKT polygons to recover coordinates that were sitting in plain float columns next door.

  • id(n) is documented as reading the node’s id field. In Neo4j and most other Cypher implementations id(n) returns an engine-assigned integer unrelated to your data; in kglite it returns the source data’s own key — the same value as n.id. CYPHER.md’s function table, its identity section, and describe()’s Cypher-function group now say so, along with the two consequences that bite: it is only as stable across a rebuild as the source key is, and it is not a positional index (treating it as a row offset answers the wrong rows, silently, because the wrong rows are still real nodes).

  • Ctrl-C now interrupts a variable-length path expansion. The three poll sites inside -[*n..m]- matching (matcher_var_length.rs) checked the deadline and nothing else, so a cooperative cancellation — the Python wheel’s Ctrl-C, or any binding’s cancel flag — was invisible to them and was noticed only once the expansion finished or hit the 10,000,000-row safety ceiling. Every other poll in the engine, including the one three functions away in the same file, uses the combined interrupt_reason() check; these did not. Measured on a fan-out fixture: a flag raised 100 ms in was observed after 3.93 s (the expansion running to its ceiling) and is now observed after 112 ms. The deadline path was never affected, and the fix adds one relaxed atomic load to a poll that already fires once per 512 frontier pops.

  • A query deadline is now observed inside the MATCH row loops, not only inside the pattern matcher. match_execution.rs polled neither the deadline nor the cancel flag: the matcher stopped on time, and then every row loop downstream of it — the match-to-row conversion (with its fused WHERE), the comma-pattern join, the subsequent-MATCH driving join, the path-binding propagation — ran to completion no matter how long ago the deadline had passed. The loops charged max_work_units per row the whole way, so the work was counted but never checked against the clock.

    Reported downstream (kglite-visual): a 3-hop path query with 1.9M intermediate rows ran past a 30 s deadline for over 120 s, reached 7.29 GB RSS, and OOM-killed the serving process on an earlier identical run. Reproduced here at 600k rows, where a deadline set a quarter of the way into the query was detected only after 94% of it had run.

    Each loop now polls at the executor’s existing INTERRUPT_POLL_INTERVAL stride, on a counter that advances per row examined rather than per row retained — so a clause whose WHERE rejects everything is bounded too. The timeout contract is unchanged (Err("Query timed out…"), no partial results), and the poll is not measurable: 34 tracked benchmarks over two runs, worst cell +8.7% and its own sibling −4.1%/+7.1% across the same two runs, with the directly affected row-loop cells flat (return_node_rel_node_100 +0.1%, return_id_10k +0.5%, consecutive_match_id_anchor +1.0%).

[0.16.15] - 2026-08-29

Added

  • LoadOptions: storage and defer_index_rebuild on the load itself. kglite.load(path, *, storage=None, defer_index_rebuild=None) — and the same two keywords on kglite.from_bytes and kglite.open_session — plus kglite::api::io::{load_file_with, load_kgl_bytes_with, LoadOptions} on the Rust surface. load_file / load_kgl_bytes are unchanged and are exactly the default options.

    storage overrides the mode the checkpoint recorded, resolved below the decode: an unserveable request is refused after the metadata read and before a single section is decompressed. "disk" is refused structurally (a .kgl is a file; a disk graph is a directory), and so is a portable request on a disk-graph directory. It is not a memory lever — for a loaded .kgl, mapped and memory measure within 0.3 MB of each other on every fixture, because columns of 256 KB or more spill and are mmap’d on both paths. What it decides is the backend the graph continues in.

  • defer_index_rebuild: load a .kgl without rebuilding its declared indexes. A load normally rebuilds every property, composite, range and unique index the file declares, which on an index-bearing graph is the single largest term in what the loaded graph costs to hold — measured at −64.3 MB physical footprint (−42.8%) and −194 ms load (−55%) on a 500k-row fixture with four index structures. Pass defer_index_rebuild=True and the load records the declarations instead of building them.

    Answers are identical either way. A deferred graph presents to every query decision exactly as a graph that declares no index: a lookup misses and falls back to a scan, which is a supported, exercised path. The build happens before it could matter — the first write, the first index/constraint DDL, or an explicit materialization — so a UNIQUE constraint still rejects a duplicate and an incremental index update is still filed into a complete index.

    Introspection lists them, marked. SHOW INDEXES, CALL db.indexes(), list_indexes(), list_composite_indexes() and describe() show a deferred load’s declarations with state = "DEFERRED" (ONLINE otherwise; the two Python listings gained a state key), and SHOW CONSTRAINTS lists the declared constraints unmarked, since enforcement is materialized before any write and the two loads are observably identical there. The predicates are strictly separate and unchanged: has_index(), has_any_index(), has_composite_index() and has_unique_constraint() answer from the built stores alone and report False while deferred — nothing may report an index as present while its buckets are empty, which would turn an indexed lookup into an empty result.

    The costs, stated plainly. An indexed equality lookup on a graph that stays read-only runs as a scan (12 ms → 20 ms on 500k rows), and the first write pays the whole build in one step (+193 ms), after which writes are unchanged. Best for a consumer that loads a large graph to scan, export or serve it read-only; not for one that leans on indexed lookups.

    Off by default. KGLITE_DEFER_INDEX_REBUILD sets the process default for callers that pass no options (the CLI, an existing binding); an explicit defer_index_rebuild= outranks it in both directions, and an unrecognised value warns on stderr and loads eagerly rather than guessing.

  • estimate_load_memory + max_load_mb: know what a .kgl will cost before paying it, and refuse to pay above a ceiling. kglite.estimate_load_memory(path) returns a dict of named terms (kglite::api::io::estimate_load_memory / estimate_load_memory_bytes and LoadMemoryEstimate on the Rust surface), read from the metadata block at the head of the file — 0.01%–0.35% of it on the measured corpora, with no section decompressed.

    It reports terms rather than one number because they differ in accuracy and in remedy. index_rebuild_bytes is modelled from the file’s own index declarations and row counts, and is exactly the term defer_index_rebuild removes; section_heap_bytes and transient_peak_bytes are calibrated heuristics with a published error band. The numbers estimate physical footprint — the metric an OS memory killer judges; RSS overstates it by up to 3.6× here and swings 2.3× with the allocator — and cover the load-settled plateau, not the further ~30% a first point lookup adds by building per-type id indexes. Calibrated against the 0.16.14 load-memory measurements: the section term read 0.56×–1.30× of measured settled footprint across three corpora and the modelled index term landed inside the 64–79 MB band measured for a 500k-row four-index fixture.

    The ceiling. kglite.load(path, max_load_mb=N) — and the same keyword on kglite.from_bytes and kglite.open_session, LoadOptions::max_load_bytes in Rust (bytes), and KGLITE_MAX_LOAD_MB as a process-wide default an explicit argument outranks — refuses the load when that estimate’s peak is over the ceiling. The check runs on the metadata side of the decode, so a refused load costs one short read; it charges only for what the load will actually spend, so turning on defer_index_rebuild genuinely buys the headroom the refusal recommends. Note the unit: the keyword and the environment variable are megabytes, the Rust field is bytes.

    The refusal is its own error class, not a corrupt-file one. kglite.LoadMemoryLimitError (code LoadMemoryLimit, HTTP 507, Neo.TransientError.General.OutOfMemoryError, C ABI KGLITE_STATUS_CODE_LOAD_MEMORY_LIMIT = 21, io::ErrorKind::OutOfMemory in Rust, and category: "load_memory_limit" on an MCP recipe-query failure). The file is valid and nothing was decompressed — reporting it as FileFormatError would send an operator to rebuild a graph that is not broken. The message names the estimate, the ceiling, the terms and the two ways out. An unparseable KGLITE_MAX_LOAD_MB warns loudly on stderr and is treated as unset: silently dropping a safety ceiling is the failure the ceiling exists to prevent.

    The estimate is conservative by construction and can refuse a load that would have fitted; set the ceiling where a failure is what you want, not as a tight budget.

  • row_limit: a true result-row cap, with a mandatory truncation signal. Available per query on KnowledgeGraph.cypher, Session.cypher / Session.execute, FrozenGraph.cypher and Transaction.cypher, with a graph-level default via set_default_row_limit() / get_default_row_limit(), and on the Rust surface as ExecuteOptions::row_limit / CypherExecutor::with_row_limit. For a caller that executes arbitrary user-typed Cypher and cannot inject a LIMIT textually, this bounds what comes back without rewriting the query.

    It is the deliberate opposite number to max_work_units, not a rename of it: max_work_units bounds work and errors, row_limit bounds retained result rows and truncates. The two are orthogonal and compose — a generous cap never rescues a query from an exhausted work budget.

    The query still runs to completion and still computes every row: ORDER BY sorts the whole answer and aggregation folds the whole answer, and only retention of the finished rows stops at the cap. So the rows kept are the first N of the uncapped answer — the genuine top-N under ORDER BY — and an explicit LIMIT m in the query is applied first, making the effective cap min(m, row_limit). A UNION arm and a CALL {} body are inputs to the result rather than the result, so neither is capped; capping them would change the answer instead of its size. A mutation’s trailing RETURN is capped like any other result, while every write still happens and last_mutation_stats still counts them all — the cap bounds what is reported, never what is changed. EXPLAIN is exempt. row_limit=0 is legal: keep no rows, still report the total.

    Truncation is never silent, and the reported total is exact. QueryDiagnostics gains row_limit (the cap in force, echoed whether or not it bit) and total_rows (the pre-truncation count, populated only when rows were actually dropped, so it doubles as the truncation flag). The count is exact on every execution path — eager rows, the lazy/streaming descriptor, and a mutation’s RETURN — never an estimate or a lower bound, so "showing 5,000 of 412,003" is answerable from the result alone. In Python both fields appear in ResultView.diagnostics, and a truncation also raises an ordinary query warning, so it reaches stderr, ResultView.warnings and the pywarn announcement channel — including for to_df=True and FORMAT CSV, whose return shapes cannot carry diagnostics.

    Exposure through the C ABI and the Java binding is deferred: the published C ABI is additive-only within a major, so row_limit there means a new exported symbol rather than a changed signature, and that is a separate change.

    Two source-compatibility notes for Rust consumers: ExecuteOptions and QueryDiagnostics each gain a public field, so any struct-literal construction of them needs the new field (ExecuteOptions::eager(&params) and QueryDiagnostics::default() are unaffected).

  • KGLITE_TMPDIR redirects the .kgl load spill directory. Loading a graph with a column blob of 256 KB or more mints $TMPDIR/kglite_portable_<pid>_<tick><seq>/ and writes the blob there to be mmap’d. Set KGLITE_TMPDIR to a non-empty path to put those directories — and the orphan sweep below — on a chosen volume instead; unset or empty keeps std::env::temp_dir(). Useful where $TMPDIR is small, RAM-backed, or on a different disk from the graph (on macOS it is always on the system volume, wherever the graph lives).

Changed

  • kglite.open routes through the shared core open path. The wheel hand-rolled its own load-then-convert sequence; it now calls kglite::api::io::open_or_create_graph_in_mode, the same entry point the C ABI, the Java wrapper and the Bolt/MCP servers already open through, so load-or-create, the mode conversion and the recovery-on-open rule have one implementation instead of two. Signature, error classes and durability semantics are unchanged; the one behavioural gain is core’s file-identity check, which now turns a .kgl replaced while it was being read into an error rather than a graph assembled from two different files.

  • kglite.open’s docstring says what a viewer should use instead. The defaults are a writer’s — a WAL sidecar and the writer lease are attached beside the path, adding roughly +110 MB on a 134 MB graph — which is right for the embedded-database entry point and wrong for reading. load() / open_session() take neither; open(path, durable="off", lock=False) is the read-mostly form. Nothing changed in the behaviour, only in what says so.

  • .kgl loads are 5–10% faster. Rebuilding a loaded graph’s type indexes allocated and hashed a fresh String for every node’s type name; it now groups on the interned type key and resolves once per type, which is what the standalone rebuild_type_indices already did. Release-profile load wall time on the measurement fixtures: 621 → 589 ms (546k nodes / 765k edges) and 362 → 324 ms (500k nodes, 4 indexes), with peak physical footprint down ~0.5 MB.

Fixed

  • set_memory_limit’s spill directories leaked after a kill, ignored KGLITE_TMPDIR, and could collide between graphs. A graph under a memory limit — which includes every storage="mapped" graph, since mapped is a zero limit — materialises its columns into kglite_spill_<pid>_<clock> and removes the tree when the last handle drops. Three defects, all in the directory’s minting:

    It was swept by nobody. The spill janitor added in the previous release reclaims directories whose embedded pid names no running process, but it knew only the .kgl load’s kglite_portable_ prefix, so a process killed by a signal, an OOM kill or a panic-abort left its kglite_spill_ trees behind forever — the same accumulation, in a second site, that was measured at 4,377 orphaned trees and 8.5 GB in a day of load-and-kill cycles. The sweep now covers both prefixes with the identical predicates (parseable name, not our own pid, a real directory rather than a symlink, over an hour old, and dead only on ESRCH), so one sweep reclaims both.

    It ignored KGLITE_TMPDIR. This path resolved std::env::temp_dir() directly, so an operator who pointed the variable at a roomy volume still had memory-limit spills land in $TMPDIR. Both producers now mint through one root resolver.

    It could collide. The name’s only varying part was the wall clock, and CLOCK_REALTIME advances in ~41.7 ns steps on arm64 macOS — so two graphs in one process crossing their limit together shared a directory, and the first one dropped ran remove_dir_all over columns the second still had mapped. This is the same race fixed for the load path in 0.16.14; the fixed-width sequence counter that closed it there now covers this site too.

  • Concurrent .kgl loads in one process could fail with a bare OS error. Every load mints a spill directory named kglite_portable_<pid>_<clock>, and the clock is not a unique value — CLOCK_REALTIME advances in ~41.7 ns steps on arm64 macOS, so two threads loading at the same moment read the same nanosecond and got the same directory. The first graph dropped then ran remove_dir_all over the tree the second was still using, and the loser’s next syscall failed: measured at roughly 1 in 600 loads with 16 threads reading one small file, surfacing as EEXIST (“File exists”), EINVAL (“Invalid argument”) or ENOENT out of load_file, with nothing in the error to say what had happened. A downstream hit two of those three on a 12 KB fixture during a concurrent test run. Directory names now carry a process-global sequence number, so two loads can never name the same directory whatever the clock says.

  • A load with nothing to spill no longer creates a temp directory. The spill directory was minted per column section at load time, before anything knew whether a column would be written there — so loading a small .kgl, whose every column is far below the 256 KB mmap threshold, still ran mkdir under a shared $TMPDIR and left an empty tree behind if the process was killed. The directory is now created at the first blob actually written to it. A .kgl with no large column touches no path but its own.

  • Load errors now name the operation and the path. load_file reports through io::Error, and a failing syscall’s error went out exactly as the OS produced it: Os { code: 17, kind: AlreadyExists } from a function called “load”, with no way to tell which syscall, which path, or whether the failure was even kglite’s. Every syscall on the load path — opening and stat’ing the file, reading it, mapping it, and writing or mapping a spill column — now wraps its error as opening '<path>': No such file or directory (os error 2). The ErrorKind is unchanged (consumers classify on it) and the OS errno stays in the message.

  • A malformed .kgl is now reported as a format error, not an I/O error. Bad magic, a container or core-data version from the future, the v3 hard break, and the pre-provenance embeddings break all raised io::ErrorKind::Other, while the v4 break, truncation and digest failures raised InvalidData. Consumers classify on that kind: the C ABI mapped the first group to KGLITE_ERR_FILE_IO — contradicting kglite_load_file’s own documented KGLITE_ERR_FILE_FORMAT (“file isn’t a valid .kgl”) — so a C consumer handed a CSV was told to retry the I/O. Every refusal that is a statement about the file’s bytes is now InvalidData / KGLITE_ERR_FILE_FORMAT; Other is left for failures that are about neither the bytes nor a syscall. Python is unaffected: the wheel already mapped everything but not-found and permission-denied to FileFormatError.

  • Spill directories orphaned by a killed process are now reclaimed. Cleanup was drop-based only — the last DirGraph holding the paths removed them — so any process that died by signal, OOM kill or panic-abort left its spill tree behind, and nothing swept it: macOS does not clean $TMPDIR during a live login session. A downstream measured 4,377 orphaned trees totalling 8.5 GB accumulating in a single day of load-and-kill cycles. Each process now sweeps once, at its first spilling load, removing sibling directories that match the spill-name pattern exactly, carry a pid no running process has, and are more than an hour old (the margin covers pid reuse). Every predicate fails towards keeping a directory: unparseable names, symlinks, our own pid, live pids, young directories and unreadable metadata are all left alone, and a removal that fails is counted and stepped over rather than failing the load. Unix only — Windows keeps drop-based cleanup, since its liveness probe has untested pid-reuse semantics.

[0.16.14] - 2026-08-29

Added

  • The kglite::api facade now exports the types its own signatures name. A Rust downstream previously had to hand-mirror them or reach past the curated surface into kglite::datatypes::*, and a mirrored copy drifts silently. New paths, all re-exports of existing types (no behaviour change): api::mutation::{DataFrame, ColumnType, ColumnData} — the bulk-ingest container add_nodes / add_connections / replace_connections take, and the two types needed to fill one; api::PropMap — the property container behind Value::Map, NodeValue::properties, RelValue::properties and ColumnData::Map; api::GraphEdgeRef — the item type of every GraphRead edge iterator; api::GraphInfo — what DirGraph::graph_info() returns; api::introspection::{ConnectivityTriple, DerivedEdgeStats, NodeTypeOverview, NeighborsSchema, NeighborConnection, PropertyStatInfo} — the result types of the already-exported compute_* / derive_* functions and of DirGraph::get_or_compute_type_connectivity(); and api::introspection::{graph_scale, GraphScale} — the four-tier core-type-count classification describe() adapts its output by, so a consumer stops copying the thresholds. GraphScale gains Debug/Clone/Copy/PartialEq/Eq.

Changed

  • BREAKING — max_rows is renamed max_work_units on every surface. The knob was never a result-row cap: it is a budget over work — intermediate rows, retained collection items and scan work units — and exceeding it fails the query with an error rather than truncating it. The old name told callers the opposite, and the first Rust downstream read it as a row limit. The old spelling is removed outright, with no alias: kglite::api::session::ExecuteOptions::max_rowsmax_work_units and CypherExecutor::with_max_rowswith_max_work_units (Rust); the Python cypher() / execute() keyword max_rows=max_work_units= and set_default_max_rows() / get_default_max_rows()set_default_max_work_units() / get_default_max_work_units(); the Java cypher(...) / query(...) overloads’ maxRows parameter → maxWorkUnits. Error text changes with it (exceeding max_rows limit of Nexceeding the max_work_units budget of N), so callers matching on the message need updating. A future true row cap would be a separate feature under its own name. The C ABI’s exported symbols are unchanged — no signature, type, arity or ordering moved. kglite_session_execute_read_opts and kglite_session_execute_mut_opts simply spell their fifth parameter max_work_units in kglite.h, and parameter names are not part of the ABI, so prebuilt consumers relink unmodified.

Fixed

  • Two doc comments that described behaviour the code does not have. load_kgl_bytes claimed “no filesystem access”; loading a graph with column sections in fact mints a $TMPDIR/kglite_portable_* directory and spills any column blob of 256 KB or more into it to be mmap’d (removed when the last DirGraph holding it drops). And the C ABI’s _opts doc said the query is rejected “if it would produce more than this many rows”, which is the row-cap reading the rename exists to kill. Both now state what actually happens.

  • The embedder-facing read-pass docs named a function embedders cannot call. GraphRead::node_view and NodeView told callers their borrow must not outlive the begin_query() guard; begin_query is pub(crate). They now point at the public DirGraph::begin_read_pass.

  • A reloaded .kgl no longer reports every relationship type as having zero edges. Edge counts are persisted only when their cache happens to be warm at save time, so a graph built with Cypher CREATE and saved carried none — and the load path then derived the type-connectivity cache with a fabricated count of 0 per triple. That cache is authoritative on read, so the lazy recount that would have produced the true numbers never ran, and describe() plus the planner’s selectivity estimates saw zeros over a graph full of edges. The load now leaves the cache cold when real counts are absent, and distrusts persisted all-zero triples on a graph that has edges, which repairs files already written with the fabricated zeros.

  • Two saves of the same graph produce identical .kgl bytes again. Four persisted lists were written in hash-map iteration order — the type connectivity triples, and the property / composite / range index-key snapshots — so a content-addressed cache or a committed fixture downstream saw the file change with nothing in the graph changing. Each is now sorted at the write, where the reader already treats it as a set; the disk-mode type_connectivity.bin.zst and interner.bin.zst sidecars get the same treatment. No format change: the same reader loads files written either way.

[0.16.13] - 2026-08-27

Release-mode measurements vs the published 0.16.12 wheel (min of 200 rounds, 200k-node two-member closure, two agreeing runs, controls flat): supertype property equality 1.495 ms → 0.0017 ms (~880×, at parity with the direct subtype lookup); dematerialize_ontology() 286 ms → 0.2 ms at 100k and 1,195 ms → 0.4 ms at 200k; alternation count() 2.57 ms → 0.0010 ms and alternation equality 1.70 ms → 0.0016 ms. make bench-check: no regressions

+20% across 34 benchmarks (worst cell +4.3%).

Rust API note (semver-check): kglite::api::RelationshipDecl gained public fields exempt and ancestry — Rust code constructing it with a struct literal must add them (or use ..Default::default()); Python callers are unaffected.

Added

  • Label alternation stops being the slowest spelling of its own question. MATCH (n:A|B|C) RETURN count(n) now fuses to a per-branch cardinality sum wherever the branches provably cannot overlap (no branch label carries a secondary label, so a node reaches at most one branch through its single primary type) — visible as FusedCountLabelUnion :A|B|C in EXPLAIN. Where they can overlap the count still unions and deduplicates the branch buckets, because a node holding two of the labels must be counted once. And MATCH (n:A|B {p: v}) now probes each branch’s index when every branch can answer a point lookup of every equality predicate the pattern binds, unioning each label’s index-invisible secondary carriers into the result; one branch without that coverage declines the whole probe rather than dropping its rows. Previously both shapes concatenated, sorted and deduplicated every branch’s full carrier set before filtering. Measured on a 2×100 000 two-type graph (debug profile, so treat the ratio rather than the absolute): count() 32.2 ms → 0.007 ms, {email:} 26.2 ms → 0.014 ms, with the single-label controls unmoved.

  • ontology_audit() breaks down by violating source class, and gains a domain_class column. CALL ontology_audit({by: 'domain_class'}) fans each rule’s scorecard row out into one row per primary node type its violations come from — the “which source types are actually violating?” follow-up that previously needed a hand-written Cypher query per rule. violations and pct are that class’s share; severity, exempted and total keep their per-rule values on every fanned row. Exempted rows are left out of the breakdown (a class whose every violation is exempted gets no row), and a rule with nothing to fan out keeps its single aggregate row, so no rule ever disappears from the scorecard. The domain-side class is the edge source for domain/range/required_properties/property_types, the node itself for required/cardinality, and for the pair/triple shapes (inverse/symmetric/transitive) the first bound node — the source of the edge or chain whose partner is missing. by is validated: any other key, or any other value, is refused with the accepted spelling.

    Column change — check any suite that pins this result shape: CALL ontology_audit() gains a trailing domain_class column, Null on every row of a bare call.

  • CALL edge_property_violation() — the row-level drill-down behind the audit’s required_properties and property_types counts, which were the only declared checks with no procedure to enumerate their rows. Yields relationship, check, source, target, property, exempt — one row per flagged edge, property naming the first declared property it fails and exempt marking the rows an exempt declaration excuses, so a relationship’s row count for a check equals that rule’s violations + exempted in the scorecard. No-argument only: the declarations are the argument.

  • Per-source-class exemptions on ontology relationship checks, and two new audit/SHOW ONTOLOGY columns. A declaration can now name source classes whose violations are reported separately instead of counted against severity: "exempt": {"required_properties": ["PetregLicence"]}. A class matches when it is the edge source’s primary type or one of that type’s declared ancestors — the same widening domain/range acceptance uses — so one legitimately nonconforming source type no longer pins a whole rule at advisory. Exemption is accepted for required_properties and property_types only, the two checks where “domain-side class” means the edge’s source type; every other check name, and the flat exempt: [...] form, is refused at declaration time with the reason, as is a class the ontology does not declare.

    Column changes — check any suite that pins these result shapes: CALL ontology_audit() gains an exempted column between violations and total (violations + exempted = everything the check flagged; violations and pct now exclude exempted rows), and SHOW ONTOLOGY gains an exempt column between enforcement and description (Null for classes and for relationships that exempt nothing). The blueprint ontology gate reports the exempted tail — HAS_OPERATOR.required_properties: 0/581 (0.0%) violations (+581 exempted) — including when the exemption leaves zero violations, so a passing gate is never mistaken for a clean graph. describe() renders the same summary as an exempt="…" attribute.

  • ancestry: true on an ontology relationship — the annotation a parent-pointer taxonomy actually wants. It records that ancestry along the relationship is meaningful and is walked with *1.., and it enrolls no check: it shows up in describe() (ancestry="true (walk with *1..)") and the agent describe(topic='ontology') guidance, and nowhere else. transitive: true keeps its existing meaning — it enrolls transitivity_violation, which audits a stored closure and requires a stored a→c edge for every a→b→c, so declaring it on a taxonomy that stores only parent pointers (STRAT_PARENT, wdt:P279) reports 100% violations. The two are mutually exclusive: declaring both is refused at define_ontology() time with the difference spelled out.

  • EXPLAIN surfaces closure-probe eligibility: a MATCH on a materialized Closed ontology supertype whose live member types are all index-covered for the queried properties now emits a ClosureProbe :Person (Student, Teacher) row naming the members the probe visits. Eligibility comes from a single predicate shared with the matcher, so the plan cannot advertise a probe the runtime declines. Parameter-valued properties ({p: $v}) stay conservatively unmarked.

Changed

  • The ontology class-cap refusal no longer recommends transitive:. Declaring more than 512 classes has always been refused with “large taxonomies are data — model them as edges”, but the advice it gave for those edges was transitive: true, which enrolls the stored-closure audit and reports every parent-pointer edge as a violation. The message now points at ancestry: true and says why transitive is the wrong promise.

Fixed

  • dematerialize_ontology() was quadratic in the number of labelled nodes (and clear_ontology() with it, since it withdraws materialized labels first): the exit removed one member at a time from a sorted bucket, and members are visited in ascending order, so every removal memmoved the whole remaining tail. It now drops each managed label’s bucket in a single move — ~29 s on a 1M-node graph before, linear in the member count after, with the per-node write-ahead-log records, change-data-capture before-images and rollback entries all preserved.

  • The ontology closure probe never engaged for property equality: a MATCH (p:Person {email: 'x'}) on a materialized Closed supertype scanned the whole label instead of probing each descendant’s index, as the 0.16.11 notes promised it would. The in-memory index store answered “no index” and “index built, value not in it” identically, and the probe’s per-member union declined on the first miss — but a unique value lives in at most one member’s index, so with two or more live members the probe was structurally guaranteed to decline. An index lookup for a covered property now answers “proven empty” for a value it does not hold (the contract the disk store already had), and the probe is alias-aware: an index built under a type’s registered title-alias spelling serves a query written as {title: …}, and vice versa. Supertype equality on a 200k-node two-member closure went from a full-label scan to a point lookup.

  • Indexed equality for an absent value scanned the type: the same conflation made MATCH (n:Student {email: 'absent'}) re-derive its empty answer by walking every node of the type. It now short-circuits.

  • Creating an index on name could change a query’s answer: n.name resolves to the node’s title when the node carries no stored name property, but create_index reads the stored property alone — so the index held a strict subset of what the same MATCH matched, and building one silently dropped rows from {name: …} lookups. Indexes on the structurally resolved names (name, type, node_type, label) are no longer read as authoritative for a point lookup; those patterns take the scan that answers correctly. The same rule fixes MERGE (n:T {name: …}), which probed the title index for a name key and could create a duplicate of the node it missed.

  • A WHERE equality pruned rows of the wrong type: the index pre-filter read the node type of the first row’s binding and pruned every other row against that type’s index, so an untyped MATCH (n) WHERE n.city = 'Oslo' could drop matching nodes of the other types. Rows of another type are now left for the predicate itself.

  • A fluent where() equality over a mixed-type node set dropped rows: the index fast path took the first indexed type’s hits as the whole answer. It now requires every type present to be index-answerable and unions them.

  • estimated_rows was 0 for materialized supertype labels: EXPLAIN counted the primary type bucket only, and a label carried purely as a secondary one (every materialized ontology supertype) has none — while the join-order model already counted both. One cardinality helper now answers both.

  • Label alternation rendered only its first branch: EXPLAIN and PROFILE showed MATCH (n:Student|Teacher) as Match :Student, naming a narrower plan than the one that runs. Both branches are now rendered (Match :Student|Teacher).

  • describe() under-reported enforcement: the ontology block printed the base severity only, so a relationship whose per-check enforcement map raised a check to error still read as advisory. Both reader surfaces (describe() and SHOW ONTOLOGY) now render one summary, base; check=severity, .

  • User-facing ontology and label messages no longer carry embedded whitespace runs: the blueprint ontology-gate failure, the describe() ontology note, the managed-label REMOVE refusal, the abstract-shadows-live-type error, the concrete-class-without-nodes warning, and the label-alternation parse error each showed runs of spaces mid-sentence.

[0.16.12] - 2026-08-26

Fixed

  • Ontology required_properties / property_types were declared-but-dead: both keys were parsed and persisted but never checked anywhere. They now produce per-edge audit rows (REL.required_properties: listed property absent or null; REL.property_types: present value fails its declared type), flowing into ontology_audit(), the no-arg validators’ shared machinery, and the blueprint gate. property_types type names are validated at declaration time against the closed type vocabulary.

  • inverse_name no longer auto-enrolls the physical inverse check: the declared design is a reading-direction alias (“no second edge exists or is implied”), but the audit scored every naming-only declaration as 100% violations — and enforcement: "error" made correctly modelled graphs unbuildable. Opt into the physical-pairing audit with inverse_enforced: true; symmetric keeps its check. Behavior change: naming-only declarations no longer emit .inverse audit rows.

Added

  • Per-check enforcement severities: enforcement accepts a {check: severity} map (e.g. {"required_properties": "error"}) alongside the scalar form; unlisted checks keep the advisory base, unknown check names are refused. SHOW ONTOLOGY renders the base plus overrides.

[0.16.11] - 2026-08-26

Added

  • Structured-data support (lists/maps stay the substrate — no new value or persistence concept). set_table_property / get_table_property store a DataFrame as a queryable list<map> with column order, dtypes, and nullability restored on reconstruction from a persisted per-(type, property) registry. define_schema types values accept structured shapes (list<map{sku: string!, qty: int!, price: float}>), enforced pre-write at add_nodes/from_records (whole-frame: nothing written on violation), Cypher SET, and CREATE, with indexed error paths (line_items[37].qty: expected integer); plain type strings stay advisory, WAL replay never validates. Nested SET l-values (SET o.line_items[2].qty = 8, SET o.metadata.status = 'x') execute as atomic engine-side read-modify-writes; read-side o.items[2].field postfix access now parses; list append is o.items + [row]. New mutating procedures table.upsert / table.delete do keyed row replace/append/remove inside the engine (no lost updates between application-level reads and writes). kglite.attach_rows(...) builds the normalized row-nodes form in one call, and describe() renders declared shapes (or a sample-inferred shape=... shape_inferred="true") per property. Docs gain an “embedded table vs row nodes” recipe.

  • Label expressions in reading patterns: MATCH (n:Law|Regulation) matches through any listed label, primary or secondary carriage, deduped (Cypher 25 / GQL alternation; the exact shape a declared supertype compiles away, and useful without one). Alternation and the :A:B AND chain don’t mix in one pattern (parse error, so no precedence is silently committed to), | is refused in CREATE/MERGE/SET/REMOVE, and $param branches work ((n:$a|$b)).

  • Ontology declaration layer (annotations, not axioms — SKOS in spirit, never OWL; it never changes what a query matches). define_ontology() / ontology() / clear_ontology() install a persisted store of classes (an is_a forest with abstract supertypes, descriptions, and a documentation-only by discriminator) and relationship semantics (domain/range, required_properties/property_types, inverse_name, cardinality, required, transitive/symmetric, per-declaration enforcement: advisory|warn|error). Read from Cypher via SHOW ONTOLOGY and the CALL ontology_audit() scorecard; the six declaration-backed rule procedures called with no arguments now check every declaration (a domain/range naming an abstract class widens to its declared descendants — the union-endpoint case a flat schema cannot declare), each row carrying a rule column. describe() renders an <ontology> section when declared. Blueprints reference a document ("ontology": "x.json") installed and audited as a final build phase — warn-level violations land in the build report, error-level violations fail the build after a full report, writing no .kgl. The MCP server applies extensions.ontology: {file: ...} at boot, memory-only. Deliberately independent of set_parent_type (presentation ownership).

  • Ontology materialization: materialize_ontology() stamps declared supertypes as real secondary labels — MATCH (p:Person) finds every Student and Teacher with today’s query semantics, indexes, and labels(n). Materialized labels are managed (closed: engine-only writer, bucket = declared closure; open: a manual SET, adoption, or union touched it — still correct, closure-reliant optimizations off), and writers downgrade to open rather than refuse; manual REMOVE of a managed label is refused, dematerialize_ontology() is the exit, ontology_diff() reports drift. Write paths maintain the closure (Cypher CREATE/MERGE, add_nodes; creating a declared abstract class is refused naming its concrete subtypes), WAL replay treats the logged label ops as authoritative (a logged dematerialize recovers as an un-apply), and extract_subgraph/save_subset carry labels and ontology. A property-filtered match on a closed supertype uses per-descendant index probes instead of a bucket scan. The MCP manifest’s extensions.ontology accepts materialize: true (boot-time, memory-only).

  • Blueprint junction edges accept a rename map ("rename": {"csv_col": "property_name"}) to store a CSV column under a different edge-property name. Keys must be columns listed in properties; property_types stays keyed by the CSV spelling; fk columns are not renamable.

Changed

  • The six declaration-backed rule procedures gained a rule column (bare CALLs therefore return one more column): the declaration name in no-argument form, the explicit call’s own subject otherwise.

  • The edge-aggregate and spatial-join query fusions no longer switch off for the whole graph the moment any secondary label exists. The gate is now per pattern: only a pattern that carries an extra label, or names a type that also exists as a secondary label, falls back to the general path (its fused executor cannot see secondary-labelled nodes). Measured on the old global bail: 71× (edge aggregate) and 33× (spatial join) slower for unrelated queries on multi-label graphs.

  • Blueprint builds now warn (in the build report / verbose=True output) for every properties / property_types value that is neither a type keyword nor a spatial target. Such values were silently ignored — the column type fell through to inference — which let the “property_types renames columns” misconception succeed without a trace.

Fixed

  • vacuum() corrupted secondary labels — the compaction remapped every index except secondary_label_index (it lives above the storage backend, so reindex() cannot see it), leaving label buckets pointing at stale node indices after any vacuum on a labelled graph: MATCH (:Label) returned phantom all-null rows, count() over-reported, and surviving nodes lost their labels. Auto-vacuum (on by default, threshold 0.3) triggered this without an explicit call. The vacuum now remaps the label buckets alongside the embedding slots.

  • CREATE INDEX (Cypher) and create_index / create_range_index / create_composite_index (Python) on a label that exists only as a secondary label now fail with a clear error instead of silently building an index no lookup ever consults — property indexes are keyed by primary type. A label that is also a live or schema-declared primary type stays indexable.

  • The REMOVE n:PrimaryType error (and two add_label/remove_label docstrings) advised retyping via SET n.type = 'NewType' — an operation that does not exist (SET n.type is itself rejected). The message now says what is true: the primary type is immutable; recreate or migrate the node.

[0.16.10] - 2026-08-25

Added

  • build_text_index(node_type, property) — an opt-in BM25 lexical index over a string property, alongside drop_text_index and has_text_index (Python) and kglite_session_build_text_index (C ABI). Explicit, like create_index and build_vector_index: nothing builds one for you. Empty strings index as empty documents; a property that is absent or holds a non-string is skipped and counted in the report. The property is read through the same alias resolution a MATCH filter uses, so a type’s id/title column can be indexed under the loader’s name for it. vacuum() renumbers every node and so drops text indexes wholesale (rebuild after). Available in the default (in-memory) and mapped storage modes; disk refuses, naming the modes that work. Text indexes appear in SHOW INDEXES / db.indexes() as type FULLTEXT under the canonical Label.property name, and DROP INDEX Label.property removes them along with any equality or range index on the same property.

    The index catches up with writes instead of following them. Creations and edits after the build are recorded, not indexed on the spot, and are folded in when a query next reads the index — as long as the outstanding delta is at or under auto_refresh_limit (a build_text_index keyword, default 1000 documents; a rebuild that omits it keeps the value you set). Past that limit the index serves what it has rather than putting an open-ended catch-up inside your query, and build_text_index again is the route back. The limit bounds a document count, not a duration: folding one document in splices into the posting list of each of its terms, so the per-document cost grows with the corpus (measured: 0.08 ms/document at 20k documents, 0.4 ms at 100k). Past ~1500 documents that overtakes a full rebuild, and the catch-up rebuilds instead — a refresh costs the cheaper of the two and never more than one rebuild, whatever the limit is set to. SHOW INDEXES / db.indexes() gained two columns for this — stale and delta, null on index kinds that are maintained on every write. Recording a write is a node-slot comparison, so bulk ingest into an indexed graph runs at the speed it would without one, ingest of an unrelated node type does not make an index look stale, and a graph with no text index pays a single branch. Deletion is not staleness: it prunes the deleted node’s document immediately, because the freed node slot is handed to the next node created and an orphaned document would be inherited by it — and a rolled back delete marks the slot so the next catch-up restores the document.

    A text index is saved with the graph. save() writes it into the .kgl as its own self-describing section, carrying its resolved column, its auto-refresh ceiling and its staleness, and load() restores all of it — a reloaded index that was stale is still stale by the same delta, and catching it up produces exactly what a rebuild would. The section is a rebuildable cache, not a format break: a graph with no text index writes byte-identical files to before, older files load unchanged, and a section this build cannot read is skipped rather than refused (rebuild the index in that case).

    text_bm25(n, 'property', 'query text') is how you search it. A Cypher scalar, so every binding gets it through cypher_query: it returns the BM25 relevance of that row’s document, 0.0 for an indexed document that shares no word with the query, and null for a row the index has no document for — the two are different answers and are reported differently. It composes with ordinary WHERE and ORDER BY LIMIT k like any other scalar. Calling it on a (node type, property) with no index is an error naming build_text_index, never a column of nulls. A query that reads a stale index folds in a small delta first (above), and one that finds a delta over the limit — or a read-only graph, which a query may not write to — serves what the index has, scores the rest null, and returns a warning naming the delta and the rebuild call.

  • score_fuse(s1, s2, [, weights]) — hybrid retrieval in one Cypher query. A pure scalar that combines several ranked lanes into one number, so score_fuse(text_bm25(n, 'body', $q), vector_score(n, 'body_emb', $qv)) ranks by keyword and by meaning in a single statement, with the graph filters and traversal of ordinary Cypher around it. Lanes weigh equally by default; a trailing list weights them in argument order (relative, so [3, 1] and [0.75, 0.25] rank identically). A lane that could not see the row — null, NaN or an infinity — leaves the average together with its weight rather than scoring zero, because zero would rank a document one lane could not see below a document both lanes disliked; the result is null only when every lane is absent. A wrong-length weights list, a negative weight, and a non-numeric score are errors rather than a quietly different ranking. There is no rrf() scalar: Reciprocal Rank Fusion needs each lane’s rank across the whole result, which no per-row scalar can see — CYPHER.md documents the two-line recipe (rank() OVER (…) in a WITH, then fuse the reciprocals) that computes it from primitives that already exist.

  • Vector indexes adopt the same catch-up contract as the new text ones. Writing vectors after build_vector_index no longer drops the index. Neither arm of a vector write moves an existing store slot — an append lands past the index’s coverage, a re-embed rewrites one slot’s contents — so both are recorded and folded into the HNSW graph at query entry while the outstanding delta stays at or under auto_refresh_limit (a new build_vector_index keyword, default 1000 vectors; a rebuild that omits it keeps the value you set). embed_texts(mode='changed') over five documents of a million therefore costs five incremental inserts instead of a corpus-sized rebuild. A larger delta is served by the exact scan rather than by a stale index: the exact path is the oracle the approximate one is measured against, so a stale vector index costs latency and never accuracy — and the fused Cypher top-k emits a diagnostic naming the delta and the ceiling. Catch-up never embeds: a node with no vector is not part of the delta.

    Vector indexes are also visible now. SHOW INDEXES / db.indexes() list a built one as type VECTOR under its source column (Doc.summary, not the Doc.summary_emb store), with the stale / delta columns and a new unembedded column counting nodes of the type that carry no vector at all. DROP INDEX Doc.summary removes it along with any equality, range or text index on the same property — the accelerator only; the vectors are data and stay. New: refresh_vector_index(node_type, text_column) folds the outstanding delta in on demand. Node creation cannot make a vector index stale (a node with no embedding is not a document), so bulk ingest into a graph with vector indexes is untouched by construction.

    Deleting an embedded node, rolling that delete back, and vacuum() still drop the index outright: each moves the slot layout the index addresses. The .kgl vector-index section carries the catch-up state alongside the topology and its payload version bumps to 3 accordingly; a graph saved by an earlier version loads normally and rebuilds its index on demand, which is the rebuildable-cache contract that section has always had.

  • New guide: Text Search and Hybrid Retrieval (docs/python/guides/text-search.md) — which lane answers which question, build_text_index() and what the analyzer does to your text, the top-k shape that reads the index’s postings instead of every row, the freshness contract end to end (inline catch-up, the over-limit warning, SHOW INDEXES, rebuild after vacuum()), and the one-query hybrid recipe with score_fuse() including RRF from window ranks. Every code example in it was executed against the build that ships it. FLUENT.md’s fluent-vs-Cypher matrix gains the text-index triple, and db.indexes()’ column reference in CYPHER.md no longer claims vector indexes are absent from that listing — they have been rows in it since this release’s vector-index work.

  • MCP server operators can let queries use the engine’s parallel runtime, via --parallel or extensions.parallel: true in the manifest (either surface alone turns it on; a malformed manifest value fails the boot rather than being silently ignored). Off by default, because a server’s cores belong to its clients. It is a permission, not an instruction — the engine still applies its own per-operator size gate, so small queries are unaffected and the answer is identical with the pin on or off — and it covers reads only: a --writable server keeps running mutations sequentially. Applied at the single read seam, so built-in cypher_query, manifest tools[].cypher templates, recipe routes, and a composed server’s domain tools all inherit it. Pool width follows available_parallelism, overridable with KGLITE_QUERY_THREADS; the boot log records the pin and the width it resolved.

Changed

  • The streaming aggregate honours the group cap LIMIT N puts on it. push_limit_into_aggregate stamped its hint on the projection, the materialized aggregator read it, and the streaming pipeline — which serves the common count/sum/min/max shapes — copied it forward and never looked at it. On 200,000 rows over 100,000 distinct groups, MATCH (n:Ev) WITH n.g AS gg, n.w AS w RETURN gg AS g, count(*) AS c LIMIT 5 built all 100,000 groups to return 5: 56 ms -> 26 ms (2.0x, release, Apple Silicon, min of 7, two agreeing runs), now level with the materialized path on the same query. Same rows, same order — the cap only skips rows that would open a group past the limit, and it declines on the node-property grouping keys where skipping them would change the answer.

  • A mapped graph’s first property probe no longer walks the node list. MATCH (n:T {p: v}) on storage="mapped" builds a lazy per-(type, property) index on first use, and turns it off for any type whose rows live in a column store — which is every type built by add_nodes, Cypher CREATE/MERGE or a .kgl load. It was rediscovering that by scanning the graph as far as the type’s first node, once per (type, property) pair: 0.82 ms on a 401k-node graph whose queried type was loaded after a bulk one. The store map now answers directly (0.065 ms, level with the same query in memory mode). Steady-state lookup cost is unchanged.

  • text_bm25() top-k reads the index’s postings instead of scoring every row. RETURN ... text_bm25(n, p, q) AS s ORDER BY s DESC LIMIT k now plans as one FusedTextBm25TopK operator (optimizer pass fuse_text_bm25_order_limit), which asks the index for its own best k documents. Row-at-a-time scoring cost the same for every query however selective; this cost follows the query. Measured on a 100,000-document synthetic corpus (release, Apple Silicon): a two-term query whose rarer term appears in ~30 documents went 30.8 ms -> 7.5 ms (4.1x), and a query opening with a near-stopword — whose postings name almost the whole corpus, so there is nothing to prune — went 25.2 ms -> 21.4 ms. Both are exact: each candidate is scored through the same kernel the per-row scalar uses, in the same summation order, so the rows and their order are identical to the unfused pipeline’s.

    The operator hands the query back to the ordinary ranked top-k whenever the index cannot answer it on its own — a WHERE that makes the rows a subset of the corpus, an index that has fallen behind (its un-caught-up rows score null, and ORDER BY ... DESC places nulls first), ORDER BY ... ASC, or fewer matching documents than the LIMIT asks for. Those queries answer exactly as before.

  • CREATE TEXT INDEX and CREATE FULLTEXT INDEX now point at build_text_index + text_bm25() instead of at the vector-search API. Both messages predate the BM25 index and claimed ranked text retrieval was only available through embeddings, which is no longer true.

  • BENCHMARKS.md is regenerated from one fresh all-backends capture, and its fluent column no longer advertises capability gaps kglite does not have. The benchmark’s fluent adapter raised Skip for twelve of the 26 sub-benchmarks; eight were expressible on the fluent surface all along, so the published table rendered Pathfinding, Vector search, Geospatial and part of Multi-type queries and Graph algorithms as for “kglite (fluent)” — the mark the table itself defines as a real capability gap — and reported its coverage as 14/26. shortest_path(), match_pattern(), degree_centrality(normalized=False, connection_types=...), statistics(group_by=...), a range where() and vector_search() cover those eight at digest-identical results to the Cypher column; coverage is 22/26. The four remaining skips name what actually differs — no fluent edge-scan primitive, traverse() returning a node set rather than path rows, and connected_components() / louvain_communities() taking no node-type scope, so they answer about a different universe. The fluent Mutations cell measures less work than its siblings (the Python surface has no delete outside Cypher); that substitution is now stated in the suite’s fairness notes rather than left to be read off a timing. The “kglite (disk)” Mutations cell ran the bulk loaders because a comment claimed Cypher CREATE is unsupported on disk-backed graphs — it is supported, so that cell now runs the same statements as every other Cypher column. All eleven columns, Neo4j included, come from a single invocation on one machine, so the table no longer mixes a fresh capture with a 0.11.2-era one.

Fixed

  • A LIMIT on an aggregating RETURN/WITH grouped by a node property silently dropped rows from the groups it kept. push_limit_into_aggregate lets the aggregator stop opening new groups once LIMIT N distinct keys are in hand; rows for a group already collected are supposed to keep feeding its aggregate. But the group set is keyed by a surrogate — a key of the form p.city is held as the bound node and resolved to a value only after the row pass — and any number of distinct nodes can resolve to one value, so freezing the surrogate set discarded rows belonging to groups that were already collected. On 30 :Person nodes across 3 cities, MATCH (p:Person) WITH p, p.name AS nm RETURN p.city AS city, count(*) AS n, collect(p.name) AS names LIMIT 1 answered n = 5 where 10 was the truth and returned 5 of the 10 names — no error, no warning, a plausible number. A LIMIT larger than the number of groups was affected too. The cap now runs only when every grouping key resolves to its value inline, which is exactly when the surrogate set and the final group set are in bijection; the shape above answers 10 on every path. Queries the cap now declines are slower and correct (a 200k-row, 100k-group count(*) ... LIMIT 5 went 53 ms -> 99 ms); queries it still serves are unchanged.

  • A second vector_score() in one query returned the first call’s score. The per-query cache that parses the property name, query vector and metric once had no record of which call it was prepared for, so the first vector_score in a query answered every later one: RETURN vector_score(d, 'summary_emb', [1.0, 0.0]) AS a, vector_score(d, 'summary_emb', [0.0, 1.0]) AS c returned a == c, and so did a query scoring two embedding columns, or the same column under two metrics. Wrong silently — the scores were well-formed numbers. text_score was affected through the same cache (it rewrites to vector_score), so two differently-worded semantic scores in one query collided too. Each call site now caches under its own arguments, and a query vector read out of the row (vector_score(d, 'summary_emb', d.vec)) is scored per row as written.

  • size() / length() on a bracket-delimited string returned an element count instead of the character count. A string whose text happened to start with [ and end with ] was parsed as a JSON list and measured by its elements, so size('[redacted]') answered 1, size('[]') answered 0, and size('[1,2,3]') answered 3 — none of them the characters (or the bytes) of anything the caller wrote, and no error to say so. Every string is now measured in characters, brackets included: size('[redacted]') is 10, size('[]') is 2, size('[1,2,3]') is 7. This matches Neo4j’s size(STRING) and completes the “characters, not bytes” rule 0.16.6 established for the rest of the string surface. Only the argument’s type decides — a real list still reports its element count — and KGLite’s other list-coercions on strings (UNWIND, indexing, head/last/reverse, IN) are unchanged. Documented as a dialect note in CYPHER.md.

  • A fluent filter that put more than one operator on the same property kept only the first and silently discarded the rest. where({'score': {'>=': 10, '<=': 20}}) — the two-sided range FLUENT.md has always documented — ran as score >= 10 alone and returned every row above the band, with no error and nothing in explain() to show the missing half. Which operator survived was whichever the caller wrote first, so reordering the dict changed the answer. Every operator in the dict is now required to hold, so a one-dict range selects exactly the same nodes as the two where() calls chained. Applies everywhere a condition dict is accepted — where(), where_any(), and the where / where_connection / filter arguments of traverse(), compare() and collect_children(). The engine gained a FilterCondition::All conjunction to carry it (public Rust API addition).

  • DROP CONSTRAINT against a schema primary key withdrew half of it and reported success. A key declared through define_schema is listed by SHOW CONSTRAINTS as a NODE_KEY row, so it resolved like any other constraint — but nothing in the DDL stores holds it, and the drop reached at most half of what it enforces. A key on a stored property deleted the unique index and answered constraints_removed: 1, after which duplicates were admitted while the row still read NODE_KEY; a key on id reached no store and failed with “no constraint named ‘Person.id’ exists … declared: Person.id” — a message enumerating the very constraint it denied; IF EXISTS turned both into a silent no-op against a row that stayed listed; and a key whose property was also declared NOT NULL reported success for withdrawing an entry the key required anyway. The key is now refused, with both spellings and with IF EXISTS, by an error naming define_schema as its owner and the calls that do withdraw it (re-declaring the type without a key, or clear_schema()). Every other constraint on a keyed type — including a composite tuple containing the key property — still drops normally.

  • Withdrawing a schema primary key deleted a CREATE CONSTRAINT ... IS UNIQUE declared on the same property. The two declarations share one entry in the engine’s unique index — the index is the constraint — and nothing recorded that a DDL statement had also declared it, so define_schema({'nodes': {'User': {}}}) after CREATE CONSTRAINT cu FOR (u:User) REQUIRE u.email IS UNIQUE plus a primary_key: 'email' removed cu from SHOW CONSTRAINTS and admitted duplicates, without an error and without the caller ever naming cu. This is the uniqueness twin of the NOT NULL provenance bug fixed in 0.15.0 and persisted in 0.16.4. DDL-declared uniqueness now carries its own provenance record, persisted in the .kgl metadata (an additive field: a file written by an older version loads unchanged, and a graph declaring none writes byte-identical output), so a schema install withdraws only what the schema declared and cu keeps reporting as UNIQUENESS until DROP CONSTRAINT withdraws it. A key with no DDL declaration behind it is withdrawn by the schema as before.

  • A disk graph opened durably (Rust durability::open_log) took the memory code path for type-connectivity statistics and bailed the fused typed-scan aggregate, because both routed on a bare GraphBackend::Disk match rather than through the write-capture-transparent as_disk(); results were unchanged, the disk fast paths were not.

  • A disk graph created by enable_disk_mode() answered edge-type queries as if it had no edges of any type. The conversion builds the CSR straight from the in-memory graph and writes no connection-type index; save() writes none either, so kglite.open() on the published directory inherits the same state. The disk edge-type scan read that missing index as “no source has an edge of this type” and stopped, so on a converted graph MATCH (p:Person)-[:VISITED]->(c) WITH c, count(p) AS n RETURN n returned no rows where the same query with disable_optimizer=True returned the right ones; describe(connections=[...]) reported a type with count="2" and no endpoints and no sample edges; and declaring a relationship constraint verified itself against zero existing relationships, installing a constraint the stored data violates. The scan now sweeps the CSR when no index exists — every matching edge, O(CSR edges) instead of O(matching edges) until one is built — and a graph that has an index is unchanged, index lookups and all. Graphs built with KnowledgeGraph(storage="disk", path=...) were never affected.

  • Eighteen public Python items answered help() with nothing at all. kglite.load and seventeen KnowledgeGraph methods — select, where, where_orphans, sort, limit, clear, save, connections, titles, get_properties, unique_values, collect_children, statistics, calculate, count, schema_text and selection — carried no runtime docstring, so help(kg.select) printed a signature and stopped, and pydoc/IDE hovers showed the same blank. Every one now carries a one-line summary matching the type stub’s first sentence. The full contract (Args, Returns, examples) stays in kglite/__init__.pyi, which is what the published API reference is generated from; the runtime docstring is deliberately the summary only, so the two cannot drift apart.

Removed

  • The three unstable _-prefixed subgraph methods on KnowledgeGraph_scan_edges_filtered, _save_subset_filtered_by_edge_type and _save_subset_induced_by_edge_type. They were a [DEBUG]-labelled spike that shipped alongside the public save_subset(path) in 0.9.12 and carried no compatibility promise; save_subset(path) covers the documented use. The underlying scan remains available to Rust embedders as kglite::api::io::pass_a_scan and friends.

  • EmbeddingStore::index as a public field (Rust API). The HNSW index now sits behind a lock, because catch-up happens at query entry where the caller holds &DirGraph and cannot reach a &mut store. Rust embedders reading it directly use has_index() / indexed_slots() / index_for_query(read_only), and the ones that wrote store.index = None use the invalidate_index() that was always the documented route. build_vector_index also takes a trailing auto_refresh_limit: Option<usize>; pass None for the previous behaviour. The C ABI’s kglite_session_build_vector_index is unchanged — its signature is fixed within an ABI major.

  • The spec parameter of kglite::api::io::save_subset (Rust API). It was accepted and then ignored: a Rust embedder passing Some(&SubsetSpec { edge_types: Some(...) }) got the unfiltered subset back, with no error and nothing in the output to say the edge-type filter had not been applied — a silent wrong answer for the one caller shape the parameter existed to serve. Nothing in-tree ever passed anything but None (the Python save_subset(path) route included), so the argument is dropped rather than given meaning it never had; the node selection is, and always was, what decides a subset’s contents. SubsetSpec itself is unchanged and still filters the disk Pass A scans (pass_a_scan, pass_a_scan_to_file), where it has always been honored. Rust callers drop the trailing None; the Python API is untouched.

[0.16.9] - 2026-08-23

Fixed

  • Loading a .kgl written by 0.16.6 took twice as long, and the 0.16.6 entry said loads were unmoved. That claim is wrong and is corrected here: the section-integrity digests 0.16.6 added were computed with a hand-rolled software CRC32 table running at roughly 0.5 GB/s, and every section of the container is verified before decode, so the whole compressed payload was passed through it on every load(). Reproduced against the published wheels on a 180 MB / 157 742-node / 1 008 891-edge graph (release, macOS arm64, min of 5): the same file content loaded in 402 ms when 0.16.5 had written it and 729 ms when 0.16.6 had — the file only pays if it carries digests, which is why a digest-free fixture showed nothing. The digests now go through crc32fast, which dispatches to the CPU’s CRC instructions, and the same 0.16.6-written file loads in 446 ms — a 1.63x recovery against the published wheel. Attributed by layer on a local release build, which reads the digest-free file in 423 ms (its own control; it runs ~6% behind the wheel, so the wheel comparison above understates the recovery): the CRC pass cost +360 ms (+85%) and now costs +14 ms, and the zstd frame content checksum costs +20 ms (+4.7%). Full eager verification of a file this build wrote is now +5.3% over a digest-free load of the same graph, rather than +85%. Both layers are kept — at ~5% combined the second check is worth its price, and it is the only one protecting bytes a reader skips.

    The digest values are unchanged: crc32fast computes the same CRC-32/IEEE as the table it replaced (both pinned by crc32_matches_known_vector), so this is not a format change in any direction. A .kgl written by this build is byte-identical to one written by 0.16.6 from the same graph; a file written by the published 0.16.6 wheel verifies here (25 of 25 single-byte flips still rejected, 0 silent loads), and a file written here verifies on that wheel. The .kgl golden digest does not move.

    Reported by the MCP-servers operator, who measured it on two production graphs after /update_kglite rewrote them with the new writer.

Changed

  • The benchmark gate now loads a file written by the build under test. The 0.16.6 load regression above shipped because no tracked cell loaded a freshly-written .kgl: make bench-check runs test_bench_core.py, whose only container cells were the two save cells, so a cost that exists solely in files carrying the new metadata was invisible by construction — the release measured “+11-15% on save, loads unmoved” and was half right. The new test_bench_load_kgl cell writes a ~4 MB graph in its fixture and times the load alone. It is version-independent, so the frozen CI harness runs it unchanged against the 0.13.2 reference wheel: each version writes and reads through its own path, which is exactly the quantity that went unmeasured. Non-vacuity checked against the shipped regression — 0.16.6 reads +72% on this fixture, well outside the 20% gate.

    The other persistence surface is closed in the same pass: test_bench_disk_dir_reopen_fresh publishes a disk-mode directory with the build under test and times open() on it — the reload path had no cell at all, fresh artifact or stored — and a PERSISTENCE_SURFACES table now pairs every writer cell in that harness with the cell reading a freshly written artifact back, with a meta-test that goes red, naming the surface, when either half of a pair is missing.

    The same gate now also reports the cells it is about to catch: any passing cell within 8 percentage points of the threshold prints in an APPROACHING block (exit code unchanged, silent when the band is empty), and each local make bench-check appends one verdict/worst-cell/watch-band row to a recurrence record, so a cell that sits at +17-19% for three releases is visible before the release it finally crosses in.

[0.16.8] - 2026-08-23

Fixed

  • An edge-property write taken while a second reference to the graph was live (SET r.p / REMOVE r.p with a held ResultView, freeze(), Session, or open Transaction) was invisible to traversal reads of that property. RETURN r.p saw the new value while WHERE r.p = ... filtered on the old one, silently dropping rows from the same statement’s results. Edge-weight writes now materialise the fork the way adjacency edits already did.

  • count(DISTINCT <relationship variable>) returned the number of distinct peer nodes instead of distinct relationships, undercounting whenever parallel edges join the same pair. The shape now declines fusion and counts correctly; pinned in the differential corpus.

  • An aggregating WITH ... WHERE/HAVING ... LIMIT N could return fewer than N rows — the LIMIT was pushed into the aggregator ahead of the filter, so groups the filter would have rejected consumed the cap. The pushdown now bails on any inline filter; pinned in the differential corpus.

  • Composite indexes are keyed by their property names sorted, so an index created in non-alphabetical order (CREATE INDEX FOR (n:L) ON (n.city, n.age), create_composite_index('L', ['city', 'age'])) is actually used by MATCH and MERGE instead of silently falling through to a full label scan. has_composite_index, drop_composite_index and composite_index_stats accept either spelling. Existing .kgl files are canonicalized on load.

  • On storage="mapped" graphs, MATCH (n:Type {prop: value}) could serve values a later SET/REMOVE had overwritten — the backend’s lazy property index was never dropped by the property writers. It is now, and an index that cannot cover all of a type’s rows reports itself absent so the match falls back to a scan instead of returning a partial answer.

  • primary_key: "id" no longer exempts a node type from the presence half of its own keyCREATE (n:T {id: null}) is rejected with the same NODE KEY violation a primary key on any other property raises. Bare CREATE (engine-allocated id), MERGE and add_nodes are unaffected, and SHOW CONSTRAINTS now reports the declaration as NODE_KEY rather than NODE_PROPERTY_EXISTENCE.

  • load_ntriples on a storage="mapped" graph no longer lets a single unparseable Q-code subject null out n.id for every other entity of the same node type. Such subjects are dropped in mapped mode exactly as they already were in disk mode; memory mode still keeps them as string ids.

  • UNWIND over a JSON-string list no longer mis-splits items containing the other quote character ('["a,b\'", 2]' yields two rows, not one glued row), and a lone-quote item ('["]') returns its row instead of aborting the query with a panic.

  • Spatial fluent methods (near_point, near_point_m, within_bounds, bounds, centroid): passing only lat_field or only lon_field no longer discards the spatial-config name for the other side, which silently produced zero or wrong matches on graphs whose coordinate columns are not named latitude/longitude.

  • Value equality is now total, matching the ordering and hashing it already used — a HashSet, a BTreeMap and sort+dedup no longer disagree about NaN, and point() values differing only by -0.0 vs 0.0 hash alike. Cypher’s =, <> and IN keep IEEE semantics (NaN equals nothing, itself included); DISTINCT and grouping fold equivalent NaNs into one row, matching Neo4j.

  • graph_info()['format_version'] reports the real .kgl container version (currently 6), identical for a freshly built, saved, or loaded graph, and derived from the container magic so a future bump moves it automatically. It previously reported 2 or 3 depending on how the graph was obtained — both container versions frozen releases ago. Same correction reaches kglite_storage_format_version().kgl (C ABI) and Java’s storageFormatVersion().kgl. No on-disk change.

  • describe() / graph_overview() index annotations report what the engine actually serves: eq for the in-memory hash index (prefix acceleration was advertised where STARTS WITH full-scans), range for range indexes (previously unreported), eq,prefix only for the sorted disk-backed string index — and enabling CDC or durability on a disk graph no longer hides its persistent indexes from the annotation or the routed CREATE INDEX/DROP INDEX entry points.

  • index_stats() / composite_index_stats() reported an inflated unique_values (and deflated avg_entries_per_value) after a held read view was dropped — the level fold left removed values counted as live.

  • begin() and begin_read() raised a Rust panic instead of the documented RuntimeError when another thread was mutating the same KnowledgeGraph, matching cypher()’s behaviour.

  • Copy-on-write forks of a graph loaded from RDF/disk (or a .kgl saved from one) no longer deep-copy the sparse-property overflow blob; copy(), a transaction fork, and holding a result view share it by reference, and a file-backed blob is no longer pulled into RAM by the fork.

  • The disk subset scan returns an error when its kept-edges file cannot be created, instead of silently falling back to a heap buffer sized by the source graph’s total edge count.

  • CALL ready_set(...) reports the actionable “CALL procedure timed out” message on deadline expiry, matching every other graph procedure.

Changed

  • A composite index’s canonical name and reported properties (SHOW INDEXES, CALL db.indexes(), list_composite_indexes(), indexes(), schema()) use the sorted property order rather than declaration order — Person.(age,city) for ON (p.city, p.age).

  • KnowledgeGraph.add_connections_internal is no longer exposed on the Python class; it was an undocumented Rust-side helper for add_connections_bulk / add_connections_from_source, which are unchanged.

  • Corrected CYPHER.md and the matching docstrings: REQUIRE n.id IS NOT NULL is accepted and enforced (an explicit {id: null} violates it), and string-index prefix acceleration is storage='disk' only. A deep comment audit over the 104 densest source files corrected ~230 false code comments and six published-docstring clusters in the same release.

[0.16.7] - 2026-08-23

Changed

  • lock_schema() now rejects a read of a property no node of the type has, where it used to warn. The lock is documented as the “catch my typos” mechanism and already refused the same typo written as a pattern literal (MATCH (p:Person {agee: 1})) or as a label (MATCH (p:Persn)). The two shapes it let through are the two most often written: WHERE p.agee = 1, where the null comparison filters out every row, and RETURN/WITH/ORDER BY p.agee, where the column comes back all-null next to correct-looking siblings — both indistinguishable from a legitimate empty or sparse result. They now raise SchemaError, naming the clause, the valid property set and the did-you-mean, the same way the write path does. Unlocked graphs are unchanged — this is opt-in strictness, and unlock_schema() returns the findings to warnings. The check promotes only that one warning family: unknown relationship types and reversed-arrow patterns stay warnings in both states, and every conservatism the warnings were built with holds under the lock, so a valid query cannot start failing — a sparse property, a property the same statement writes, a type with no recorded properties, a field define_schema() declares but nothing has written yet, a multi-label pattern, a WITH-rebound variable and the built-ins are all left alone. Applies to reads, mutations’ WHERE selectors, EXPLAIN and session/transaction queries alike, since all of them share one prepare step.

  • lock_schema() also rejects a comparison a property’s declared type can never satisfy. Extends the contract above to the declared-type warning family: with CREATE CONSTRAINT ... REQUIRE p.age IS :: INTEGER in force, WHERE p.age > 'forty' is null on every row, so a locked graph raises SchemaError — same sentence the warning carried, plus the same unlock_schema() way out — instead of returning an empty result that reads exactly like “no matching data”. The same mistake behind a bound parameter (WHERE p.age > $cutoff with a string bound) raises too, since the property side is still enforced and the binding’s type is a fact of that call; the verdict is per call, so the identical statement with an integer bound runs. IN over an all-incomparable list and the string predicates (STARTS WITH / ENDS WITH / CONTAINS / =~) on a non-STRING declaration promote on the same rule. A type declared only by define_schema() is never promoted, in any schema state: nothing enforces it at write time, so the finding stays a warning and its plan stays cacheable. A property pair promotes only when both sides are declared. Unlocked graphs are unchanged, and every conservatism the family was built with — numeric-vs-numeric, temporal-vs-string, DURATION/POINT, unrecognised schema type names, unbound parameters, built-in fields, multi-label patterns and WITH-rebound variables — still holds under the lock.

  • to_networkx(), one-arg embeddings() and degrees() now raise on a key collision instead of silently returning less than they were asked for. All three flatten a node collection into one dict (or one NetworkX graph) keyed by a field that is not graph-unique: node ids are unique per type only, and titles are not unique at all. Before, the second node to land on a key overwrote the first with nothing on the wire to say so — to_networkx() merged a Person and a City that both carried id 5 into a single networkx node, the City’s attributes winning and both nodes’ edges rewiring onto the survivor (3 kglite nodes out as 2, with a Person-typed edge pointing at a city); embeddings("summary") over a selection spanning two types dropped one of the two vectors; degrees() returned one row for two same-titled nodes. Each now raises ArgumentError naming the colliding key and the collision-safe way to get the data: give the types disjoint ids (to_networkx() is whole-graph and ignores the selection, so filtering cannot avoid it), call the two-arg embeddings(node_type, text_column) once per type, or use degree_centrality(), whose ResultView carries one row per node. Single-type and collision-free calls are unchanged. This is the same doctrine shortest_path_lengths_from() already applied to cross-type ids; all four surfaces now share one message.

  • vector_search() / search_text() now raise when no selected node type has an embedding store for the column, instead of returning []. The Python surface takes the text column and mints the store name itself, so vector_search('summary_emb', …) looked up summary_emb_emb, matched nothing, skipped every candidate and returned an empty list — the same answer it gives for “nothing is similar”, and indistinguishable from it. A benchmark comparing exact and HNSW search this way had been timing two empty no-ops. The error names the store that was looked up, the node types asked for it, and the column that would have worked: passing a store name gets Did you mean ‘summary’? vector_search() takes the text column, and an ordinary typo gets the usual did-you-mean over the type’s embedded columns. A selection where only some types carry the store is unchanged — those rows are a supported partial result, and the raise fires only when nothing in the selection could have matched. A caller that probed for a store by trying columns should use list_embeddings() or has_vector_index() instead. build_vector_index('Doc', 'summary_emb') gains the same hint on its existing error. Fixed in the engine (kglite::api::algorithms::vector_search), so every binding inherits it; Cypher’s vector_score(), which legitimately takes the store name, is unaffected.

  • vector_search() stops auto-using an HNSW index below 400 vectors (was 256). Below the crossover the index is strictly dominated: the walk costs more than the contiguous scan it replaces and the answer is approximate. Measured in release at cosine/top-k=10 over three agreeing runs, the old 256 floor sat inside that band — a 256-vector store served an approximate answer 1.04-1.15x slower than the exact scan, and a 300-vector store 1.03-1.15x slower. Stores of 256-399 vectors with an index built now return exact results, slightly faster. Nothing above 400 changes: the index still wins by 2.7-9.8x at every size and dimension measured from 400 up to 50k x 384, so this is not a retreat from the index. The threshold is deliberately flat rather than a function of dimension — the crossover on representative (low-rank) embeddings measured ~350 at both 64 and 384 dimensions. build_vector_index() is unaffected; only auto-selection moved.

  • from_networkx() now raises on a node key it cannot store as an id, instead of warning and returning a smaller graph. Ids are stored as integers or strings; anything else — a tuple label, a fractional float, an arbitrary object — was dropped row by row by the bulk loader, so importing nx.grid_2d_graph(3, 3) produced a UserWarning and a graph with zero nodes that the caller then queried as if it were the input. The importer now checks every key up front and raises ArgumentError naming how many of how many keys are unusable, the first offender and its type, and the fix (nx.convert_node_labels_to_integers), before a single row is loaded — and the same check covers the id half of a (node_type, id) tuple key, so the new round-trip path is not a way around it. Nothing about the loader’s own warn-and-drop behaviour changed; other callers see it exactly as before.

  • from_networkx() also raises when one node type’s keys mix integer and string ids, instead of importing more nodes than the graph has. The keys were individually storable; the combination was not. Each node type is bulk-loaded as one DataFrame, so pandas types its whole id column at once — a column holding both becomes object, every id is written as text, and the edge endpoints that kept their original type stop matching: importing a 2-node / 1-edge graph keyed 1 and 'b' returned a KnowledgeGraph(3 nodes, 1 edges), with node 1 present twice, once as the integer and once as the vivified string stub "1". ArgumentError now names the node type, counts each id shape with an example, and gives the relabel recipe. Booleans are their own shape despite bool subclassing int in Python — [True, 2] types as object too, and that mix imported a 2-node graph as 4 — while whole floats count as integers and byte strings as strings, matching how each is stored. The grain is the node type, not the graph: int-keyed Person beside string-keyed City never shares a column, imports exactly right, and keeps working. Applies equally to the id halves of (node_type, id) tuple keys.

Added

  • to_networkx(node_key="type_id") exports a graph whose ids collide across types. Node ids are unique within a type, so the default bare-id node key refuses a multi-type graph in which a Person and a City both carry id 5 — correct, but the refusal was the only outcome available, and two types both numbering from 1 is what add_nodes produces by default. Passing node_key="type_id" keys every networkx node by its (node_type, id) 2-tuple, which is unique by construction: the export runs, edge endpoints are the same tuples, and edge keying is untouched. node_key="id" remains the default and behaves exactly as before, guard included; its collision error now names the new option alongside “give the colliding types disjoint ids”. Any other value raises ArgumentError listing the two valid ones.

  • from_networkx() round-trips a node_key="type_id" export, with no extra argument. The tuple keys the export emits are detected and unwrapped: the id and both endpoint types come straight off the key, so a colliding-id graph now survives to_networkx(node_key="type_id")from_networkx() with its types, titles, node properties, edge properties and parallel edges intact — previously it came back as an empty graph. Detection asks whether a key is a 2-tuple whose first element equals that node’s own node_type attribute, a shape only the export produces, so a foreign tuple-labelled graph (nx.grid_2d_graph coordinates, for instance) cannot be mistaken for one. It is decided per graph, not per node: a graph mixing tuple keys with plain ones raises ArgumentError naming the two shapes rather than importing the half it recognises. Plain int/string-keyed graphs are unaffected.

  • A query warning when a declared property type makes a WHERE comparison vacuous. MATCH (p:Person) WHERE p.age > 'forty' on a graph that declared REQUIRE p.age IS :: INTEGER is null on every row — legal Cypher, an empty result, and no indication that the literal was the problem. It now reports WHERE compares Person.age (declared INTEGER) with a STRING literal 'forty' a cross-type ordering comparison is null in openCypher, so this filters out every row., alongside the four warning families already carried on ResultView.warnings / diagnostics["warnings"] and echoed to stderr. Covers =, <>, <, <=, >, >=, IN over a literal list, and the string predicates STARTS WITH / ENDS WITH / CONTAINS / =~ on a property typed as anything but STRING. The other operand may be a literal, a bound $parameterWHERE p.age > $cutoff with cutoff='forty' names the parameter and the value it carries — or a second typed property: WHERE p.age > p.email names both sides and both types. <> gets its own wording — a cross-type <> is true, so it matches every row that has the property rather than filtering them out. Runtime three-valued logic is unchanged; this only describes it.

    Two sources of type knowledge, in this order: the DDL declaration (REQUIRE n.p IS :: T), which the write path enforces, and define_schema()’s field types, which it does not. Where both cover a property the declaration wins, and every message quotes the declaration it read in the words that declaration was written in — declared INTEGER for the constraint, schema-defined integer for the schema definition — so it is always visible which one the claim rests on. Observed per-type metadata is still not consulted by either. The family stays silent wherever it cannot place a type: numeric against numeric (INTEGER and FLOAT are one comparison family), DATE/LOCAL DATETIME against a string (parsed at runtime, so value-dependent), DURATION/POINT, a schema type name it does not recognise, IN lists with one comparable element, an unbound parameter, a property pair with one untyped side, absent properties (the existing absent-property warning already explains those), built-in fields, multi-label patterns and WITH-rebound variables. It is a warning in every schema state — lock_schema() does not promote it.

  • ResultView.warnings, and a process-global policy for where query warnings are announced. The warnings a query collects were readable only as result.diagnostics["warnings"], which is a TypeError waiting to happen on a view that carries no diagnostics (a head() slice), and their presentation was fixed: a warning: line on stderr, for every embedding host, whether or not stderr was somewhere a human would ever look. ResultView.warnings is the same list as a plain list[str], [] rather than an error when there are no diagnostics at all. kglite.set_query_warning_policy(...) takes "stderr" (the default, and byte-for-byte the previous behaviour), "silent", or "pywarn" — which raises each warning as a UserWarning through the warnings module instead of the stderr line, so the host’s warning filters, logging.captureWarnings(True) and a custom showwarning all apply. "pywarn" is opt-in and will not become the default: under -W error it turns an advisory into a raise out of cypher(). kglite.get_query_warning_policy() reads it back. The policy covers every Python query path — cypher(), Session.cypher/execute, Transaction.cypher, FrozenGraph.cypher, reads and mutations alike — including to_df=True and FORMAT CSV, whose return values have nowhere to carry diagnostics and for which the announcement is the only channel there is (documented on cypher() rather than hacked onto a DataFrame). The structured channel is untouched by all of this: no policy can empty diagnostics["warnings"]. Engine-side this is a two-state sink (kglite::api::cypher::set_query_warning_sink) that decides whether the one warning: emitter prints; the warnings.warn re-emission is wheel-side and post-execution, so no Python callback ever runs inside the executor. The CLI, MCP and Bolt servers keep their own presentation and are unaffected.

  • Docs: measured HNSW recall, and what ef_search actually buys. The semantic-search guide gains a Recall on hard corpora section with a swept recall@10 table (20k/50k/100k x 128/384 dims, clustered and unclustered corpora, two release runs). Structured embeddings — the corpora the index exists for — stay at >=0.99 recall at the default ef_search=64; independent high-dimensional random vectors fall to 0.42 at 100k x 384, and raising ef_search to 256 only reaches ~0.5 while tripling query latency, so the default is unchanged and exact=True (1.6 ms at that size) is named as the answer for that regime. build_vector_index’s ef_search docstring points at it. The sweep is reproducible: python tests/benchmarks/bench_vector_index.py --recall-sweep.

Removed

  • as_dict=True on pagerank(), betweenness_centrality(), degree_centrality() and closeness_centrality(). The dict it built was keyed by bare node id, and node ids are unique per type only — on a graph where a Person and a Company both carry id 5, the second row overwrote the first and the caller got fewer entries than nodes, with no error. There is no key that fixes it without changing the shape, so the parameter is gone rather than silently lossy. Build the mapping from the ResultView (the default return), which carries type alongside id: {r["id"]: r["score"] for r in g.pagerank().to_dicts()} — or key by (r["type"], r["id"]) when the selection spans more than one node type. to_df=True and the default ResultView are unchanged.

Fixed

  • A property named node_type or title no longer shadows the real one in to_networkx() output. The export wrote the two identity attributes first and then flattened every property over the top, so a node carrying a property literally called node_type came out of the export claiming to be a type it is not — and from_networkx reads that attribute back, so the round trip silently retyped the node. Properties are now written first and the identity attributes last, making them authoritative. A graph with no such property is unaffected.

  • Deleting a node now removes its embedding, so a later node cannot inherit the deleted node’s vector. EmbeddingStore is keyed by the engine’s internal node index, and the graph hands a deleted node’s index straight to the next node created — so a vector left behind was not merely orphaned, it was inherited: embed Doc 1/2/3, DETACH DELETE Doc 2, add any new node (Doc 77, or a Note, embedded or not), and vector_search returned that node at score 1.0 for Doc 2’s own query, while list_embeddings() still counted three. The prune happens at the single deletion chokepoint, so Cypher DELETE/DETACH DELETE, purge_provisional() and WAL replay of a recovered deletion are all covered, in every mutable storage mode; .kgl saves written after a delete carry no ghost, and statement- and transaction-rollback restore the vector on the exact slot it vacated (a rolled-back DELETE leaves search results identical). One consequence worth knowing: because an HNSW index addresses vectors by slot, deleting an embedded node drops that store’s index — it is a rebuildable cache, so search falls back to the exact scan until build_vector_index() is called again.

  • compare(target_type=['A', 'B']) silently compared against 'A' alone; it now raises ArgumentError. The comparison traversal is single-target by construction — every method (contains, intersects, distance, text_score, cluster) dispatches on one type name — but the parameter accepts str | list[str] for symmetry with traverse(), and a longer list was truncated to its first element with nothing on the wire to say so: a caller asking to compare against two types got results for one, and the missing type looked like a genuine no-match. A list of two or more now raises ArgumentError naming the count and the workaround (call compare() once per type). A bare string and a single-element list are unchanged.

  • compare() raised a bare ValueError for every failure inside the comparison itself, so except KgError never saw it. The wheel wrapped the traversal’s errors in the built-in exception instead of the typed hierarchy, which meant an unknown method name, a method invoked without its target_type, a missing max_m / property / features, and an unknown resolve mode all escaped a try: ... except kglite.KgError: block that correctly catches every other engine error — including compare()’s own multi-target refusal, which is an ArgumentError. All of them now raise ArgumentError (a KgError subclass carrying .code == "InvalidArgument"), with the original wording kept behind the family’s Invalid argument: prefix. ArgumentError does not inherit from ValueError, so a caller catching ValueError around compare() must catch kglite.ArgumentError (or kglite.KgError) instead.

  • A whole-graph vector_search() / search_text() never used the HNSW index on a graph with more than one node type — the case the docstrings promised it for. Eligibility for the index was proven by type homogeneity of the candidate set: the first node of any other type disqualified the search, so graph.vector_search('summary', q) on a graph holding Articles and Notes fell back to a full exact scan while the identical graph.select('Article').vector_search('summary', q) ran through the index — measured 44x slower at 100k vectors x 384 dimensions. Routing is now decided by the invariant it actually needs, store uniqueness for the embedding column: while one node type carries the column, the index serves any selection, because a node of another type has no vector in the store and is skipped exactly as the exact scan skips it. When two or more types carry the same column the previous rule stands — a selection spanning both is ranked by exact scan, so neither type’s rows can be dropped. The auto-use size gate is now counted in store vectors the selection actually covers rather than raw candidates, so admitting mixed selections cannot send a search that covers one vector in 100 000 through the index. Results are unchanged in every case; only which path computes them, and how fast. The five places that documented the old-but-never-true rule now describe this one.

  • pagerank(connection_types="KNOWS") and its three sibling centralities raised TypeError: Can't extract 'str' to 'Vec' on a bare string. The stub has always documented the parameter as str | list[str], the Cypher twin (CALL pagerank({connection_types: 'KNOWS'})) has always accepted the scalar, and traverse(target_type=...) accepted it too — only the four Python centralities (betweenness_centrality, pagerank, degree_centrality, closeness_centrality) demanded a list, and the error they raised named a Rust type rather than the parameter. All four now take the documented union through the one wheel-side str | list[str] convention that traverse() and compare() also use: a bare string is a one-element filter, a wrong type raises ArgumentError naming the parameter, and an empty list means “no filter” — where it previously meant “match no relationship type at all” and returned an all-zero score for every node.

  • Docs: the 0.16.6 note on the query-warnings channel claimed the warnings reach Bolt. They do not. The engine populates QueryDiagnostics.warnings for every consumer including kglite-bolt-server, but that server forwards nothing of it onto the wire — a Bolt client sees no notifications metadata on SUCCESS, so from a driver’s point of view the channel does not exist. The 0.16.6 entry no longer lists Bolt; ResultView.diagnostics, the MCP warnings: block, the CLI and stderr were and remain accurate. Bolt notifications metadata is a real gap and is on the backlog, not shipped.

  • print(result) rendered every small float as 0.00. The ResultView table formatted floats with two fixed decimal places, so a PageRank score — which sums to 1 across the graph, and is therefore below 0.01 on any graph past a hundred nodes — printed as a column of identical 0.00 cells (negatives as -0.00), indistinguishable from a genuine zero and from each other. Finite non-zero floats under 0.01 in magnitude now print with three significant digits (3.00e-4); a true zero, the band boundary 0.01, NaN (NULL), the infinities and every larger float are spelled exactly as before. to_dicts(), to_df=True and every other data path always carried full precision — only the printed table changed. The CLI’s own table is unaffected.

  • set_embeddings()/add_embeddings() rejected the very column name a graph was built with. Their source-column guard hardcoded id/title/ type and then probed live node properties — but add_nodes(df, "Person", "npdid", "name") hoists the title column out of the property map and registers name as the type’s title alias, so set_embeddings("Person", "name", …) raised Source column 'name' not found on any 'Person' node, and no spelling the caller knew about worked. The guard now resolves the column the same way every read path does — per-type id/title alias, then a stored property, then the structural alias (name, type, node_type, label) — so an aliased type’s own column names are accepted. Genuinely unknown columns and the 'summary_emb' store-name typo are still rejected with the same error. Fixed in the engine (kglite::api::embeddings::resolve_source_column, new), so the C ABI and the Java wrapper inherit it. The store is still keyed by the spelling passed — 'name' writes name_emb, not title_emb — because canonicalising the key would strand stores already written under the raw spelling by add_nodes<col>_emb ingest and by every existing .kgl.

  • embed_texts() silently embedded nothing for an identity column. It read the source column with a raw property lookup, which excludes id/title by contract, so embed_texts('Person', 'name') on a title_field='name' type — and even embed_texts('Person', 'title') — returned {'embedded': 0, 'skipped': N} while looking like it had run. It now resolves the column through the same resolve_source_column predicate the ingest guard uses and reads through NodeView::resolved_field, so the two halves of the feature accept exactly the same columns (pinned by a parity test). A column that resolves to nothing now raises the same ValueError set_embeddings() raises, before the embedder is loaded, instead of reporting a silent zero; a node type with no nodes stays the {'embedded': 0} no-op it was.

  • embed_texts() on a node type the graph has never seen reported {'embedded': 0} while set_embeddings() on the same type raised Node type 'X' does not exist in the graph — so a typo’d type name was a silent no-op on one half of the embedding surface and an error on the other. It now raises that same error, before the model is loaded. An existing type with no matching rows is unchanged and still returns {'embedded': 0}.

  • A property written with values of more than one type recorded the first value’s type as the type of all of them. Every write that stores one value per node — the fluent store_as targets, the calculation writers — typed the whole batch from whichever value came first, so writing [1, 'two', 3] left describe() reporting type="Int64" beside the very strings that contradicted it, and a later write of the honest type reported a spurious “Type mismatch for property …” against the recorded lie. The batch is now classified across all of its values: a heterogeneous batch records "mixed", the name the columnar store and write-ahead-log replay already use for a column whose values disagree and which no type-knowledge source reads as a claim (so it never makes a query warn). Homogeneous batches — and the numeric widening a column performs, Int64 beside Float64 being a Float64 — now record exactly what loading the same values through add_nodes() records, including the Boolean/DateTime/List/Map types this path used to call "Unknown". Nulls do not participate; an all-null batch still records "Unknown".

[0.16.6] - 2026-08-22

Fixed

  • Every Python query paid a full second parse of its own text. The public kglite::api::cypher::parse_cypher re-export pointed at the raw parser rather than the process-wide parse cache that session::execute uses, so the pre-parse each binding runs to classify a statement as read-or-mutation — KnowledgeGraph.cypher, Session.cypher, Transaction.cypher, the frozen view, and the fluent mutation path — re-parsed from scratch on every call while the cache only ever served the parse inside prepare. The comment at the Python call site had asserted the opposite (“the parser is cached so this second parse is a cache hit”) since the cache landed in 0.9.53. Profiling a repeated parameterised point query attributed 25.5% of the whole call to that redundant parse. Now a hash lookup plus an AST clone. Measured on macOS arm64 (release, min of 1000-1500 rounds, two agreeing runs, against the published 0.13.2 and 0.16.5 wheels in isolated venvs): MATCH (n:Item) WHERE n.code = $code RETURN n.bucket, count(*) on 100k indexed nodes 5.375 → 4.041 µs (−24.8%, and −12.6% against 0.13.2); MATCH (n:Item {code: $code}) RETURN count(*) 3.500 → 2.458 µs (−29.8%); the same shape with ORDER BY ... LIMIT 5 4.667 → 3.000 µs (−35.7%); MATCH (a:Broad)-[:LINK]->(b:Anchor {id: $anchor}) WHERE a.code IN $codes 7.084 → 5.333 µs (−24.7%); RETURN 1 1.000 → 0.750 µs (−25.0%). Every statement issued through the wheel is affected, parameterised or not — a parameterised query cannot use the plan cache, so this was pure repeat work on the hottest small-query path. Bindings that route through parse_with_mutation_check (MCP, Bolt, CLI) were already on the cached parser and are unchanged.

  • The unbounded-row safety ceiling did not bound pattern expansion, so a runaway variable-length query spent gigabytes before the guard could refuse it — and a deep enough one never reached the guard at all. The ceiling that applies when no max_rows is set was enforced by a single check on the finished match vector, which meant the producer ran unchecked: measured on a 10 000-node scale-free graph with 50 seeds, MATCH (p)-[:KNOWS*1..4]-(f) RETURN count(*) errored only after 26.9 s and 9.5 GB, and *1..5 was still climbing when a 300 s deadline cut it off. The expansion loops now test their in-flight buffers against the same ceiling and raise the same quantified error: the two queries above now stop at 16.0 s / 7.9 GB and 8.2 s / 4.2 GB, and every deeper k — which previously had no terminating answer at all — errors in about the same time. The message names which expansion overflowed, so MATCH, OPTIONAL MATCH, EXISTS { ... } and COUNT { ... } are told apart. An explicit max_rows is unaffected: it already bounds the producer, and it remains the way to choose a larger ceiling.

  • COUNT { ... } held every match it counted, without a bound. Counting is not streaming here — the subquery materializes the whole match vector and then counts it — so the same *1..12 pattern that overflows a MATCH overflowed a COUNT too, at 26.4 million matches and 10.6 GB before the post-hoc check fired. It is now charged as it expands. The ceiling refuses no answer that was previously reachable: the count itself was already checked against the same 10 000 000 rows, so a larger result was an error before this change as well.

  • The fused OPTIONAL MATCH ... count() plan ignored the row ceiling entirely. Fusing the count away removed the row materialization but not the match materialization, and no guard replaced the one the unfused plan gets per row — so the fused plan returned a 26 394 496-row answer, holding ~10 GB for 64 s, where the same query with the optimiser off is refused. Both plans now agree, which is the invariant the budget suite already asserts for every other clause.

  • An unanchored shortestPath sized its work-list at sources x targets before checking anything. Two 10 000-node labels asked for a 100 000 000-entry allocation up front — gigabytes, or an abort — with no row check in between. The pair set is the materialized row set, so it is now charged as one.

  • shortest_path_length(weight_property=...) silently ignored the filters it appeared to accept, and three members of the family could not express a type-restricted traversal at all. The weighted branch built a default options struct and dropped connection_types / via_types on the floor, so a call that named a filter got an unfiltered answer with no error and no warning. shortest_path_lengths_batch and are_connected took no filters at all, which meant a Person-to-Person question was routinely answered through a City — the batch API’s only spelling of “distance” was “distance through anything”. All of them now take, and honour, the same arguments. The source_type / target_type / node_type arguments remain what they have always been — an ID namespace, saying which type to look the endpoint id up in and never restricting the traversal — and the stubs, guide and describe() now say so everywhere rather than leaving it to be discovered.

  • On a disk-mode graph, saving silently dropped every string SET whose new value was a different byte length from the old one. After a reload the property read back as its pre-SET string, or as an empty string (not null) when the SET was what created the column. It hit the node title, any previously-unseen property key, and any existing string column alike, on the first save as well as on later ones; a same-length replacement, a fixed-width property, REMOVE, SET to null, secondary labels and edge properties were never affected, which is why the class stayed invisible. TypedColumn::set cannot shift a string column’s offset array in place — that would move the next row’s start — so it parks the replacement in a relocated overlay; the packed-sidecar writer folded that overlay back before writing, but the columns.bin writer that disk-mode saves use read the raw offset and data buffers straight through and never consulted it. Both writers now fold.

  • enable_disk_mode() without a path wrote the converted structures to the system temp directory silently. The CSR and the edge-property blob went to a scratch directory under std::env::temp_dir() that nothing named and that is deleted when the graph drops — so a caller who assumed the conversion had persisted something found nothing afterwards, and a conversion larger than a small (or RAM-backed) /tmp failed there rather than on the filesystem the data was on, with no hint of where the bytes had gone. The pathless form is kept — a throwaway, process-scoped conversion is a real use — but it now emits a UserWarning naming the location, saying the data does not survive the process, and pointing at enable_disk_mode(path=...), which converts into the directory you name and publishes it.

  • enable_disk_mode()’s refusals were classified and worded as something else. A durable graph cannot convert (disk mode keeps no logical log, and the conversion must not unwrap the capture layer) but said so as “enable_disk_mode not supported while wrapped in RecordingGraph” — naming a type no caller can see, for the shape every kglite.open(path) user meets, since a log is attached by default. Converting an already-disk graph reported “Already in disk mode” as a file I/O error. Both are now ValueErrors stating the condition and the way forward: durable=False / kglite.load(path) (or KnowledgeGraph(storage="disk", path=...)) for the first, save(path) for the second.

  • enable_disk_mode()’s documented memory claim described an outcome it cannot produce. The docstring promised the call “reduces memory usage to ~10% of the in-memory graph”; the mechanism that once did that (an inline→columnar conversion) has been unconditional since 0.16.0, and the conversion that remains adds the on-disk edge structures on top of a graph that is already resident, so this process’s memory goes up rather than down and stays up — the allocator keeps the freed pages. The ~10% is real, but it belongs to the saved directory reopened in a fresh process (measured 56 MB against 492 MB for the same graph), because the reopened graph pages its edges in on demand instead of building them. The docstring, the stub and docs/python/core-concepts.md now say which of those a caller gets, name kglite.trim_memory() for returning the conversion’s freed pages, and point at KnowledgeGraph(storage="disk", path=...) for building at the small footprint from the start. The behaviour of the call is unchanged.

  • set_memory_limit() listed enable_disk_mode() as one of its spill checkpoints. It is not one: the consolidation pass that spills is skipped during the disk conversion, so a caller who set a limit and converted got no spill at that point. The documentation now describes the checkpoints that exist (each mutating statement, plus the pass save() and vacuum() run) and states that the limit governs property columns only.

  • A comma-separated MATCH with RETURN DISTINCT <one variable> could drop rows. The DISTINCT pushdown deduplicated the first pattern’s matches by the returned variable before the remaining patterns joined, which keeps one arbitrary representative of every other variable — and the join (or the clause’s WHERE, which is not fused for a multi-pattern MATCH) could reject exactly that representative while the discarded one would have survived. Where a1 and a2 both reach f but only a2 has the second pattern’s relationship, MATCH (a)-[:R]->(f), (a)-[:S]->(g) RETURN DISTINCT f.id answered nothing, while the same query without DISTINCT answers one row. The pushdown now requires a single-pattern MATCH.

  • Breaking (semantics fix): a variable-length segment could hide its relationships from its own clause’s uniqueness check. When the optimizer cleared trail tracking on a [:T*min..max] segment, the relationships the segment walked became invisible to openCypher’s rule that one relationship occurs at most once per clause — so a sibling edge in the same clause was free to re-bind one of them. On two nodes joined in both directions, MATCH (a {id: 1})-[:R*1..2]->(x)-[:R]->(c) RETURN DISTINCT c.id answered [1, 2] where trail semantics give [1]. The optimization now applies only when no sibling edge in the clause can bind a relationship the segment binds — either it is the clause’s only edge, or every edge is typed and this one’s types are disjoint from all the others’.

  • describe()’s algorithm topics advertised signatures that do not exist. The fluent API reference an agent reads offered shortest_path(..., connection_type=None, directed=True) — both keywords raise TypeError, and the advertised directed=True default is the opposite of the real (undirected) behaviour — plus a shortest_path_length(..., connection_type='ROAD') example that cannot run, and singular connection_type= on pagerank / betweenness_centrality / louvain_communities / connected_components, which take connection_types (plural, a list). The same drift ran through the rest of the fluent reference: statistics(properties=...), update(..., conflict_handling=...), valid_at(from_col=...), degrees(connection_type=), a vector() method that does not exist (it is vector_search()), a set_spatial() example passing three positional arguments to a method that takes one, and a within_bounds() signature listing its bounds in the wrong order. Every advertised signature and example now matches the installed API, the shortest-path family’s undirected semantics are stated explicitly (with Cypher’s shortestPath() named as the directed route), and tests/test_introspection_signature_truth.py checks each one against inspect.signature so a future description cannot drift from the code. docs/python/guides/graph-algorithms.md claimed all path methods accept connection_types / via_types / timeout_ms; it now carries the per-method table kglite/__init__.pyi already documented — shortest_path_length, shortest_path_lengths_batch and are_connected take none of them.

  • Breaking (semantics fix): the optimized variable-length path returned distance reachability instead of Cypher’s trail reachability. The planner marked a [:T*min..max] segment for a set-based BFS whenever the query’s first RETURN/WITH collapsed row multiplicity. That BFS visits each node once and reports what a shortest path reaches, which is a different relation from the one Cypher asks for, and the two come apart on any graph with a cycle. Three consequences, all silent: the source node was dropped from its own answer when a closed trail returned to it ((a {id: 1})-[:R*1..3]->(b) on a triangle answered [2, 3], not [1, 2, 3]); a minimum hop count of 2 or more was answered from the shortest-distance set, returning the empty set or a heavy undercount (undirected *2..2 on the same triangle answered nothing where both peers are reachable, and *3..3 on a 3 000-node ring reported 12 of 16 reachable nodes); and one clause’s DISTINCT licensed every other clause in the query, so MATCH …*1..2… WITH DISTINCT b MATCH (b)-[…*1..2]->(c) RETURN c.id returned 2 rows where the graph has 3. The optimization now engages only where it is provably equivalent — minimum hop count at most 1, dedup-safety proved against that clause’s own consumer, and the source emitted when a closed trail reaches it. Affected queries return more (or differently many) rows than they did, and some become slower, because they were previously answering a different question.

  • WHERE n.id IN [...] bound the same node once per duplicate list entry. The index-anchored fast path is driven by the list — one index probe per element — so count(n) over [1, 1, 2] answered 3 where the scan path, every other anchor, and Neo4j answer 2, and the row form returned the node twice. The same defect sat in the sibling arm for IN on a non-id property carrying a per-type index. Both now bind each node once, keeping the first occurrence so the anchor’s list ordering is unchanged. Candidates are deduplicated rather than the list, because coercion-equal spellings ([1, 1.0]) are two elements resolving to one node. UNWIND [1, 1, 2] AS x MATCH (n {id: x}) still yields three rows — there the duplicate is a driving row, not a duplicated binding.

  • graphgen() died with a bare ModuleNotFoundError when pandas was missing, and its docstring said “no extra deps”. The default out=None path loads the generated CSVs through DataFrames, so it needs pandas — but the docstring advertised no dependencies four paragraphs before admitting one, and the failure was the raw import error with no install hint. It now raises ImportError naming the package, the install command, and the out=DIR streaming path, which is pure Rust and genuinely needs nothing extra. Both docstrings say which path needs what.

  • export_string() required a format its file-writing twin infers. export('graph.json') works; export_string() raised TypeError for a missing argument. format now defaults to 'json' — the asymmetry with export()’s extension-inferred graphml fallback is deliberate (a string return has no extension to read) and is documented on both. Asking export_string() for 'csv' — a format that writes two files — now explains that and points at export(path, format='csv') instead of listing formats.

  • explain()’s docstring named operators from several versions ago, and its empty message taught nothing. The example output advertised TYPE_FILTER Prospect (...) -> TRAVERSE HAS_ESTIMATE (...)TYPE_FILTER has been SELECT for many releases, and the type names came from a downstream graph. Worse, the plan lives on the object a fluent method returns, so calling explain() on the graph itself answered No query operations recorded with no hint that the chain result is where to ask; an external evaluation read that as evidence the method was dead. The docstring (and its .pyi twin) now shows the real operator names and the g.select(...).where(...).explain() call site, the empty message says where the plan lives, and both point Cypher users at the EXPLAIN / PROFILE prefixes (result.profile carries the PROFILE statistics).

  • vector_search(), search_text() and embeddings() returned [] when no selection was active. With embeddings stored and no select() in front of it, g.vector_search('summary', q) answered “nothing is similar” to a question it had never been asked, and search_text() did it after loading the embedder and paying for the query embedding. All three now search the whole graph when the selection was never narrowed — the same never-selected rule get_nodes() has always followed, now written once as CurrentSelection::never_selected() and shared by both. A selection a query emptied (a filter that matched nothing) still returns nothing, because that is the answer to a question the caller did ask. A whole-graph search over a single embedded type still rides the HNSW index; one spanning types whose stores disagree on dimension raises instead of silently ranking part of the graph.

  • The embedding store name was undiscoverable from either surface that needed it. Cypher’s vector_score() takes the store name ('summary_emb') and its error for anything else was a dead end (no embedding 'summary' found for node type 'Article'), while the Python API takes the source column ('summary') and rejects the store name — each surface pushing the caller at what the other refuses. vector_score() now names the store that does exist and points at text_score(n, 'summary', …), which takes the column; list_embeddings() reports a store_name beside text_column, so the two spellings are visible in one place. Unknown columns with no matching store keep the plain message. The .pyi also documents the metric key list_embeddings() has always returned.

  • An unbound $parameter in a WHERE clause raised on projection shapes but silently returned zero on fused aggregate shapes. MATCH (v:Vessel) WHERE v.flag = $flag RETURN count(v) with no flag in params answered 0 and no error, while the same predicate projected as a row raised Missing parameter: $flag — so an aggregate turned the caller’s own missing binding into a confident “the graph has none of those”. The fused execution paths (scan-aggregate, top-K, HAVING, WITH WHERE) drop a row whose predicate cannot be evaluated instead of failing the query, which is what an unbound OPTIONAL MATCH binding relies on, and the missing-parameter error was being swallowed along with it. It now propagates from every fused path exactly as the regex-compile class already does; both are recognised beside where they are minted, and every other evaluation error keeps the deliberate swallow byte-for-byte.

  • An unbound $parameter inside an inline property map matched nothing instead of raising. MATCH (v:Vessel {flag: $flag}) with no flag in params returned an empty result and no error, so a caller read “this graph has no NO-flagged vessels” off their own missing binding — while the exact same predicate written WHERE v.flag = $flag raised Missing parameter: $flag. The inline map is the spelling describe()’s own examples teach, and the matcher that evaluates it answers bool, so an absent parameter could only read as “no candidate equals it”. Presence is now checked once before planning, alongside the dynamic-label binding, for every read pattern — MATCH, OPTIONAL MATCH, EXISTS {}, COUNT {}, CALL {} and UNION branches — and raises the same message the WHERE spelling always did. CREATE / MERGE property maps already raised from expression evaluation and are unchanged. Including on plan-cache hits: a query carrying $flag with no params looks parameterless to the cache, so the check runs on a path that leaves nothing cached and the second run of the same text raises just like the first.

  • Query warnings never reached the surfaces that needed them most. The engine has always detected an unknown node label, relationship type or property in a MATCH and computed a “did you mean?” hint — and then wrote it to stderr only. ResultView.diagnostics was never populated by the engine (the wheel re-derived the warnings on its own read path; every other binding got an empty list), and the MCP server — the surface agents actually query — showed nothing at all, so a typo’d label returned a confident “No results.” The engine now populates QueryDiagnostics.warnings for every execution: reads, mutations, EXPLAIN, session and transaction queries, and on plan-cache hits, where the warnings ride on the cached entry rather than being lost to the early return. The MCP server appends a trailing warnings: block to cypher_query responses (direct tool, manifest templates, and the no-rows write acknowledgement alike), execution-time procedure-scope advisories join the same field, and graph_overview’s unknown-type error now suggests a near-miss type name. One computation, every surface; the wheel’s duplicate derivation is gone. ResultView.diagnostics is consequently a dict rather than None on mutation, EXPLAIN and transaction results.

  • CALL ready_set(...) with an unknown relationship type reported every node as ready. The procedure answers “are all of this node’s dependencies done?” with a universal quantifier over its outgoing edges of the given type, and a type the graph does not have makes every dependency list empty, every quantifier vacuously true, and the frontier everything — a typo widened the answer instead of narrowing it, in the one direction that causes work to be dispatched. ready_set / dependency_frontier now refuse an unknown relationship type with a did-you-mean and the valid set. Every other procedure that takes relationship: degrades toward an empty answer instead (uniform scores, singleton components, coefficient 0.0), so those warn and return unchanged results; an unknown node_type: warns everywhere, and a locked schema promotes both warnings to errors.

  • add_nodes(managed_reload=True)’s skip report had a different shape from every other report. When the call declined to write a runtime-layer type it returned a dict carrying only nodes_created, nodes_updated, skipped_runtime_layer, node_type and message — so a caller checking the load the documented way, report["has_errors"], raised KeyError on exactly the path where it most needed a readable answer. The skip report now carries the full add_nodes shape (operation, timestamp, the three counts, processing_time_ms, has_errors=False) plus the skip keys.

  • Transaction.cypher’s docstring was attached to is_read_only. A misplaced doc comment left Transaction.cypher.__doc__ empty at runtime and gave the is_read_only property the query method’s prose.

  • save() and sync() reported a failed write as a bare OSError. The typed-error taxonomy is what lets an application tell a full disk from a bad argument, and the whole write path sat outside it: every I/O failure in save(), sync() and to_bytes() — including the checkpoint’s log flush and log truncation — raised the builtin OSError, indistinguishable from any unrelated OS error the call stack could produce and carrying no .code. They now raise kglite.FileIoError with .code == "FileIo", the same class load() already used for the same fault. Breaking for a caller that wrapped save() in except OSErrorkglite.FileIoError descends from kglite.KgError, not from OSError; catch kglite.KgError (or kglite.FileIoError) instead. A save refused before it touched the path is still a ValueError.

  • An out-of-space open() claimed the graph was locked by another process. The advice that names the read-only route out (use kglite.load(path)…) was appended to every failure the writer-lease acquisition could produce, not only to contention — so a full disk or an unwritable directory sent the operator hunting for a writer that does not exist, down a route that fails the same way. The advice is now gated on an actual contention refusal; any other I/O failure surfaces as the plain typed error.

  • A save killed part-way through leaked a full-size copy of the graph, forever. save() writes a sibling <name>.tmp.<pid>.<n> and renames it into place, and removed it on every error path it could see — but not on the one that matters: a fault-injection run left a full-size temp behind on 22 of 30 SIGKILLs mid-save, and nothing ever deleted one, so a crash-looping writer filled the volume with copies of its own graph. Taking the writer lease (kglite.open, and the CLI/MCP/Bolt servers through the same seam) now reaps the temps for that graph whose owning process is gone. A temp belonging to a running process is never touched, and a file that merely shares the prefix is not a temp — the reaper’s default is always to keep.

  • A WAL corrupted in its middle was reported as a routine crash tail. Recovery correctly stops at the first bad frame and discards everything after it, but the stderr diagnostic asserted the harmless reading unconditionally (“expected after a crash mid-commit”), so committed work being thrown away because a middle frame was damaged read as a normal restart. The diagnostic now distinguishes the two: when frames after the corrupt one still decode, it says so, reports how many and how many bytes are discarded, and names it as mid-file damage rather than a crash tail. A genuine torn tail keeps the original wording.

  • The bulk loader silently dropped every integer id outside the u32 range. add_nodes auto-detects an integer unique_id_field as the compact 32-bit key type, so a negative id, a snowflake id, a hash, or anything from 2**32 up parsed to nothing and its row was dropped — a short load reported only as a UserWarning and a nodes_skipped count, and the advice in the skip message (column_types={'id': 'string'}) silently changed the key type to text rather than fixing the range. An integer id column that does not fit u32 is now stored as a full 64-bit key, which indexes, matches, saves and loads exactly as before — column_types={'id': 'int64'} was always the working shape and is now what the default does. Auto-minted ids stay 32-bit and ignore out-of-range values, so the two id spaces cannot collide. The skip message that remains no longer recommends a type change that loses the ids.

  • An object-dtype column stringified without a word. The typed columnar store has no heterogeneous variant, so [10, 20, 'N/A'] was stored as ['10', '20', 'N/A'] and every later comparison, sort and aggregate on that column silently became a text one. The coercion is unchanged — it is the design — but the loader now warns, naming the column, what pandas inferred it to hold, the first row that gets rewritten, and the column_types= override that picks a real type. A column that already holds only text is unaffected.

  • A crash between a storage="disk" open() and the first save() left a directory every later open() refused. Disk-mode creation materialised the path — the writer lock and seg_000/*.bin — but published no generation until a save, so a process that died in that window left a directory with no CURRENT pointer, which every subsequent open rejected as FileFormatError: missing disk_graph_meta.json. The application could not restart without an operator deleting the path by hand; a fault-injection run hit it on 28 of 50 killed disk-mode processes. Creation now publishes an empty generation up front, so load-or-create holds for storage="disk" too: a path that was created but never saved reopens as the empty graph it is, and is writable. Creating over a directory that already holds a graph is unaffected — no pointer moves until that graph’s own save().

    Two consequences worth knowing. A blueprint built with save=False into a disk path now leaves a directory that opens (empty) rather than one that raises. And load_ntriples’ disk build, which is contractually reloadable with no intervening save(), retires the create-time pointer as the last step of publishing itself, so the build is what a reload sees.

  • load_ntriples into a disk graph holding a published generation corrupted that generation in place. A disk graph commits by publishing an immutable generation, and save() leaves the handle writing inside that snapshot — so a build run afterwards (or on a graph opened from a saved directory) rewrote the published generation instead of producing a new one: the rebuild’s interner.json landed beside the snapshot’s own interner.bin.zst, which shadows it, and the reload failed with invalid type_indices.bin: directory contains an unresolved type key. The snapshot was already overwritten by then, so nothing recovered the directory. The build now stages in a mutation workspace and is published as a new generation, leaving previously published snapshots byte-for-byte intact. The same staging fixes a build that followed any other mutation, which finalised into the workspace and vanished with it — load_ntriples reported success and the reload returned the pre-build graph. A build on a freshly created directory still finalises in place, so the documented reload-without-save() contract is unchanged.

  • An explicit save() after an ntriples disk build dropped every property column. The build’s stores are mmap-backed, which the unified column writer cannot plan, so the save rewrote each type as a columns.zst sidecar. The sidecar carried the data, but the reload rebuilds type_schemas from node_type_metadata — which this build path never writes — and the packed loader skipped every column whose name the schema did not know. The graph reloaded with its nodes, ids and titles intact and every property null. A packed column store is self-describing, so a column the caller’s schema does not name now extends that schema rather than being discarded.

  • DISTINCT used as a bare variable name inside an aggregate panicked the engine. This dialect leaves DISTINCT and COUNT unreserved in name position, so MATCH (DISTINCT:Person) binds a variable — but the aggregate that read it back, count(DISTINCT), lexed the word as the dedup flag and left the call with zero arguments. Once the DISTINCT-dedup fix stopped the fused path from absorbing that shape, it reached a count arm that indexed args[0] blind and aborted the host process. Inside a call, DISTINCT is now the flag only when an argument follows it: count(DISTINCT) is a read of the variable, count(DISTINCT x) is the flag, and count(DISTINCT DISTINCT) is the flag applied to that variable. count(DISTINCT x), count(DISTINCT *) and count(*) are unchanged.

    The same argument-less shape reached other aggregates by a shorter route — writing the call with no arguments at all. count() answered the row count, min() answered true, and collect() and a bare RETURN count() aborted the process. A zero-argument aggregate is now a syntax error naming the function (count() points at count(*)), and every aggregate evaluation arm errors cleanly rather than indexing empty arguments.

  • A corrupted .kgl file could load silently, with different data. Nothing but Postcard’s structural decode stood between a damaged payload and a graph the caller then trusted: over a third of single-bit corruptions loaded successfully and wrong (34.7% and 38.3% on two independent sweeps) — one flipped bit renamed 1135 nodes and load() reported success, contradicting the documented promise that a corrupt file raises a typed FileFormatError. Every section of the container — topology, each node type’s columns, embeddings, timeseries, secondary labels, vector index — now carries two independent integrity checks: a CRC32 digest of its compressed bytes recorded in the file’s metadata and verified before the bytes reach a decoder, and a zstd frame content checksum the decoder verifies on its own. A damaged section raises FileFormatError naming the section rather than loading. Single-bit corruption of the section payload now either loads byte-identical content or raises — never a successful load of different data.

    Both layers are additive and neither is a format bump: a .kgl written by an older build carries no digests and loads exactly as before, and a file written by this version loads on older binaries (verified against the published 0.16.5 wheel in both directions). Disk-mode graph directories are unaffected; their sidecars have their own integrity handling. Measured cost (release profile, min across three agreeing runs): ~+11-15% on the tracked save cell — the linear CRC pass over compressed bytes — inside the 20% gate; loads and every query cell are unmoved.

  • A write that failed mid-append was committed by a later save(). On a durable graph, a statement whose write-ahead frame could not be written (a full disk, an I/O fault) was reported to the caller as a FileIoError — and then left applied in memory, with its captured ops already drained, and nothing marking the graph. The next save() serialized whatever was in memory, so a write the caller had been told did not happen reached disk minutes later, indistinguishable from the ones that succeeded. A failed append now latches the graph handle: further logged writes, save() and sync() all raise, naming the reopen that recovers the last checkpoint plus every frame that did reach the log. A failed append also no longer consumes its log-sequence number, so the checkpoint stamp can never claim a commit that was never written. Graphs opened without a log (durable='off', the default) are unaffected, and the engine’s Session already had both properties.

  • Chaining a configuration call dropped the write-ahead log, silently. set_instructions, define_schema, clear_schema, lock_schema, unlock_schema, set_schema_version and unique_values(store_as=…) returned a copy of the graph purely so the call could be chained. The copy kept the save path but could not keep the log (an OS file handle is not shareable), so the documented g = g.set_instructions(…) form handed back a handle whose every later write was applied to a graph the log did not describe and no checkpoint would hold: no frame appended, the write absent from the original handle, and absent again after a crash and reopen — an acknowledged commit, gone. These calls now return the same graph object, so there is no second handle to lose the log in.

  • Writing through a fluent view of a durable graph reached neither the log nor the graph. Fluent methods (select, where, traverse, expand, the set operations, date) return a derived handle that must share the storage and cannot share the log; on the first write it also forks away from the original. A write taken there was therefore lost twice over, in silence. Such a handle now refuses every logged write, save() and sync(), naming the handle that owns the log and cypher() as the route that expresses the same writes and is logged. This fences the selection-based fluent mutations (add_properties, create_connections, set_property, and the store_as= forms of calculate / count / collect_children / unique_values) off durable graphs, since a selection is itself a derived handle. copy() and to_subgraph() build independent graphs rather than views and are unaffected; graphs opened without a log are unchanged.

  • Four fluent store_as= writes never reached the write-ahead log or the change stream. unique_values, collect_children, calculate and count write node properties when given store_as=, but none of them closed the commit boundary afterwards: on a durable graph the property landed in memory and no frame was appended, and on a CDC-enabled graph no event was published. All four now commit like every other logged mutation.

  • kglite.load() and kglite.open_session() served stale data in silence. Both read the .kgl checkpoint alone, so on a path whose write-ahead sidecar holds newer commits — a durable writer that crashed, or one still running — they returned a graph missing those commits with no signal of any kind. They now emit a UserWarning naming the sidecar, how many commits it holds beyond the checkpoint, and the kglite.open(path, durable=…) call that replays them. This is a warning rather than an error on purpose: reading a checkpoint while another process writes the path durably is what these entry points are for, and there a sidecar ahead of the checkpoint is the steady state. The hazardous direction — saving such a graph back over the path — remains a refusal, and a sidecar the checkpoint already contains (ordinary crash residue) still loads silently.

  • CREATE stored null-valued relationship properties; node CREATE did not. CREATE (a)-[:E {x: null, y: 1}]->(b) left x on the edge, so keys(r) reported a property that the identical literal on a node — CREATE (:N {x: null, y: 1}) — never creates. The same null also registered a phantom "Null"-typed x in the connection type’s schema metadata, which schema_text(), connection_types() and describe() then advertised for the life of the graph. A relationship property that evaluates to null is now simply not written, on every route that reaches it (CREATE, FOREACH CREATE, MERGE’s create branch). Relationship constraints are unaffected: a NOT NULL declaration already refused a null value and still does.

  • SET r.p = null and SET r += {p: null} left the property present with a null value, while the node spellings of both removed it. openCypher treats a null assignment as a removal, so the two entity kinds now agree: the key is gone from keys(r) and properties(r), and the write still reports as a property set (REMOVE r.p remains the spelling that reports a removal).

  • size() and length() on a string counted UTF-8 bytes, not characters. size('Tromsø') answered 7 and size('日本語') answered 9, which disagreed with substring(), left() and right() — those have always been character-indexed — so the idiomatic substring(s, size(s) - 1) returned an empty string for any non-ASCII s instead of its last character. Both functions now count characters on every path. A string whose text looks like a JSON list ('[1,2,3]') still reports its element count, unchanged: that coercion is shared with UNWIND, list indexing, head/last/reverse and IN, and is deliberately left for a single coordinated change.

  • toString(null) returned the four-character string 'null'. An absent value became indistinguishable from a present one, and — because the result was a non-null string — it survived coalesce(toString(x), 'default'), the very call that exists to substitute a default for a missing value. toString(null) is now null; non-null arguments are unchanged.

  • split() with an empty delimiter returned phantom empty elements. split('a', '') answered ['', 'a', ''] and split('abc', '') answered ['', 'a', 'b', 'c', ''] — an artefact of the underlying Rust str::split rather than a Cypher answer. An empty delimiter now splits into characters (split('abc', '')['a', 'b', 'c']), and an empty original stays [''] for any delimiter. openCypher does not define the empty-delimiter case, so this is recorded as a documented dialect divergence in CYPHER.md.

  • A query that set no max_rows could materialize an unbounded intermediate row set and get the host process killed. max_rows is opt-in and unset by default on every surface, which left every cardinality guard in the executor inert on the path almost all callers take: a nested UNWIND cross-product such as UNWIND range(1,1000) AS a UNWIND range(1,1000) AS b UNWIND range(1,1000) AS c RETURN count(*) grew to hundreds of gigabytes of intermediate rows until the operating system killed the process — and kglite is embedded, so that process is the caller’s application. The executor now enforces an absolute row backstop when max_rows is unset (10,000,000 rows or retained collection items, twice the largest row set any query in this repository’s own suites materializes by design). Crossing it raises a quantified error naming the operator, the count, the ceiling, and the escape hatch: an explicit max_rows — per query, or per graph/session via set_default_max_rows() — still governs on its own, above or below the backstop. Whole-graph scan work is deliberately exempt, so a fused count(*) over a 124M-node disk graph is unaffected. The query above now fails in seconds with a bounded footprint instead of taking the process down.

  • An invalid or unsupported regular expression in a WHERE clause silently returned zero rows on the fused execution paths. The unfused path has always raised Invalid regular expression '…'; the fused node-scan aggregate, the fused top-K scan, WITH WHERE and HAVING swallowed the compile failure along with the predicate errors they drop by design (a row whose predicate cannot be evaluated does not match), so MATCH (n:S) WHERE n.name =~ '[' RETURN count(*) answered 0 while the same filter with RETURN n.name raised. Only the compile failure now propagates — an unbound OPTIONAL MATCH binding still drops its row. This also turns lookaround and backreference patterns, which are valid in Neo4j but unsupported by the underlying regex engine, from silent empty results into a clean error naming the unsupported feature.

  • DISTINCT aggregates could return different answers depending on the internal aggregation path. sum, avg, collect and mode each carried a private idea of what makes two values distinct, none of which was the one RETURN DISTINCT, WITH DISTINCT and count(DISTINCT …) have always used:

    • the materialized executor deduplicated numeric aggregates on the f64 bit pattern, so sum(DISTINCT …) over [1, 1.0, 2] folded the integer and the float into one value and answered 3 where the streaming path answered 4.0 — and split 0.0 from -0.0, which every other DISTINCT in the engine treats as one value;

    • collect(DISTINCT …) deduplicated on the compact string form, which is "1" for both 1 and '1' — so one of the two was dropped from the list, in a row whose own count(DISTINCT …) counted both;

    • the streaming aggregate deduplicated correctly per group but merged partial states by adding their sums while unioning their value sets, so grouping by a node property — which buckets one intermediate group per node — made sum/avg(DISTINCT …) count every row again: [1, 1, 2] grouped by a property summed to 4 and ungrouped to 3;

    • count(DISTINCT *) fused into the node-scan aggregate, whose accumulator folds * as a constant row marker, so it answered 1 for any number of rows while the other two paths answered the row count.

    All paths now deduplicate on the value, which is the rule the DISTINCT clauses and count(DISTINCT …) already applied — so 1 and 1.0 are two values, 1 and '1' are two values, and 0.0 and -0.0 are one, everywhere. count(DISTINCT *) no longer fuses and counts rows on every path.

  • A LIMITed relationship pattern could silently return zero or partial rows. Pushing a LIMIT into a MATCH caps how many candidates the pattern executor materialises — max(limit * 100, 1000) start nodes and max(limit * 50, 1000) intermediates per hop. Those numbers are a selectivity guess: neither knows which relationship type is being matched, so a start node whose only matching edge sat past the cap was dropped and the query answered with silence rather than with rows. It affected unlabeled starts whose relationship sources enumerate late, sparsely-labeled starts (3 000 :Symbol nodes of which 5 carry a :RARE edge, LIMIT 3 → 0 rows), undirected, reversed, variable-length and type-alternation patterns, and multi-hop patterns with a sparse intermediate. The answer could also change with the limit itself, since the cap is arithmetic on it (LIMIT 10 → 0 rows, LIMIT 11 → rows).

    The caps are now advisory: they still bound the first pass, but a pass that hit one and came back short of the limit is re-run once without them, so the answer is the rows the graph has. A query whose limit is met by real rows — the case the caps exist for — is unaffected and pays nothing.

    Present since 0.7.4 (start-node cap) and 0.6.18 (hop cap), not introduced by 0.16.1; 0.16.1’s lazy seeding made the start-node shape easier to reach.

  • avg() over a node property containing non-numeric values divided by the wrong count. On the fused node-scan aggregation path — MATCH (n:T) RETURN avg(n.v), its grouped form, and the WITH n variant — the running numeric sum was divided by the count of every non-null value rather than the count of the numeric ones. One string cell in an otherwise numeric column skewed the average silently and in every group: [10, 20, 'hello'] averaged 10.0 instead of 15.0, while sum() over the same input answered 30. A column with no numeric value at all (the shape a bulk-loaded object column produces, since the loader types a column once and stores mixed values as strings) averaged 0.0 instead of null.

    avg() and sum() over zero numeric values now answer null and 0, the same as the unfused path, and sum()’s Int64-vs-Float64 result type no longer changes when a string cell is present — it was read off the running min(), where a string outranks every number. count() is unchanged: it still counts every non-null value.

  • sum() could flip between an integer and a float result type for identical data, depending on which internal aggregation path happened to serve the query. The materialized executor decided the type by probing the first row of the group, so a leading non-numeric or null value — which says nothing about the numerics behind it — forced the whole sum to a float: ['x', 10] summed to 10.0 and [null, 1, 2] to 3.0, while the streaming and fused-scan paths answered 10 and 3 for the same rows. Whether a query saw one or the other depended on the query’s shape (a median alongside the sum, a grouping key, DISTINCT, streaming=False, disable_optimizer=True all route differently).

    All paths now apply the same rule: the result is an integer iff every numeric input was an integer and the total is whole. Floats, and non-Int64 numeric values generally, still produce a float; non-numeric values and nulls are skipped and no longer influence the type. avg, count, min and max are unchanged.

  • FORMAT CSV over MCP was uncapped, and is now capped at 200 rows. The inline preview has always shown at most 15 rows, but the FORMAT CSV branch returned the entire result set as text — an external eval measured 283,686 characters (~71k tokens) from a single cypher_query call on a 5,420-node graph, on a tool whose own description recommended FORMAT CSV for large results. The inline CSV body now carries the header plus the first 200 rows (the same number the structured recipe route uses) followed by a notice naming the true row count, the full byte size, and the extensions.csv_http_server escape hatch that returns the complete file as a fetch URL. The same cap applies when a configured csv_http_server fails to write and the renderer falls back to inline — previously the failure handed back the very payload the extension exists to avoid. csv_http_server remains opt-in: it binds a port and writes files, so no query can enable it. The three cypher_query tool descriptions and the bundled cypher_query skill now state the cap instead of recommending FORMAT CSV for “large” or “full” results.

  • describe() over MCP never truncated long sample values. The MCP graph_overview route passed sample_truncate=None — “emit every sampled value at full length” — while Python’s describe() has defaulted to 40 characters since it shipped, so the surface with the tightest token budget had the weakest cap and one long text property could dominate an entire overview. It now passes 40, matching every other surface.

  • The piped shell executed nothing and exited 0 when the final statement had no trailing ;. printf 'MATCH (n) RETURN count(n)\n.quit\n' | kglite graph.kgl printed no rows, no error, and a success exit code — and the .help text promised the opposite. When stdin is not a terminal, rustyline’s fallback reader accumulates continuation lines into a local buffer and discards it at EOF, so the statement (and the .quit that followed it, absorbed into the same pending buffer) was never seen by the shell at all. The shell now reads a non-terminal stdin itself, sharing the prompt’s termination rule rather than restating it: a balanced statement runs at end of input and at a dot-command line, which is what a script or heredoc expects, while a tail left unbalanced by an unclosed quote or bracket runs nothing, is named on stderr, and exits non-zero. Terminal behaviour is unchanged — rustyline still owns the prompt, its editing and its history.

  • Table output width-truncated values when it was not writing to a terminal. The 60-character per-cell cap exists so one long value cannot wreck an aligned table on screen, and it was applied unconditionally: piping .schema or a query into a file, a pipeline or an agent silently returned -elided property lists and text values, with the elision indistinguishable from the data. The cap now applies only when stdout is a terminal, where it additionally honours a narrower COLUMNS; piped and redirected output renders every value in full, as does the JSONL session’s rendered output field. CSV and JSON modes were never truncated and are unchanged.

  • The JSONL session’s op set was undiscoverable from the protocol. A driver connected to kglite session had no way to ask what it could send, and an unknown op answered unknown op "delete" without naming one valid alternative. {"op":"help"} now returns the op table — each op with its request shape — as a normal ok:true response, and the unknown-op error lists the valid ops and points at help.

  • RETURN DISTINCT over a pattern with anonymous intermediate nodes dropped reachable answers. When the projection deduplicates on one node variable the planner tells the matcher so, and the matcher was collapsing the partial matches of each unnamed intermediate hop to one per node. Two partial matches standing on the same node are not interchangeable, though, and they can differ in two ways that a later hop reads. First, the relationships they have already consumed: Cypher paths are trails, so the survivor may be blocked where the discarded one was not — on a four-node cycle, MATCH (a {id: 1})-[:R]-()-[:R]-()-[:R]-(b) RETURN DISTINCT b.id answered [4] where both 2 and 4 are reachable. Second, a node variable the pattern binds a second time: MATCH (a:N)-[:A]->()-[:B]->(a) RETURN DISTINCT a.id returned 1 of its 3 rows, because only one a survived the hub. The collapse now happens only when neither applies — which is the case the optimization was written for (pairwise-disjoint hop types record no trail), so the shapes it speeds up keep their speed.

Added

  • The whole Python shortest-path family takes the same traversal controls. shortest_path, shortest_path_ids, shortest_path_indices, shortest_path_length, shortest_path_lengths_batch, are_connected and all_paths all now accept direction= ('outgoing' / 'out', 'incoming' / 'in', 'any' / 'both' / None — the same vocabulary traverse() and where_connected() use, defaulting to today’s undirected search), and the four that were missing them gained connection_types / via_types / timeout_ms. Every default is unchanged, so existing calls answer exactly what they answered before. A new scoped adjacency serves the batch API, which keeps building its adjacency once for the whole batch while honouring the filters. An unrecognised direction raises rather than falling back to the default.

  • shortest_path_lengths_from() — one-to-many BFS distances, with the family’s filters and direction. One walk outward from a single source returns {node id: hop count}; previously N shortest_path_length() calls were the only route to the same answer. It must be bounded by target_ids, target_type or max_hops — an unbounded one-to-all is refused with a message naming the three — and the two answer shapes differ deliberately: with explicit target_ids you get one entry per requested id, None where unreachable; in discovery mode you get only what was reached, where an absent id means unreachable. target_type filters the result and names the id space; via_types is what restricts the walk. A timeout_ms expiry raises here rather than answering None, because a dict silently missing its far half is a wrong answer rather than a missing one.

  • enable_disk_mode(path=...) converts and publishes the disk directory in one step. The conversion now writes its CSR and edge properties inside the directory you name and runs the publish tail, so the live handle ends in the published, mapped, overlay-free state — the same state a fresh kglite.open(path) reads, which is where the documented ~10% footprint actually lives. path also becomes the graph’s save target, exactly as save(path) sets it: a later bare save() publishes a new generation into the same directory. Nothing transits the system temp directory on the way, so a graph too large for /tmp (or one whose /tmp is RAM-backed) converts where you pointed it, and the conversion’s scratch is removed as soon as the publish has rebased every mapping — peak disk is one copy plus the staged generation rather than two. save_subset()’s directory-format branch (the

    1M-node path) routes through the same call and stops staging through /tmp too.

  • graph_info() reports the edges’ storage shape: edges_mapped and edge_property_overlay_rows. Disk mode had no honest reading. The nearest field, columnar_is_mapped, answers a different question — did the memory limit spill the property columns — and answers False on a healthy disk graph, which an external evaluation read as a failed conversion. edges_mapped is True when the edge CSR arrays are memory-mapped from files (the structure enable_disk_mode() materializes; always False on the memory and mapped backends, which have no CSR), and edge_property_overlay_rows counts the edges whose properties are still held on the heap rather than in the mapped base — the term that dominates a conversion’s in-process growth, and which a save() drains to zero. columnar_is_mapped keeps its meaning unchanged; its documentation now says which knob it tracks.

  • kglite.trim_memory() returns allocator-retained memory to the OS. The Rust side allocates through mimalloc, which keeps a finished workload’s pages for the next one rather than handing them back — the reuse that makes a repeated large query cost no page faults is also why a process that once peaked at several GB keeps reporting that peak after the graphs and result sets are gone. Dropping them frees nothing to the operating system on its own: measured on a 400k-node ingest, the footprint stayed at 406 MB across the drop and fell to 15 MB on the first trim_memory(). It is opt-in and never called internally, because forcing a collect at a seam like save() or graph drop would spend that reuse on every call; a host that knows its peak is over spends the milliseconds itself. Safe to call at any time, releases the GIL while it runs, and shrinks nothing still referenced — drop the graph or result view first. On macOS the reclaim leaves the pages counted in rss (ps, psutil) and shows up in the process footprint instead; on Linux RSS drops immediately.

  • EXPLAIN emits an Expand row for each variable-length pattern edge. The plan is clause-granular and a variable-length edge sits inside a MATCH, so MATCH (a:Person)-[:KNOWS*2..3]->(b:Person) and the fixed-length -[:KNOWS]-> spelling produced the identical Match :Person, :Person row — the entire cost of a multi-second expansion was invisible in its own plan. Each var-length edge now adds Expand (:Person)-[:KNOWS*2..3]->(:Person) after its Match row, in pattern order, with estimated_rows null: no cardinality model covers variable-length expansion, and a fabricated number would be worse than none. Every other row is unchanged and the step column stays contiguous.

  • The MCP cypher_query tools accept a params argument. There was no way to bind a $placeholder over MCP at all: the tool took only query, so every parameterised example in describe() — including the inline {prop: $p} form — was unusable on the surface agents actually query, and the workaround was to splice values into the query text. params takes a JSON object and binds both spellings, on the read-only tool and the write-enabled one (whose mutation path also ignored parameters), reusing the same conversion the manifest-declared Cypher tools have always used. Values are bound as data and can never be read as Cypher syntax.

  • The engine now warns about the two silent-empty-result mistakes it could already see. Both are legal Cypher that returns nothing useful without raising, and both were previously invisible at every layer. A projection that reads a property no node of the matched type has — RETURN v.imo on a Vessel whose column is imo_number — is now reported, with a “did you mean?” hint; the all-null column was the worse half of the pair, because the sibling v.name title-aliases to a real value and the rows read as half-correct rather than empty. WITH and ORDER BY projections are covered on the same terms (the existing WHERE warning is unchanged). A relationship pattern pointing the wrong way(p:Port)-[:ARRIVES_AT]->(v:Voyage) when every ARRIVES_AT edge runs Voyage→Port — is now reported naming both orientations. The direction check is deliberately one-sided: it fires only when the connection metadata shows the pattern’s orientation has no support and the opposite one does, so undirected patterns, variable-length expansions, unlabelled endpoints, relationship types observed in both orientations, and alternations with one live branch all stay silent. A sparse property never warns — the metadata records a property as soon as one node carries it — and neither does a property the same statement is in the act of writing. Both ride the existing QueryDiagnostics.warnings channel, so they reach ResultView.diagnostics, the MCP warnings: block, the CLI and stderr with no per-surface work.

  • The MCP server accepts an operator-pinned write scope. write_scope on the cypher_query tool is chosen by the agent, so on its own it is role hygiene rather than access control — an agent that wanted a wider perimeter simply asked for one, or omitted the argument and got no perimeter at all. kglite-mcp-server --write-scope Plan,Task and the manifest key extensions.write_scope: [Plan, Task] pin a ceiling outside the agent’s reach. The pin never falls open: an agent that omits write_scope gets the pinned scope rather than unrestricted writes, an agent that supplies one gets the intersection of the two, and a write with nothing left in scope is refused with a message naming the server’s scope. Flag and manifest key are intersected with each other, with the effective scope logged at boot; a malformed extensions.write_scope fails the boot rather than being dropped (an allowlist that silently fails open is worse than no allowlist), and an explicit [] is honoured literally. A pinned server states its scope in the cypher_query tool description, so an agent can plan inside the ceiling instead of discovering it one refusal at a time.

  • on_invalid={'warn','error','skip'} on add_nodes and add_connections. Bulk loading has always tolerated input it cannot use — a row whose id is null or unrepresentable, an edge row with a null endpoint — by skipping the row, counting it, and warning. That is the right default for exploration and the wrong one for a pipeline, where a silently short load surfaces as a data bug much later. on_invalid='error' refuses the whole call before it writes anything, naming how many rows are unusable, which row is first and what it holds; on_invalid='skip' does today’s work without the warning, keeping the counts in the returned report. The default stays 'warn', so existing callers see no change. 'error' also refuses an object-dtype column that would be stringified wholesale.

  • verify_unique_constraints() is bound to Python. The audit docs/python/guides/primary-store.md has always pointed at for graphs filled through a path that bypasses enforcement (the RDF / N-Triples loaders, the embedding-carry path) existed only in the engine. It returns one dict per violated constraint — constraint, node_type, properties, duplicate_tuples, a sample tuple and a message — and an empty list when the stored data is clean.

Changed

  • The parallel runtime, trail semantics, and what depth costs are documented where a user looks for them. parallel=True shipped in 0.16.4 with a reference section and a stub docstring, and nowhere in the Python guides — so the single largest lever on an analytical scan (5-6x on scan-dominated shapes) was invisible to anyone not reading CYPHER.md end to end. The Cypher guide now carries it with the measured table, the runtime gates (20,000 candidate rows compiled / 5,000 interpreted), and the scope that is easy to trip over: per query rather than per graph, KnowledgeGraph.cypher() only, ignored by disk-mode and spatially-configured graphs. Alongside it, a new “How deep traversal behaves” section states what a deep variable-length pattern costs — reachability shapes flatten once the frontier saturates, EXISTS is depth-independent, shortestPath is sub-linear in distance, and count(*) or a minimum hop count of 2 is path enumeration that grows with branching to the power of the depth. CYPHER.md gains the trail rule itself (no relationship twice per clause, and what that means for a node being its own endpoint), which was previously stated only as a row in the dialect table. The public benchmark report’s claim that results are “digest-checked equal across engines” is corrected to what the harness actually reports: the k-hop topics diverge by under 1% because engines disagree on whether a path may return to its own seed.

  • A WHERE whose every conjunct was pushed into the pattern is no longer re-evaluated per row by the fused node-scan operators that already apply it. Predicate pushdown copies WHERE n.age > 30 into the pattern as a property matcher and used to keep the WHERE clause as well, so FusedNodeScanAggregate and FusedNodeScanTopK — whose candidates come from the pattern matcher — tested every surviving node a second time against the same predicate. Measured as the dominant cost of a low-selectivity filter + aggregate: disabling pushdown outright was faster than keeping it, because the pattern pre-filter saved nothing downstream while the WHERE re-ran regardless. The clause is now dropped by the fusion pass — after every earlier pass has chosen its operator, so the plan cannot be rerouted — and only when replaying the extraction against a property-free copy of the pattern reproduces the matchers already on it, term for term. A partially-pushed predicate, a text matcher (STARTS WITH and friends are candidate pre-filters, not equivalents of their predicate), a correlated term, and every operator outside the node-scan family keep the clause exactly as before. EXPLAIN now marks a surviving predicate with a +filter suffix on those two operators, so a plan reader can tell the two cases apart. Measured on a 400k-node in-memory scan (release, min of two runs, unchanged-path control cells drifting −2.4%/−3.2% over the same interval): grouped aggregate under a 90%-selectivity filter 31.0 ms → 19.6 ms (−37%), bare count 15.9 ms → 8.4 ms (−47%), and a 0.1%-selectivity filter unchanged at 3.4 ms.

  • Single-pair shortest-path queries now search from both ends at once (bidirectional BFS), an order of magnitude faster on large graphs. shortest_path(), shortest_path_ids(), shortest_path_indices(), shortest_path_length(), are_connected() and Cypher’s shortestPath(...) used to grow a single frontier out of the source until it swallowed the target, exploring everything within the full path radius. They now grow one frontier from each endpoint and stitch the halves where the two meet, which touches a small fraction of the same graph: a 5-hop answer costs two 2-3 hop searches instead of one 5-hop search. Filters, direction and timeouts are unchanged — the backward frontier walks the reverse of the requested direction, and via_types gates both halves identically, with the endpoints exempt as before. The weighted finders (weight_property=..., Dijkstra) and allShortestPaths(...) are unchanged and remain one-sided.

    Where several shortest paths tie, the family has never promised which one it returns, and meeting in the middle makes a different arbitrary choice than the old scan did. Hop counts, reachability and allShortestPaths(...) are unaffected; code that pinned one particular equal-length node sequence may see a different one.

  • Path-returning shortest-path calls no longer pay a per-node sort that made them several times slower than the length-only call. On the default (unfiltered) traversal every visited node’s neighbours were collected into a Vec, sorted and deduplicated before the search looked at them — work a breadth-first search discards for free against its own visited set. shortest_path_ids() measured 8x shortest_path_length() on the same pair on the fixture that found it, and 3-4x on a 114k-node / 904k-edge generated graph. It is now the cheaper of the two. The search paths now iterate the graph’s neighbours directly; the deduplicating collector remains where duplicates actually cost something (all_paths() and allShortestPaths(...)).

  • enable_disk_mode() streams edge properties into the on-disk store instead of cloning them onto the heap. The conversion copied every property-bearing edge’s properties into the disk backend’s heap mutation overlay — around 175 bytes per edge of pure duplication, since the in-memory graph it copies from is dropped as soon as the conversion returns, and the dominant term in what the call added to a converting process. The properties are now written into the columnar blob beside the CSR as the edges are walked, one edge’s encoded bytes at a time, and the resulting graph maps that blob the way a reopened directory does: graph_info()['edge_property_overlay_rows'] reads 0 immediately after the conversion, and the conversion’s memory overhead is the CSR structures alone (~32 bytes per edge). The bytes written are the same format a save() writes, so reads after the conversion, after a save, and after a reopen all answer identically, and a SET on a converted graph’s relationship takes the overlay on top of the mapped base as usual.

  • Behaviour change for programmatic MCP clients: a tool call that fails now reports isError: true. Cypher syntax and execution errors, an unknown procedure, the read-only refusal, a write-scope refusal (the agent’s own scope or the operator’s pin), no active graph, a failed reload_graph/save_graph/load_graph/create_graph/save_graph_as, an overview the engine could not compute, and a manifest tools[].cypher template that could not run all travel in an MCP error envelope instead of a success envelope whose body happened to contain error text. The text itself is unchanged — the teaching prose, the near-miss suggestions, the identity and staleness footers all read exactly as before, on both arms — so an agent reading the response sees no difference; a client that branches on the flag no longer has to pattern-match prose. Successful-but-empty and advisory outcomes stay successful: zero rows (No results.), the engine’s warning block, mutation acknowledgements, and EXPLAIN output. An empty result is an answer. Invalid tool arguments (a call the framework could not even hand to the handler) also flip, via mcp-methods 0.4.6, which is now the floor.

  • Performance: a variable-length expansion feeding a distinct-only consumer no longer materialises duplicate target rows. A multi-seed reachability query — MATCH (p:Person)-[:KNOWS*1..3]->(f) WHERE p.id IN $ids RETURN count(DISTINCT f) — built one row per (seed, target) pair and deduplicated afterwards, so its peak memory followed the seed count even though the answer is the union of the reachable sets. The DISTINCT pushdown that already covered RETURN DISTINCT f.id now also covers a projection made entirely of multiplicity-invariant aggregates over one variable (count(DISTINCT f), collect(DISTINCT f.id), min/max), and the pattern matcher may apply it during expansion even when a WHERE is fused into the same MATCH. Peak memory for the shape above now tracks the distinct target set: doubling the seeds costs 1.4x the peak where it used to cost 2.0x, and the absolute peak fell 6-8x (54.7 MB to 6.6 MB at 50 seeds over a 30 000-node graph, release); the tracked khop3_in_list_count_distinct cell is 2.4x faster. The optimization only ever skips emitting a row, never traversing: each seed still runs its own expansion through targets an earlier seed reached, so a seed whose own targets lie past another’s is unaffected. A source variable that reaches the projection (RETURN p.id, count(DISTINCT f)) keeps the per-source answer, and count(*) — where the row count is the answer — is excluded as before.

  • Performance: the UNWIND-driven spelling of that query shares the dedup across its driving rows. UNWIND $ids AS i MATCH (p:Person {id: i})-[:KNOWS*1..3]->(f) RETURN count(DISTINCT f) asks the question one seed per row, so it runs one expansion per row and each one started with an empty seen-set — the memory and time the WHERE-IN spelling had just been given back, it kept paying. The seen-set now spans those expansions whenever the projection is made entirely of multiplicity-invariant aggregates over the deduplicated variable, so a driving row that reaches nothing new contributes no rows at all. Peak memory for 50 seeds over a 30 000-node graph falls from 16.2 MB to 3.9 MB and stops following the seed count (1.87x per doubling to 1.05x), and the tracked khop3_unwind_distinct cell is 2.1x faster — within ~1.2x of the WHERE-IN spelling it computes the same answer as, where it was 2.6x. The same sharing covers the two-clause spelling (MATCH (p) WHERE p.id IN $ids MATCH (p)-[…]->(f)). As with the single-expansion dedup it skips only emission, never traversal, and a projection that reads anything but those aggregates — RETURN p.id, count(DISTINCT f), RETURN DISTINCT f.id, count(*) — keeps its per-driving-row answer.

  • Performance: the variable-length BFS no longer allocates and zeroes a graph-sized visited buffer per row. It sized the buffer to the whole graph for every source row, so the cost scaled with the node count rather than with the work done: after existence checks learned to stop at the first witness, that buffer was what was left, and a 50-row EXISTS { (p)-[:KNOWS*1..3]->(:Person) } measured 31 µs at 10 000 nodes, 37 µs at 40 000 and 89 µs at 160 000 against a flat 16-18 µs fixed-hop control. Rows that may stop early now mark into a small set and only allocate the dense buffer if they keep going, and rows that do sweep their reachable set re-use one buffer across the expansion by bumping a generation stamp instead of re-zeroing it. The same three sizes measure 30.1 / 30.6 / 30.7 µs — flat, and 2.9x faster at 160 000 nodes.

  • Performance: EXISTS { } and pattern predicates stop at the first witness. An existence check needs one match, but the pattern behind it ran to completion for every candidate row — on a 10 000-node social graph the tracked EXISTS { (p)-[:KNOWS*1..3]->(:Person) } cell measured 466x its fixed-hop control. The evaluator now caps the subquery at one match wherever the first match settles the answer (a single pattern with no inner WHERE, and no binding the pattern executor cannot enforce itself), and a variable-length segment inside an existence check with a minimum hop count of at most 1 uses the set-based expansion, which stops as soon as it has the witness. NOT EXISTS gets the same cap — one witness decides it either way — and a witness that only exists past the executor’s candidate pre-caps is still found, by the same uncapped retry that guards every other capped pattern. COUNT { } is deliberately untouched: it needs the count, not a witness.

  • A fixed-length variable-hop pattern (*k..k) is now planned as k explicit hops. -[:R*2..2]-> and -[:R]->()-[:R]-> ask the same question, but only the second spelling reached the fixed-pattern machinery: start-node selection, relationship-predicate pushdown, the fusion family and the trail and target-type annotations all decline a variable-length element, so the star spelling of a fixed-length question paid for none of them. A new optimizer pass (lower_fixed_var_length_hops) rewrites the segment into k copies separated by anonymous intermediate nodes, replicating the type alternation, inline relationship properties and direction onto each hop — measured 20x on the tracked unanchored two-hop count (release build, min); anchored spellings were already at parity and are unchanged. The rewrite preserves trail semantics: the fixed-hop matcher already enforces relationship uniqueness across hops, and the one annotation that switches that bookkeeping off requires pairwise-disjoint hop types, which k copies of one element can never be. It declines a genuine range, *0..0, a bound relationship variable (which binds the relationship list), a path assignment, and any pattern that would exceed two hops after lowering. That ceiling is where the win is and where it stops: the machinery a lowered pattern unlocks that the star spelling cannot reach is the fused count operator, which takes a one- or two-hop pattern, and inside that window the rewrite is worth 3.4x-17x across a sparse chain, a heterogeneous typed graph and a scale-free social graph. A deeper lowered pattern reaches only the general fixed-hop matcher, which measured slower than leaving the star alone on every shape tried — 1.98x/2.54x/3.05x at three/five/eight hops on a 20k sparse chain, 3.83x on a 50-seed count(DISTINCT) at *5..5 over a 10k social graph — and far heavier: that last shape peaks at 10.5 GB lowered against 1.16 GB left as written, because the fixed matcher materializes a match per trail where the variable-length expansion emits pairs the distinct hint folds as they arrive. Answers are identical either way; only the plan differs. Like every registered pass it can be switched off with disabled_passes=['lower_fixed_var_length_hops'].

  • describe() spends fewer tokens saying the same things, and says two it never said. Five changes to the shared introspection output, so every surface gets them:

    • Sampled values are deduplicated after truncation. Distinct long strings that clip to the same 37-character prefix were all emitted, so a vals= could be N copies of one display string.

    • The inventory tier’s <connections> map is capped at 50 connection types (highest edge count first) with a <more count=… hint=…/> marker, matching the Extreme tier and the type listing beside it. It was the only unbounded listing left in the document and drove the measured worst case.

    • A property carries coverage="51%" when it is present on only some of its type’s nodes, and nothing at all when it is present on all of them — unique= counts distinct values and never said how many nodes had one.

    • Connection properties render as name:Type (properties="since:Int64") rather than bare names, from metadata already in memory. An untyped since could be an integer year or an ISO date string, and an agent that cannot tell avoids the property.

    • The <cypher hint=…> states that the standard openCypher surface is supported and that the items it lists are KGLite extensions on top. It previously listed only the extensions, which reads as “this is a partial dialect”.

  • The bundled cypher_query MCP skill no longer ships code-graph methodology. It opened with the four-step code-graph workflow and “Never grep for a definition, caller, or call site” — roughly 1.5k tokens of instruction about navigating a codebase, delivered verbatim to shipping, legal and maritime graphs, where none of it applies. That content already lives in the code_graph_analysis skill, which gates on the graph actually containing Function/Class node types. The generic skill keeps the Cypher workflow guidance that applies to any graph, and now documents the params argument (it still said $name parameters “aren’t currently exposed”) and the FORMAT CSV row cap.

  • managed_reload and the runtime cypher docstrings no longer overstate what they do. README described define_schema(layer=...) + add_nodes(managed_reload=True) as making a rebuild “provably” unable to clobber agent-owned nodes. What the code does is skip a runtime-layer type when the rebuilding side passes the flag: an add_nodes call that omits it writes the type normally, nothing gates a live writer out of managed types, and add_connections is not covered at all. README, the .pyi, the schema docs and the derived-index guide now say that, and point at write_scope as the mechanism that actually refuses an out-of-role write. Separately, the runtime (help()) docstrings for KnowledgeGraph.cypher, Transaction.cypher and Session.execute documented a read-only API — no mutation clauses, no write_scope — while the accurate prose lived only in the .pyi, invisible to an agent introspecting the API at runtime. All three now describe the write surface.

  • Breaking (semantics fix): write_scope now covers every write, not just CREATE/SET. The whitelist was enforced on node creation and property assignment only, so a scoped session could still DELETE / DETACH DELETE a node of any type, REMOVE its properties or labels, add a label to it with SET n:Label, and create, retype, or delete any relationship in the graph — including forging an edge between two nodes it had no standing to touch. An external audit deleted 1100 out-of-scope nodes from a write_scope=["Plan", "Task"] session; a role that provably could not write an Algorithm node could delete every one. The perimeter is now:

    • Node writesCREATE, MERGE’s create arm, SET n.p, SET n += {…}, SET n:Label, REMOVE n.p, REMOVE n:Label, DELETE, DETACH DELETE, and node-type index/constraint DDL — are judged by the node’s stored type, never by a pattern label, so no relabelling talks a node into scope.

    • Relationship writes — edge CREATE, DELETE r, SET r.p, REMOVE r.p — are allowed when at least one endpoint’s stored type is in scope. Linking a node you own to a matched out-of-scope node stays allowed (it does not mutate that node — the 0.12.1 fix is unchanged and pinned); an edge between two out-of-scope nodes is refused.

    • DETACH DELETE removes the incident relationships of a node you may delete, whatever the far endpoint’s type is — that collateral is authorized by the node delete, not re-checked per endpoint.

    • A refusal happens before the statement mutates anything: an out-of-scope row in a multi-row DELETE does not cost the in-scope rows.

    • write_scope=[] now denies every mutation, including relationship writes.

    Outside the perimeter, deliberately and now documented: relationship constraint DDL (a scope names node types, and there is no relationship spelling for one to name), db.cdc.enable/db.cdc.disable, and the bulk loaders add_nodes/add_connectionswrite_scope is a per-Cypher-execution concept and does not reach the Python loader API. A caller relying on a scoped session to delete or relink out-of-scope data must widen the scope to list those types. README, docs/operators/cli.md (which over-claimed relationship-type scoping), the .pyi prose, the --write-scope CLI help and the MCP cypher_query tool description all now state the boundary.

  • Breaking (semantics fix): Cypher =~ now matches the whole value instead of searching inside it. openCypher, Neo4j and Kùzu all define =~ as a full-string match; KGLite evaluated it as a substring search, so 'inactive' =~ 'active' was true, 'Alice' =~ 'li' was true, and WHERE n.role =~ 'admin' also selected 'superadmin' — a filter written as an exact check silently behaved as a prefix/suffix-tolerant one. The pattern is now anchored (^(?:…)$), with the group binding a top-level alternation as a unit, so 'catx' =~ 'cat|dog' is false. Patterns that already carried explicit ^/$ anchors, inline flags such as (?i), and character classes are unaffected. To search, say so: wrap the pattern with .* (n.name =~ '.*ali.*'), or use CONTAINS / text_match_regex(). Compile-failure messages still quote the pattern as you wrote it.

  • Breaking (semantics fix): the fluent {'=~': …} operator follows Cypher’s =~ and now matches the whole value. {'regex': …} and {'not_regex': …} are unchanged and keep the search semantics FLUENT.md documents — the two spellings were synonyms and are not any more, because one of them is a Cypher operator. text_match_regex() is likewise untouched: it is the documented search function.

  • define_schema rejects keys it does not understand. An unrecognised key inside a node or connection declaration used to be dropped in silence, so {'uniqe': [['email']]} installed a schema that reported success and enforced no uniqueness at all. Unknown keys now raise, naming the near miss where there is one (Did you mean 'unique'?) and the accepted set otherwise. The same check catches a forgotten 'nodes' wrapper — define_schema({'Task': {...}}) previously installed a completely empty schema without complaint — and points at the wrapper by name. Valid schemas parse exactly as before, and an absent or non-map nodes / connections section stays the no-op it has always been.

  • Cypher parse errors read as Cypher, not as Rust. A token in an error message is now spelled the way it was written — MATCH (n) SET 1 = 2 says got 1 instead of got Some(IntLit(1)), and a lookahead past the end says got end of input. A reserved keyword used as a name names the escape hatch (got MATCH a reserved keyword; backtick it (`match`) to use it as a name), and /* ... */ is reported as an unsupported block comment pointing at // instead of surfacing as Unexpected token at start of clause: Slash. Block comments remain unimplemented.

  • primary-store.md states the schema and bulk-load rules the code actually enforces. Three claims were wrong or missing. define_schema’s types: map is advisory — it is checked by validate_schema(), not at write time; the page now says so and points at CREATE CONSTRAINT IS :: TYPE, which is enforced on every write path, and at lock_schema(). The defaults table said “No schema; any property on any node” while a node type’s property set is in fact fixed by its first write (the deliberate CREATE typo guard, which fires whether or not schema_locked is set); the page now states the rule and the four ways to widen the set — SET, define_schema, a bulk load with a new column, or a fresh node type. And the “chunks already flushed stay written” caveat on large bulk loads is retired: every refusal add_nodes / add_connections can raise is now decided in one pass before the first row is written, so a rejected load writes nothing at any input size, and the 1000-row chunking is a memory bound rather than an atomicity boundary.

  • --graph mode’s default source root is documented. Serving a .kgl with a manifest that declares no source_root/source_roots auto-binds the graph file’s parent directory as the sole static source root — deliberate, and the reason file tools work next to a graph with no configuration, but undocumented, so the blast radius of a .kgl kept at the top of a home directory was invisible. docs/operators/mcp-server.md now states the default, that an explicit manifest declaration wins outright, and how to scope or move it.

  • Public scale claims now state the measured shape, and the benchmark BENCHMARKS.md points at is in the repo. benchmarks/competitive/largescale/ — the staged-dataset harness that runs one generated graph through kùzu and both kglite disk-backed modes — was excluded by a .gitignore rule, so the “Scaling” pointer resolved to nothing for every reader outside this machine. It is tracked now, with its capture provenance (date, engine revision, machine), an explicit methodology note (one timed run per phase, not a min-of-rounds), and a corrected generator invocation; its kùzu loader also projects the staged CSVs onto the declared schema, which the current generator output otherwise fails to load. Separately, “1B+ edges” and “billion-edge Wikidata” (README ×3, CYPHER.md) claimed a number no measurement in this repository supports; every site now reads the 124M-node / 861M-edge Wikidata graph the engine’s own disk-path work is written against.

  • Three dialect deviations are documented instead of discovered. KGLite’s value model has no NaN or Infinity, so sqrt(-1), log(0) and 1.0 / 0.0 are null where Neo4j returns a non-finite float (integer 1 / 0 still raises, as in Neo4j); and toInteger('3.7') is null where Neo4j truncates to 3, because a string argument must spell an integer. CYPHER.md carries both as divergence notes, the feature-coverage table reclassifies the two rows, and tests/api-baselines/cypher-dialect.json gains matching intentional_divergence entries backed by executable contract cases. Behaviour is unchanged.

  • The unbounded-row safety ceiling is documented next to set_default_max_rows(). The Cypher guide now states the 10,000,000-row backstop that applies when a query sets no max_rows, quotes the error it raises, and names both escape hatches (an explicit max_rows, or a LIMIT), along with the O(1)-work exemption that keeps count(*) over a 100M-node mapped graph answering.

[0.16.5] - 2026-08-19

Added

  • Relationship constraints — REQUIRE r.p IS NOT NULL and REQUIRE r.p IS :: <TYPE> on a relationship pattern. Declared, enforced on every write path, and persisted:

    CREATE CONSTRAINT knows_since FOR ()-[r:KNOWS]-() REQUIRE r.since IS NOT NULL;
    CREATE CONSTRAINT FOR ()-[r:KNOWS]-() REQUIRE r.since IS :: INTEGER;
    

    Declaring one validates it against every existing relationship of the type and refuses, installing nothing, if the data already violates it — the same posture node constraints take. Enforcement covers Cypher CREATE (and MERGE’s create branch), SET r.p in all three spellings (SET r.p = v, SET r = {…}, SET r += {…}), REMOVE r.p, and the bulk add_connections / replace_connections loaders. A refused write changes nothing: no relationship, no connection-type metadata, and no entry in the change-capture stream. Declarations survive save/load in the .kgl metadata, and SHOW CONSTRAINTS / CALL db.constraints() report them under Neo4j 5’s RELATIONSHIP_PROPERTY_EXISTENCE / RELATIONSHIP_PROPERTY_TYPE names, with entityType reading RELATIONSHIP. describe() annotates a constrained edge property with constraint= / declared_type=, in the same vocabulary it uses on the node side.

    A bulk row is judged on the state it will actually leave behind, which is not the row: under preserve a value the stored relationship already has is discarded and therefore never refused, and under sum an addition that turns an integer into a float is refused even though both operands are fine. A frame is refused whole rather than row-by-row, matching the loaders’ existing validate-then-write contract, and replace_connections raises the refusal before its delete, so a frame the constraint rejects never costs the caller the relationships they already had.

    Not served: IS UNIQUE and IS RELATIONSHIP KEY on a relationship, refused by name. KGLite has no single answer for when two relationships of a type are the same one — the bulk loader deduplicates (type, source, target) while Cypher CREATE freely makes parallel edges — so a uniqueness declaration would mean different things depending on which write path produced the data. Relationship constraint DDL is allowed under any write scope, since write scopes name node types.

  • Bulk writes raise the typed constraint exception. add_connections / replace_connections reported every failure as ArgumentError, including a constraint refusal whose structured violation was sitting on the graph waiting to be recovered — the recovery step existed only inside add_nodes. It is now shared, so every bulk entry point raises ConstraintViolationError / ConstraintCreationError where one applies.

  • Change-capture before-images — CALL db.cdc.enable({enrichment: 'full'}). Every event then carries state.before as well as state.after, in the same {title, labels, properties} / {properties} shape, so a consumer can see what a commit replaced without keeping its own mirror. A delete’s before is the state it destroyed — the one event whose only informative half is that one. A create has none, and reports null.

    before is the state at the start of the commit, not before the most recent write: three writes to one entity in one transaction publish one event whose before is what the transaction opened on. Label changes are included, so before.labels is the set the commit replaced.

    'off' remains the default and is unchanged in cost and output. 'full' adds one whole-entity read per changed entity per commit — at each entity’s first touch, not per write — so an update or delete event then carries two images where it used to carry one. Measured (release profile, min of 7 rounds, two runs) on 1000 autocommit SETs, which is the worst case for the read because every write is its own commit and therefore its own first touch: +2-5% wall time versus 'off' (median +4.5% over three interleaved pairs at the program’s perf gate; 4.62-4.76 ms -> 4.83-4.86 ms). The enrichment applies to writes rather than to the log, so switching a running log to 'full' keeps its epoch and its retained events, and before starts appearing from the next commit.

    'diff' — Neo4j’s third txLogEnrichment value — is refused by name: the diff is computed from the full before-image, so it saves ring bytes but not the read the mode exists to avoid. Compute one from a 'full' event, where both sides are present.

  • Change-stream selectors — CALL db.cdc.query({selectors, maxRows}). selectors is a list of filter maps, and an event is returned if any of them matches, so one query serves “every delete, plus every update to a Person”. The keys are elementType, operation, nodeType, relationshipType, srcType/tgtType, nodeId/srcId/tgtId, labels and changesTo, and their values are the same strings the columns reportoperation: 'update', not Neo4j’s 'u'.

    labels is a conjunction over a node’s secondary labels (the primary type is nodeType’s job); changesTo matches when any listed property differs across the commit and is refused on an enrichment: 'off' log, naming the fix, rather than silently matching every event that merely has the property.

    Filtering happens at read time and before the copy-out, so an event you did not ask for is never cloned out of the ring, and rows keep the cursor id they would have had unfiltered — a cursor addresses the log, not your filtered view, so consumers with different selectors can exchange them. The consequence is that a filtered poll may return zero rows while the log has advanced: take db.cdc.current() before the query and adopt it after, which CYPHER.md documents and a test pins.

    Validation is strict one level in: an unknown key inside a selector map is refused rather than ignored, wrong-typed values are refused by key name, selectors: [] means no filter, and selectors: [{}] is refused because an empty map is a filter that constrains nothing. maxRows caps the rows returned after filtering; it is spelled maxRows rather than limit because LIMIT is a reserved clause word that cannot be a bare map key.

  • A saved graph records where its change-stream epoch ended, so a stale cursor gets a useful refusal after a reload. db.cdc.query already refused a cursor from a different epoch; when the file this graph was loaded from was saved under that epoch, the message now names where it ended and whether the consumer was caught up at the handoff or behind by a countable number of changes that were never delivered. A wrong-epoch cursor the file knows nothing about keeps the previous prose — nothing is known about it, so nothing is claimed.

    The stamp is written by every save (not only a durable checkpoint — the file is what the next process loads), and by db.cdc.disable(), which is where an epoch actually ends. It is a diagnostic, not a durable cursor: the log is still never persisted, capture is still off after a load, and the remedy is still to resync. Graphs that never enabled capture write no stamp at all and are byte-identical to before.

  • CALL db.cdc.status() — read the change-capture configuration without changing it. Yields enabled, epoch, capacity, enrichment, buffered, earliest, current. It is the one CDC read verb that answers while capture is offenabled: false with the rest null, rather than the “not enabled on this graph” refusal the other read verbs give — so a consumer can ask whether the stream exists before deciding to consume it. Until now that information was only obtainable from db.cdc.enable(), which mutates.

Changed

  • BREAKING (Rust API): node, relationship and map properties are now a PropMap, not a BTreeMap<String, Value>. NodeValue::properties, RelValue::properties and Value::Map all carry kglite::datatypes::PropMap — an Arc’d, sorted flat map with the same key-ordered iteration, equality, Ord and hashing a BTreeMap gave. Rust embedders that named the field’s type, or that matched Value::Map(m) and used it as a BTreeMap, need the map-like API instead (get, iter, keys, values, len, contains_key, insert, remove); PropMap also converts both ways with BTreeMap<String, Value> via From. Python, Bolt, the CLI and the C ABI are unaffected — all four already convert properties at their boundary, and their output is byte-for-byte what it was.

    No file format changed. .kgl snapshots, WAL frames and CDC payloads serialize through postcard’s identical map framing; the pinned byte goldens and the .kgl digest in value_byte_identity_tests pass unchanged, so old files load and new files are readable by any binary that could read the old ones.

    The motivation is clone cost: a materialised node’s properties now travel between rows for a refcount instead of a deep copy. Measured at the program’s perf gate (release profile, three interleaved pairs against the same tree with the container reverted, unchanged-path controls carried): ORDER BY n +36.6% — the three pairs agree to within 0.6 points — collect(n) at 10k +9.8%, a WITH chain +9.6%, n {.*} map projection +8.1%, RETURN p over paths +5.5%, a map literal +4.7%, RETURN n +3.0%, and collect(n) at 100k +2.0%. properties(n) and the Python .to_list() round trip are flat. No cell regressed.

  • BREAKING: db.cdc.query’s state column is now the pair {before, after}. CDC v1 (0.16.4) put the after-image directly in state; it now sits under state.after, matching Neo4j’s CDC shape, with state.before alongside it. A v1 consumer reading state.properties, state.title or state.labels must read state.after.properties (and so on). Two further consequences:

    • state is now always a map, including for a delete, where it reads {before: null, after: null}. A v1 consumer testing state is None to detect a delete must switch to operation == "delete" (or state["after"] is None), because the null it was testing is now one level down.

    • state.before is null in every row for now. The half is in the shape so that consumers write state.before once rather than migrating a second time when it starts carrying data.

    The break is deliberate and taken now, one release after CDC shipped, rather than left to grow a second consumer base: the pair is the shape the stream was always heading for, and a state that means the after-image in one version and the pair in the next is worse than a single documented move.

Fixed

  • Re-saving a loaded .kgl now produces the same bytes every time. Saving a graph that was loaded from a .kgl wrote a different file on each run — same data, different bytes — so backups never deduplicated, content hashes were unusable as change detection, and byte-comparing two saves said nothing.

    The loader rebuilt each type’s column schema from a HashMap’s key order, which Rust re-seeds per process. Column slot order is the order columns are written into the file, so every load produced a differently-ordered save. The packed column payload already records the order positionally, so the loader now reads it from there and reproduces the order the file was written with. The same unordered-iteration pattern was fixed in the four sibling paths that build a type schema from a map (in-memory reload, disk-directory load, the post-load schema rebuild, and the RDF loader), which now order by property name — the canonical choice where the source records no order.

    Data and values were never affected: reads resolve a property by name, so a permuted-but-self-consistent order returned correct results, which is why this stayed invisible. Freshly-saved bytes are unchanged.

    A re-saved file is still slightly larger than a fresh save of the same graph (79 bytes on a 6-node fixture), and that is intended: loading a file warms the type_connectivity / edge_type_counts caches that make describe() instant on large graphs, and a save persists a warm cache. The round-trip reaches a fixed point after one cycle — every save from then on is byte-identical.

  • UNWIND over a collected list is no longer quadratic in memory. WITH collect(n) AS ns UNWIND ns AS m expands one row into n, and every expanded row was a full copy of the source row — which still held the list under its own name. The result was n rows each retaining an n-element copy of identical data:

    rows

    before

    after

    500

    213 MB

    5.3 MB

    1 000

    837 MB

    6.4 MB

    2 000

    3 334 MB

    8.7 MB

    10 000

    killed (out of memory)

    40.7 MB

    A new narrow_unwind_source planner pass marks the cases where nothing after the UNWIND can observe the source binding, letting the executor take the list out of the row instead of copying it into each expanded row. The pass is deliberately conservative and keeps the old behaviour whenever the list is still reachable — a later clause naming it, RETURN * / WITH * (which observe every binding without naming one), a second UNWIND over the same list, or any write/procedure clause downstream. Results are unchanged in every case; it can be turned off with disabled_passes=["narrow_unwind_source"].

    Not fixed by this change, and tracked separately: FOREACH, list comprehensions, reduce() and the list quantifiers (any/all/none/ single) clone the source row per element too. Their copies are transient, so peak memory stays flat, but the work is quadratic in time (measured at 2 000 elements: ~53 ms, growing 4x per doubling).

  • CREATE CONSTRAINT IS NODE KEY / IS RELATIONSHIP KEY now has to agree with the FOR pattern. The optional NODE / RELATIONSHIP scope word was parsed and discarded, so FOR (p:Person) REQUIRE p.email IS RELATIONSHIP KEY silently installed a node key — a constraint the statement did not ask for. All four crossings are now refused, naming what was written, what the pattern targets, and what to write instead. The unscoped spellings (IS UNIQUE, IS KEY) are unchanged and still legal against either pattern.

  • A CREATE CONSTRAINT on a relationship pattern no longer answers with an index limitation. It reported “KGLite indexes node properties only, so there is no index to create” — a fact about a structure the statement never mentioned. CREATE INDEX keeps that message; the constraint form now explains itself in constraint terms.

[0.16.4] - 2026-08-19

Added

  • Opt-in parallel Cypher runtime — kg.cypher(..., parallel=True), the CLI’s kglite query --parallel, and ExecuteOptions::parallel in Rust. One heavy analytical query may now use the whole machine. It is a hint, never a semantic change: answers and row order are identical either way, only operators that can partition deterministically use it, and each still applies a runtime gate on candidate count and per-row cost, so a small query stays sequential however it is flagged.

    The first operator to honour it is the fused node-scan aggregate (MATCH (n:T) [WHERE ...] RETURN <group keys>, <aggregates> over count/count(DISTINCT)/sum/avg/min/max). Measured release-mode on a 1M-node graph and a 10-core Apple Silicon machine, two agreeing runs: ungrouped scan+filter+count 2.7x (33.9 ms → 12.5 ms), the grouped form 3.7x (69.4 ms → 18.3 ms). Group emission order is preserved exactly — partitions are contiguous candidate ranges merged in candidate order, so first-seen group order is unchanged.

    Off by default everywhere, and deliberately not exposed by the Bolt or MCP servers in this release: a server’s cores belong to its concurrent clients, so enabling it there would trade across-query throughput for one query’s latency. Disk-mode graphs and graphs with a spatial configuration ignore the flag and run sequentially rather than refusing it, so portable code is unaffected.

    The candidate scan and filter honours it too — the node scan behind MATCH (n:Label {prop: …}) and MATCH (n:Label) WHERE . Measured release-mode on a 1M-node graph, two agreeing runs: scan + filter + count 5.4x (35.4 ms → 6.6 ms), the same with a grouped aggregate 5.4x (69.1 ms → 12.8 ms), a property-filtered scan 5.2x (8.2 ms → 1.6 ms), and an interpreted text predicate 6.5x (30.2 ms → 4.6 ms). Row order is preserved exactly: partitions are contiguous candidate ranges concatenated in candidate order, so the bucket order of an un-ORDER BY’d MATCH is unchanged.

    A query whose cost is dominated by building result rows rather than scanning them sees little of this (1.08x on a 792k-row projecting query) — row construction allocates per row and does not scale; the same reason the projection fan-out has a row threshold.

    Grouped aggregation honours it as well, by evaluating groups in parallel with each other — the shape behind collect, median, mode, percentile_*, std and variance, which the streaming aggregate declines. Measured on the same 1M-node graph, two agreeing runs: 1.28x on a few large groups, 1.38x on a few hundred, 1.42-1.46x for median/percentile_cont, and 1.09x once the group count approaches the row count (800k groups), where there is little left to share. Each group’s aggregate is still computed over its own rows in row order, so collect comes back in the same order and float sums are not reassociated.

    Also parallelised: ORDER BY sort-key precompute (the key computation only — the sort stays stable and sequential, so ties keep input order).

    Numbers, release, 1M-node/11M-edge synthetic graph, 10-core Apple Silicon (4P+6E), minimum of two agreeing runs: scan + filter + count(*) 5.2x, scan + filter + grouped aggregate 5.2x, property-filtered scan 5.0x, interpreted text predicate 6.5x, regex predicate 6.0x, grouped aggregation 1.1-1.4x depending on group count, ORDER BY over 800k rows 1.1x, 792k-row projection 1.1x. Concurrent-client throughput with the flag off is unchanged. The shape of that table is the guidance: scanning parallelises, building rows does not.

  • Property-type constraints — CREATE CONSTRAINT ... REQUIRE n.prop IS :: TYPE (and the IS TYPED TYPE spelling) are now declared and enforced. The statement previously parsed and was refused with an explanation; it now installs a real constraint that every write path checks — Cypher CREATE and MERGE, SET, and bulk add_nodes loads alike — and the declaration survives save and reload. Declaring one scans the existing data first and refuses if any row already disagrees, so a constraint never exempts the rows that were there before it, and DROP CONSTRAINT (by name or by the canonical Label.property descriptor) withdraws it.

    The accepted types are BOOLEAN, STRING, INTEGER, FLOAT, DATE, LOCAL DATETIME, DURATION and POINT — the Neo4j type names with an exact KGLite value counterpart. Anything else (LIST<...>, unions, zoned temporal types, decorated forms) keeps the explanatory rejection, which now names the supported set: a name KGLite cannot enforce exactly is refused rather than approximated. Matching is strict — an integer does not satisfy FLOAT — and, as in Neo4j, a null or absent value satisfies every type, so combine the constraint with IS NOT NULL when presence is also required. Where a declared type and a schema-locked graph’s recorded property type both cover a property, the declaration wins and the error names the constraint.

    SHOW CONSTRAINTS and CALL db.constraints() gain Neo4j 5’s propertyType column — the declared type on a NODE_PROPERTY_TYPE row, null on every other kind — in Neo4j’s position, last. A declared type is its own row, never folded into a uniqueness or existence row, because a property can be UNIQUE and typed. describe() annotates a constrained property with declared_type="INTEGER" alongside its existing constraint= attribute, so an agent sees the requirement before planning a write.

    Format note: a .kgl file containing a property-type constraint does not load on 0.16.3 or earlier. Files that declare none are byte-identical to before.

    Result-shape note: any code reading SHOW CONSTRAINTS / db.constraints() rows positionally, or asserting an exact column set, sees one additional column.

  • Change data capture — an opt-in change stream, read through CALL db.cdc.*. CALL db.cdc.enable() starts recording; every committed change to a node or relationship is then published to a bounded in-memory log that CALL db.cdc.query({from: <cursor>}) reads back, oldest first. Cursors are opaque strings from CALL db.cdc.current() (the newest change — start here to see only what happens next) and CALL db.cdc.earliest() (the oldest still retained), so a consumer keeps its own position and the engine keeps no per-consumer state. Events carry the operation (create/update/delete), the element type, the logical identity — (nodeType, nodeId) for a node, (relationshipType, srcType, srcId, tgtType, tgtId) for a relationship — and the after-state as a map. Works on in-memory and mapped graphs, durable or not; storage='disk' is refused, because a disk graph’s change boundary is its generation publish rather than the per-commit capture this is derived from.

    A change that was not committed never appears. Events are derived from the same write-capture buffer the write-ahead log uses, at the same commit boundaries, so a rolled-back statement or transaction contributes nothing — not a filtered-out event, but no event at all.

    Retention is a ring of 65536 events by default and configurable with CALL db.cdc.enable({capacity: N}), which also resizes a running log in place without invalidating live cursors. A consumer that falls further behind than the retention gets a typed refusal naming both remedies (resync from earliest(), or raise the capacity) rather than a silently truncated answer. The log is process-local runtime state and is deliberately not saved into the .kgl: a reloaded graph starts a new epoch, and a cursor from a different epoch is refused rather than resolved against different data.

    Divergences from Neo4j’s db.cdc.*, which this deliberately tracks where the concepts line up: id and seq mean the same things, but txId and metadata are absent (KGLite assigns no durable transaction identity and records no per-transaction metadata), the event map is flattened into columns so YIELD nodeType, operation filters directly in Cypher, and state is the after-image only — Neo4j’s {before, after} shape needs before-images, which are not in this release. db.cdc.enable/disable have no Neo4j counterpart at all: enablement there is a database option.

    Every binding reaches it through Cypher — there is no new Python or CLI method. The wheel and the kglite shell now publish at each statement’s commit boundary, so kg.cypher("CALL db.cdc.enable()") followed by ordinary writes fills the stream on a plain in-memory graph, not only on a durable one; a Transaction publishes its whole batch at commit() and nothing on rollback(), and a write behind a held ResultView (which forks the graph copy-on-write) publishes exactly once. Bolt sessions already published through the session commit path. In-memory and storage='mapped' graphs serve identical streams.

    What capture costs while it is on (measured, release profile, Apple M4): the cost is one buffered op per mutation plus the loss of the checkpoint-free-mutation fast path, so it scales with ops written, not with statements run. A bare CREATE pays +33% (2.25 -> 3.00 us), MERGE that creates +8%, SET by id +3-7%, and a MERGE that matches an existing row and writes nothing pays 0%. Bulk loads pay most — +52% for a 1000-row add_nodes and +82-88% for a 1000-edge add_connections. The default ring costs ~50-65 MB of resident memory once full at its 65,536 events, depending on how many properties the changed entities carry (measured as the resident-set delta against an identical workload with capture disabled, with the ring verified exactly full). enrichment: 'full' adds the before-images, and its cost tracks that same payload rather than a fixed multiplier: +0.7% when the changed entities carry no properties beyond their identity, +32% on 4-property nodes (~82 MB). A graph that has not called db.cdc.enable() pays none of this.

Changed

  • Result materialisation is up to 1.5x faster on large result sets. The parallel branch that turns projected rows into output cells cloned every value, where the sequential branch moved them — so above the fan-out threshold it did strictly more work than below it. It now moves the values without consuming the rows, which keeps their deallocation off the worker threads: RETURN n over 10 000 nodes is 33.5% faster (3.12 ms → 2.07 ms). (Consuming the rows as well was measured too, and made a RETURN id(n) over the same nodes 46% slower — the row shells’ 40 000 deallocations landing on contending workers — so both halves of this are load-bearing.)

  • Small and mid-size query projections no longer fan out, and got up to 2x faster as a result. The RETURN/WITH projection, window projection and result materialisation loops parallelised at 256 rows. Those loops allocate a bindings map per row, so at small row counts ten threads contended for the allocator instead of sharing work — the fan-out was a net pessimisation. Measured release-mode (median, 4P+6E Apple Silicon): a 499-row projection went 221 µs → 113 µs (1.96x faster) by staying sequential, while 10 000-row projections keep fanning out and keep their 1.40x. The threshold is now 4096 rows, chosen inside the measured crossover.

Fixed

  • A regex (=~) predicate was 6x slower under the parallel runtime, and is now 1.2x faster sequentially as well. Every row resolved its compiled pattern through a process-global cache behind an RwLock, and every row then matched through a Regex whose internal scratch pool is shared between threads — two contended cache lines per row. An 800k-row =~ scan measured 49 ms sequential against 305 ms parallel. Patterns are now cached per thread, compiled once per thread, and borrowed rather than reference-counted at the row: the same scan is 41 ms sequential and 6.9 ms parallel.

  • A cancelled or timed-out parallel query could report “parallel region failed” instead of its real reason. The first worker to notice set the shared failure flag before storing the reason, so another worker that observed the flag inside that window read an empty slot and substituted the placeholder. The reason is now stored under the same lock acquisition that sets the flag.

  • Change data capture now sees writes made through the MCP server. The server’s write tool ran its mutation and returned without draining the capture buffer, so a --writable server that had run CALL db.cdc.enable() reported an empty stream no matter how much the agent changed — and, because nothing drained it, the buffer grew by one entry per mutation for the life of the process. The tool call is now the commit boundary it always was in effect: each successful write publishes its events, and a failed statement publishes nothing (its ops were already rolled back). The Python, CLI, and Bolt paths were unaffected.

  • A declared property type no longer exempts a write from the schema lock’s typo guard. A type constraint can be declared on a property no node holds yet, which leaves that property absent from the observed schema lock_schema() validates against. The SET path treated the declaration as covering both of the lock’s verdicts, so a locked graph accepted SET p.nickname = 7 for a nickname it did not know — the one write the lock exists to refuse. The declaration now yields only the type verdict, as intended; the unknown-property check always applies. A property the schema does know still reports the typed ConstraintViolationError rather than the generic validation error, and the CREATE path (which never carried the exemption) is unchanged.

  • A property-type constraint on a structural field is now decided structurally instead of by scanning data. id, title, and the primary type a node reads back as type are not stored properties, so an empty label had no rows to contradict any declaration and accepted all of them. Two opposite failures followed: REQUIRE p.type IS :: INTEGER installed and then enforced nothing (no write path can check the primary type), while REQUIRE p.id IS :: STRING installed and then rejected every subsequent write, leaving the node type unwritable. Each structural field now has the one type it can ever hold — id is INTEGER, title and type are STRING — and a declaration that disagrees is refused at CREATE CONSTRAINT, with or without rows, naming the field and what it always is. Declarations that agree still install and are satisfied by construction, and aliased id/title fields resolve through their alias. Ordinary stored properties keep the existing-data scan unchanged.

  • A failed replace_connections no longer destroys the edges it was going to replace. The call deletes a source’s existing edges of the given type and then re-adds them from the supplied frame, so a refusal raised after the delete left the graph with neither the old edges nor the new ones. Column presence was already validated up front for this reason, but two refusals still landed on the far side: an unknown conflict_handling mode, and the constraint check that runs when an edge to a missing endpoint auto-creates a stub node. Both now happen before anything is deleted, so a rejected replace leaves the graph exactly as it was. Successful replaces behave identically, including the vivified-stub count they report.

  • A bulk load refused by a constraint no longer records the rejected data’s schema. add_nodes merged the incoming frame’s column types (and its id/title field aliases) into the node type’s observed metadata before checking any row against the declared constraints, so a load the gate refused still left the rejected column’s type behind. describe() then reported a schema the user never accepted, it was saved into the .kgl, and the next conforming load warned about a type mismatch against it — for example loading a string age into a type whose age is an integer was rejected, yet flipped the recorded type to string anyway. Constraint checking now runs as its own pass over the frame before any of that state is written, which is what “a rejected load needs no rollback” already claimed; the within-batch duplicate-primary-key check moved into the same pass, since it aborts the call the same way. Accepted loads are unaffected, and a type that declares neither a constraint nor a primary key does no extra work.

  • A CREATE CONSTRAINT ... IS NOT NULL declaration no longer loses its protection when the graph is saved and reloaded. The list a presence constraint is enforced from lives inside the schema, so KGLite records separately which entries a DDL statement declared — that record is what stops a later, unrelated define_schema() from replacing the schema and silently un-enforcing the constraint. The record was not written to the .kgl file, so it came back empty on load: after a reload, the next define_schema() dropped a constraint the user had declared in Cypher, with no error anywhere. It is now persisted alongside the other declarations. Files that declare no such constraint are byte-identical to before, and files written by earlier versions load unchanged (their DDL declarations remain indistinguishable from schema-declared ones, as they were).

[0.16.3] - 2026-08-16

Added

  • reload_graph — a no-argument MCP tool that re-reads the served graph file from disk. A --graph server holds its graph in memory for the life of the process, so a .kgl rebuilt by another process (a nightly ingest, an external producer, a kglite script) left every query answering from the bytes loaded at boot with no way to catch up short of restarting the server — and restarting is not something an agent-facing client can generally do. reload_graph re-opens the same path, reports the new node/edge counts, and is registered in --graph mode on read-only servers as well as writable ones (a read-only deployment is precisely the one whose graph someone else rebuilds). It requests no storage mode, so a reload never re-runs a boot --storage conversion, and a failed re-read leaves the current graph serving and returns the error. On a write-enabled server it discards unsaved in-memory changes — call save_graph first.

  • extensions.graph_watch: true — opt-in filesystem watch that refreshes a --graph server automatically. With the key set in the manifest, the server watches the served .kgl and re-reads it on the next graph tool call after another process rewrites it, so a client querying a periodically rebuilt graph stays current without calling reload_graph. The watch callback only marks the graph: the re-read is lazy (a tool call pays for it, never the watcher thread) and single-flight, so many writes between two queries cost one reload, and the sibling temp/lock files an atomic republish creates are filtered out. A failed re-read keeps the previous graph serving and attaches a staleness warning to results; after three consecutive failures the watcher-driven reload goes dormant until a reload_graph succeeds. Off by default, --graph mode only (other modes warn and ignore it), and single-file graphs only — a disk-graph directory logs a boot warning and starts no watcher, with reload_graph still covering it.

  • extensions.tools_allow: [...] — a closed-by-default MCP tool surface. A server’s tool list has always been the union of everything that happened to register: framework builtins, the mode’s source tools, KGLite’s graph tools, manifest Cypher tools, downstream domain routes — and routes that arrive from the environment, since an ambient GITHUB_TOKEN exported for unrelated reasons registers github_api, github_issues, and screen_stargazers on a server whose manifest never mentions GitHub. A deployment that wanted three tools could not express that: the things to remove were owned by other layers and the list grew from the outside. Naming the allowed tools in the manifest now pins the whole surface — everything else is hidden (unlisted, and rejected when called by name), so no dependency, credential, or mode change can widen it without an edit to that list. Matching is against the final, agent-visible names, so a tools: rename is honoured; naming a tool that did not register in this boot is a deliberate no-op, which keeps one manifest valid across environments where GitHub tools, write-lifecycle tools, or the code tools come and go. The allowlist only removes: a route another rule hid stays hidden. A configured extensions.cypher_recipes must keep its two fixed routes, and a malformed value fails boot rather than silently leaving the surface open.

Changed

  • GitHub MCP tools now require an explicit builtins.github: true opt-in (mcp-methods 0.4.5). github_issues, github_api, and screen_stargazers used to register whenever a GitHub token was reachable — a GITHUB_TOKEN exported for unrelated reasons, or one the .env walk-up found several directories above the server’s root, silently added three authenticated GitHub tools to a server whose manifest never mentioned GitHub. A reachable credential is not a declaration of intent, so registration is now off by default and the manifest declares it; the token only decides whether the opted-in tools can actually work. builtins.screen_stargazers is subordinate — with github off it registers nothing whatever its value.

    Deployments that want GitHub tooling must add the key, otherwise the three tools disappear from tools/list at the next restart:

    builtins:
      github: true
    

    Both bundled examples that advertise GitHub tools (open_source_workspace_mcp.yaml, local_code_review_mcp.yaml) now set it. --selftest reports the opt-in as the first thing to check when the tools are absent.

  • A read-only --graph MCP server no longer holds the graph file’s single-writer lock. The server took the cross-process writer lease on the served path at boot and held it for its whole lifetime, so any other process that opened that .kgl the normal way — kglite.open(path), which locks by default — was refused for as long as the server ran, by an error that named a pid but nothing about an MCP server. A read-only deployment never writes the file, so the lease protected nothing there while blocking exactly the workflow it is paired with: rebuilding the served graph in place. External rebuilders can now lock and republish the file while the server serves (pair it with reload_graph or extensions.graph_watch), and several read-only servers can share one .kgl. A torn rewrite arriving mid-load was already refused by the load path’s identity check, which leaves the previous graph serving. Servers that can write the file — --writable, or builtins.save_graph: true — keep today’s exclusive lease, and so do disk-graph directories under any mode, whose columns stay memory-mapped while they are served.

  • A read-only --graph MCP server no longer offers explore or read_code_source when the loaded graph has no Function or Class nodes. Both are code-graph tools: explore pins its entry types and its traversal edge whitelist to code node types, so on a non-code graph it can only ever answer “no match” while still occupying a slot in every agent’s tool list; and read_code_source’s optional node_type argument turned it into a general reader of whatever file_path properties the graph carried, with no code-graph purpose to justify the disk access. Legal, oil-and-gas, music and other data-only deployments now present a tool list containing only tools that can actually do something. The decision is made once at boot, applies to --graph mode only (other modes have no graph yet when tools register), and exempts --writable servers, where load_graph can swap in a code graph at any time. crates/kglite-mcp-server/skills/read_code_source.md documented this behaviour before it existed; its wording is now accurate.

Fixed

  • text_score() no longer stops working after an MCP server swaps graphs. The embedder declared by extensions.embedder was bound once at boot to the graph that happened to be active, and nothing re-applied it afterwards — so the first load_graph or create_graph on a writable server, and every workspace-graph rebuild, installed a fresh graph with no embedder and left every later text_score() call failing with “requires a registered embedding model”. The binding is now held by the server and re-applied to each graph it installs, so one boot-time declaration covers the whole session. An embedder declared before any graph exists is likewise applied to the first graph that arrives, instead of being dropped with a warning.

  • A local-workspace MCP server no longer refuses to boot when its manifest mentions repo_management. Local workspaces activate a directory with set_root_dir, so the GitHub clone-oriented repo_management tool is hidden at startup — but it was hidden by removing the route from the router, and manifest tools: overrides are validated against the routes the router still knows. Any local-workspace manifest carrying a bundled: repo_management entry — hiding it explicitly, renaming it, or just replacing its description — therefore died on every start with “manifest bundled-tool override targets unknown route”. The tool is now hidden by disabling the route instead: it is still absent from tools/list and still rejected when called directly, and overrides naming it resolve normally (a hide override leaves it hidden).

[0.16.2] - 2026-08-16

Added

  • Bolt server: CALL dbms.components(), CALL dbms.showCurrentUser(), and SHOW DATABASES are answered at the server (they report server facts the engine does not hold). dbms.components() follows the server identity: kglite-bolt-server/<version> by default, Neo4j Kernel 5.26.0 under --neo4j-compat — the same switch as the handshake agent, so GUIs that version-gate (Neo4j Browser, G.V()) need --neo4j-compat. Edition is always community. SHOW DATABASES returns one row named neo4j (matching the routing default) with access reflecting --readonly.

  • CALL apoc.meta.nodeTypeProperties() / apoc.meta.relTypeProperties() — APOC-compatibility shims over the db.schema pair, adding APOC’s columns: crucially the rel side’s sourceNodeLabels/targetNodeLabels (one row per observed source/type/target pairing), which schema-graph clients require to draw edges. Scoped to exactly these two names; all other apoc.* remains rejected.

  • Bolt server: EXPLAIN now follows the Bolt contract — zero records, with the plan tree (operators, estimated rows, optimizer passes) in the SUCCESS summary’s plan metadata, so driver summary.plan consumers and IDE plan tabs render. PROFILE executes normally and deliberately reports no per-operator statistics (none are collected; fabricating them would mislead).

  • Bolt server: every incoming query is logged at debug level — run a client at RUST_LOG=debug to capture its exact connect sequence.

  • elementId(entity) scalar function — Neo4j 5 element identity as an opaque string, agreeing with the element_id the Bolt server packs on Node/Relationship structs so clients can round-trip it into predicates. Distinct from id(), which remains the logical (domain) identity.

  • WHERE elementId(v) = <value> is planned as a point lookup (optimizer pass anchor_element_id). elementId() is the node’s slot, so the predicate names exactly one candidate — but the pattern a client sends back after a click carries no label, and an unlabelled MATCH (v) was a full node scan with the predicate applied per row: 28 s for one G.V() node expansion, now milliseconds. The planner records the slot on the MATCH and the executor seeds it as a pre-binding. Both operand orders, a string or integer literal, and a $param are recognised, and only the predicate’s AND spine is read — elementId(v) = $x OR constrains nothing and is left alone. The anchor is a search-space constraint only: the predicate is never removed, so a slot that is out of range or no longer holds the expected node yields the rows the predicate would have yielded anyway. Note the identity’s own caveat, which this inherits from elementId(): a slot is stable for the lifetime of the loaded graph, not across a save/load rebuild, so an element id held across one may name a different node — or none.

  • Standalone CALL proc() without YIELD — the form Neo4j clients and cypher-shell send — now works for every procedure, returning all declared columns in declared order. A bare CALL must be the entire statement; combining it with other clauses still requires YIELD.

  • Java: per-query timeout and row-budget overloads on query/cypher. query(cypher, Duration timeout), query(cypher, params, timeout) and query(cypher, params, timeout, long maxRows) (and the matching cypher(...) write-path overloads) bind the C ABI’s kglite_session_execute_read_opts / kglite_session_execute_mut_opts. timeout past which the statement returns a CypherTimeout error; maxRows a runaway-result guard that errors on overflow rather than truncating (add a LIMIT to bound output). Following the C ABI, a null/zero/negative Duration and a maxRows of 0 both mean “unlimited” — 0 is not “expire immediately”.

  • Java: KnowledgeGraph.storageFormatVersion() returns a StorageFormat record — the .kgl on-disk snapshot format version plus the write-ahead-log frame format versions — over a new additive C ABI function kglite_storage_format_version(). This is the persisted-format lifecycle, distinct from the engine SemVer reported by nativeAbiVersion().

  • Java: KnowledgeGraph.openReadOnly(Path) (and openReadOnly(Path, StorageMode)) open a graph with a wrapper-enforced read-only guard: query() works, while cypher() and beginTransaction() are refused with a ReadOnlyGraphException raised before the call crosses into native code. A convention-level guard on the handle, documented as such — the engine has no read-only open mode, so it does not lock the file against other processes.

Changed

  • id(n) on a bound node no longer materialises the whole node. The id() scalar previously evaluated its argument into a full Value::Node (id, title, and every property) to test for a relationship, then discarded it before reading the id. For a bound node variable it now reads the id column directly, ahead of the relationship fallback — MATCH (n:Person) RETURN id(n) over 10k nodes drops ~43% (1.43 ms → 0.81 ms, release). Semantics are unchanged for every argument shape, including id(r) on a relationship and id(head(relationships(p))) on a relationship-valued expression.

  • Breaking (contract fix): CALL YIELD result columns now follow YIELD order (Neo4j semantics). Previously they were inferred from the first row and sorted alphabetically, so YIELD type, name answered [name, type].

  • A CALL that yields zero rows now still reports its declared columns — previously a Bolt client’s result.keys() was empty for e.g. CALL db.indexes() on a graph with no indexes.

  • CALL db.schema.nodeTypeProperties() / db.schema.relTypeProperties() — Neo4j’s typed-schema pair, measured as the calls G.V()’s data-model load makes: one row per (type, property) with propertyTypes in Neo4j’s type vocabulary (Long/Double/String/…); a property-less type emits one row with null propertyName. SHOW PROCEDURES additionally yields a signature column (not in the default set, matching Neo4j) — the exact YIELD name, description, signature G.V() sends now answers.

  • CALL db.schema.visualization() — Neo4j’s schema-graph shape: one row of virtual nodes (one per label, with name/indexes/constraints properties) and virtual relationships per observed (source label, type, target label) combination. This is what Neo4j Browser’s schema tab renders.

  • SHOW PROCEDURES [YIELD …] lists every procedure with Neo4j’s default columns (name, description, mode, worksOnSystem). It reads the same registry as CALL list_procedures() and CALL YIELD validation, fixing a drift where list_procedures advertised db.labels as yielding name (the real column is label).

  • SHOW FUNCTIONS [YIELD …] lists every callable function with Neo4j’s default columns (name, category, description), and yields signature and aliases on request. G.V() sends SHOW FUNCTIONS YIELD name, description, signature on connect (measured) — until now a syntax error, which left the IDE’s function autocomplete empty while label and procedure autocomplete worked. One row per canonical name: an accepted alternate spelling (toUpperCase for toUpper, ln for log) is reported in that row’s aliases list rather than as a row of its own. The listing is not a hand-maintained list that can drift from the engine: a Rust test executes every registered name and alias through the real dispatcher chain and fails the build on any entry the engine does not answer, and the aggregate category is checked against the parser’s aggregate classifier in both directions.

Fixed

  • Bulk-loaded edge properties (add_connections) now record their observed types instead of registering everything as “Unknown” — typed rel schemas reach every schema consumer (the Cypher CREATE path already did this). Graphs written before this fix keep their recorded “Unknown”s until edges are rewritten.

  • The Bolt EXPLAIN plan root now carries args["string-representation"] (a rendered text plan, Neo4j’s convention) — G.V()’s plan tab reads the key unconditionally and threw without it.

  • MATCH p = (n:Label) RETURN p (a zero-length path) now binds p to a one-node path instead of null — measured live: G.V()’s Data Explorer sends exactly this shape and showed “No results” against real matches.

  • Documentation now states Bolt-client compatibility as measured: the CI-tested drivers (Python/JS/Java) connect unchanged; Neo4j Browser needs --neo4j-compat; LangChain’s Neo4jGraph needs refresh_schema=False plus a hand-supplied schema (its refresh path requires APOC, which KGLite does not ship); a USE clause is a syntax error (the session-level database= field is what is accepted and ignored).

  • Trailing tokens after a MATCH pattern are now a syntax error. Previously MATCH (n) bogus tokens — including a typo’d keyword like RETRUN n — silently executed as MATCH (n), running a different query than written.

  • Aggregates nested inside wrapper expressions in RETURN/WITH projections now evaluate correctly instead of failing with “Aggregate function … cannot be used outside of RETURN/WITH”: map literals (RETURN {c: count(*)}), list literals (RETURN [count(*)]), negation, CASE results, comparisons (count(*) > 2), map projections (n {.x, total: count(*)}), and list comprehensions over collect(...). Grouped and WITH forms included; the zero-row case now yields one row ({c: 0}) per Cypher aggregation semantics. This also unblocks Neo4j Browser’s connect-time metadata queries over the Bolt server.

  • collect(x)[..k] (a slice over an aggregate) now returns a real list, matching the scalar slice path — previously it serialized to a JSON string, which broke any consumer expecting an array (including Neo4j Browser’s sidebar label list).

  • Node lookup by id answers the same rows in every spelling. MATCH (n {id: 2}), MATCH (n {id: $x}), WHERE id(n) = 2 and WHERE id(n) = $x all denote “every node whose id is 2” and could return different results. Two causes, both fixed: the untyped {id: …} anchor returned as soon as the first node type’s id index answered — so on a graph where the same id exists under several labels it reported one arbitrary node, arbitrary because the type map’s iteration order is a hash map’s and therefore not stable across processes — and it recognised only a literal, so the $param spelling fell past it into the exhaustive scan and answered the complete set. Separately, id(n) = $param (either operand order) was not extracted as a pushable equality at all, so the literal and the parameter also took different plans. The anchor now unions one node per type, in ascending node order, and resolves parameters; a measured 1-row-vs-68-rows divergence between the two spellings is gone.

[0.16.1] - 2026-08-15

Added

  • dot(a, b), cosine(a, b) and norm(a) — vector math over ordinary list properties. vector_score and embedding_norm read the registered embedding store; these three read whatever list-valued data a query has to hand — a stored list column, a list literal, a $param bound to a list, a collect() — so vectors that live in the graph as plain data are queryable without registering a store or an embedder, and two nodes’ own vectors can be compared against each other. Available to every binding through Cypher. A null argument (including a missing property) makes the call null, so a partially-vectorised corpus still returns its rows. Three cases are errors rather than a quiet null, because each describes a data bug that a null would hide inside a column of otherwise plausible scores: vectors of different lengths (the message names both — Neo4j’s vector.similarity.* family likewise compares only equal dimensions), a non-numeric element (the message names the vector and the position; Neo4j’s GDS substitutes 0.0 for a null element and we deliberately do not, since a zeroed component changes the answer without changing its shape), and a non-list argument. cosine of a zero-length vector is null0/0 is undefined — which differs from vector_score, whose 0.0 exists because a top-k ranking needs a total order.

  • kglite_define_schema — declarative schemas from the C ABI, so a Java, Go or .NET consumer can declare primary_key, unique, required, types, layer and auto_timestamp instead of reaching for Cypher DDL for the parts it covers. It takes the same schema document Python’s define_schema takes, parsed by the same core function (kglite::api::schema_from_json / schema_from_value) rather than a C-ABI-local walk. That matters because a published C signature never changes within an ABI major: had the C surface shipped the serialized {"node_schemas": {"T": {"required_fields": …}}} shape it would have created a second, permanent schema dialect. The Python wrapper now delegates to the same parser, so the two cannot drift; every refusal keeps the Python exception class it always raised (TypeError / KeyError / ValueError), and a shape PyO3 used to reject with a generic extraction message now says which key was wrong. mode is "merge" (the default, and what null means) or "replace"; an unrecognised spelling is refused rather than defaulted.

  • kglite_writer_lease_acquire_ex — the writer lease’s holder as data. The existing symbol reports a refused acquisition only through out_error_msg: a sentence written for a human, with the pid and the acquisition time embedded in it. The new symbol adds out_holder_json, carrying {"pid", "since", "self", "message"}pid/since null when the record could not be read (it is published just after the lock is taken, so a contender losing a startup race sees an empty one), and self true when the holder is the calling process itself, which is an un-closed handle in the caller’s own code rather than another deployment. kglite_writer_lease_ acquire is unchanged and both symbols run one shared body, so they cannot disagree about a status code or a message. Additive-only per the ABI rule. In Rust the same detail is GraphWriterLease::acquire_exResult<_, LeaseRefusal> with a public LeaseHolder { pid, since }; acquire is that, projected to its io::Error. The Java wrapper’s holder() still returns the whole prose paragraph; the Java wrapper now binds the new symbol and adds the fields beside it (below).

  • Java: WriterLeaseHeldException.pid(), .since() and .self(). The refusal has always named the holding process — inside a sentence, which a caller wanting the pid had to regex and then re-regex every time the wording improved. These read the structured record kglite_writer_lease_acquire_ex returns instead: pid() and since() (RFC-3339, so a retry policy can back off longer for a lease taken hours ago) are null when the holder’s record could not be read, and self() distinguishes an un-closed WriterLease in the caller’s own JVM — a different problem with a different fix — from another deployment. holder() is unchanged. A malformed holder record degrades to absent fields rather than turning a retriable refusal into a parse failure.

  • Auto-vacuum’s state is readable, and relationship churn is now garbage it can see. graph_info() gains auto_vacuum_threshold (the configured value, or None when disabled — set_auto_vacuum was write-only), auto_vacuums_run (how many times it has fired on this graph object; not persisted), and edge_capacity / edge_tombstones. The edge numbers are a third, independent garbage population: a workload deleting only relationships (MATCH ()-[r]->() DELETE r) leaves every node alive and every property-column row referenced, so both existing readings stayed clean — measured at 500 of 1,000 edges deleted, fragmentation_ratio 0.000, no auto-vacuum possible, and an explicit vacuum() returning a no-op with every freed edge slot still held. Auto-vacuum now takes the worst of the three ratios, vacuum() reclaims edge slots even when the node slots are clean, and it reports edge_tombstones_removed alongside tombstones_removed. fragmentation_ratio stays node-shaped so its documented meaning does not change under existing callers. A DETACH DELETE workload can now auto-vacuum sooner and more often, because the edge population fragments faster than the node population whenever the node set carries ballast the edge set does not: on the delete-lifecycle fixture (S comments + S/10 issues, one edge per comment, 160 delete batches) the schedule moves from three fires at batches 67/113/146 — the node ratio b/220 — to four at 61/103/133/154, the edge ratio b/200. Both are exactly 0.3-crossings; the earlier one was simply invisible before.

  • Dynamic labels and relationship types — a parameter can supply a name, not just a value. MATCH (n:$label), the Neo4j 5 spelling MATCH (n:$(label)), -[:$type]->, CREATE (n:$label {…}), SET n:$label, REMOVE n:$label, WHERE n:$label, secondary labels ((n:A:$label)), type alternations (-[:A|$type]->), and the same inside EXISTS { }, COUNT { }, MERGE, FOREACH and CALL { }. This removes the last position in a query that a caller had to escape by hand: a label could previously only be spliced into the query text, so every caller building a query from user input owned an injection surface. Bound as a parameter, the value is a name by construction — it is written into an already-parsed query, so no spelling of it can change the query’s shape, and a value naming no existing label matches nothing exactly as the literal spelling would. The parameter is bound before planning, so a dynamic label plans, uses indexes and performs identically to the literal form (its EXPLAIN is byte-identical). Deliberate limits, both erroring rather than guessing: $(...) takes a parameter name and not a general expression, and the value must be a string — Neo4j’s list forms that expand into several labels or an alternation are rejected. A missing parameter is an error, not an empty result.

Changed

  • add_connections reads the endpoint types’ id index in place instead of copying it — the per-call cost no longer grows with the graph. Release, two agreeing runs, in-memory and non-durable, against an unchanged-path control: 24 000 edges over two connection types, into a single node type, with the edge count held fixed while the node count grows. Without edge properties 3.34 -> 2.09 ms at 20k nodes, 8.05 -> 2.93 ms at 100k, 20.85 -> 4.15 ms at 400k; with three property columns 6.50 -> 5.23, 11.53 -> 6.15 and 23.73 -> 7.51 ms. The old shape is visible in the 0.16.0 column: 6x the nodes cost 6x the time to add the same edges. Every call materialized a fresh id -> node map over the whole endpoint type before looking at its first row — 53% of a property-free call at 100k nodes in the profile, and the reason a second add_connections over the same type paid for the same map again. Rows are now resolved against the live index in one pass, ahead of the mutating pass (the two cannot hold the graph at once), which is snapshot-equivalent: the map it replaced was also built before the first mutation. Types whose index is not heap-resident — an unmutated type of a loaded disk graph — keep the previous path, whose mmap-resident probes are a different trade. One degenerate case changes answer: a type holding both a Float64 and a UniqueId spelling of the same numeric id on different nodes now resolves an Int64 edge endpoint the way every read path already did (MATCH {id: ...}, MERGE), rather than the other way round.

  • A LIMITed relationship pattern stops building start nodes it will never reach — 3.2x on the tracked multi-binding projection. Release, two agreeing runs, with a same-graph node-only control that stays flat (42.7 -> 43.1 µs): MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN a, r, b LIMIT 100 over 10k nodes / 30k edges, 305 -> 96 µs; the same shape at LIMIT 10 35.0 -> 13.5 µs. The candidate start set is deliberately capped at 100x the requested rows so a sparse pattern can still fill the limit, and every one of those candidates was turned into a match record up front — 10 000 of them to answer for 100, where building and dropping them was 60% of pattern execution. The expansion now builds each start record as it reaches it, so the early exit that already bounded the work now bounds the allocation too. Uncapped patterns are unchanged (they consume every candidate anyway).

  • add_nodes maintains a type’s indexes and unique constraints instead of rebuilding them — 170-219x on the upsert and constrained shapes. Release, two agreeing runs, against an unchanged-path read control and a create-only reference cell that stays flat (86 -> 83 µs, inside the session’s own 4% drift): a ten-row upsert into a 200k-row type carrying a hash and a range index 14.35 ms -> 85 µs (170x); a ten-row append into a 200k-row type under a UNIQUE constraint 22.99 ms -> 105 µs (219x); the two together (an upsert on a constrained type) 22.41 ms -> 105 µs (213x). The path already folded creation-only batches; it declined outright when any row updated an existing node, and whenever the type carried a UNIQUE or non-id PRIMARY KEY tuple, and each decline rebuilt every covering index from every member of the type. Both declines are gone: one read pass per updated node, taken before the batch writes, captures the old value of each indexed property and the tuples the node occupies, so an update can vacate the bucket it leaves and release the tuple it gives up — and a row that re-asserts the value it already had touches no bucket at all, which is the common upsert. Occupancy for created rows is claimed from the node as stored, so a conflict_handling mode that skipped or merged a write cannot make the index disagree with the data.

    • What still rebuilds, deliberately: a batch whose row count is a large fraction of the type, and a batch that turns out to move a large fraction of it. Reading a pre-image costs something and vacating a bucket walks it, so folding is O(rows) reads plus O(moves x bucket) against the rebuild’s one linear pass — cheap for a streaming upsert, quadratic for a full re-load. Two gates pick the cheaper path per call (both test-pinned in both directions), so a whole-type re-load keeps exactly the cost it had.

    • One visible behaviour change: when an upsert changes an indexed value, the moved node joins the end of its new value bucket rather than landing in member order. Bucket order is the row order an indexed MATCH without ORDER BY returns, so such a query can order those rows differently than before — the same divergence a Cypher SET has always produced. Rows whose indexed value did not change keep their position exactly.

  • A Cypher SET resolves its write target once per statement, not once per written row. The row loop re-derived the store handle, the type key, the property key and the key’s column slot for every row — a type-name hash, a column_stores probe for the declared-type check, a second probe for the write, an interner registration and two TypeSchema lookups — and then read the cell’s prior value that only REMOVE consumes, which is an allocation per row for a string property. A 100k-row SET of a string property is 8-11% faster (release: 32.05 ms before, against eight runs across four sessions after — every one of them faster, 28.52 ms on the landed build and 31.09 ms at worst); the same statement writing an Int64 moves within that cell’s between-session spread, which is the expected shape — the removed read is a clone, so what it returns scales with the value. The two Arc::make_mut uniqueness checks per written cell are unchanged: they are the price of the copy-on-write sharing a fork and a held view depend on.

  • Filtered scans and describe read the column, not the row. Both were asking a columnar store the same question once per row and re-deriving the machinery each time; both now resolve it once and walk the column.

    • describe / describe_types property statistics: 4.0-4.3x faster (release, in-process A/B over the same fixture, two agreeing runs; 5k nodes x 12 wide string properties 1.71 -> 0.43 ms, 50k 17.9 -> 4.2 ms). The row loop materialised every property of every node as an owned Value to increment a counter and probed the accumulator map once per (row, property); the column-major pass probes once per property, and a property whose distinct set is already capped is counted from its null byte without building a Value at all. Output is unchanged — pinned by an equivalence test that runs the same fixture through both routes and compares counts, distinct sets and enumerated values, across sampling, capped and uncapped thresholds, relocated strings, a grown schema and tombstoned rows. Declines to the row loop where a row resolves through more than its dense columns (mapped-mode mmap base, the disk loader’s overflow bag, a node whose properties are still inline).

    • Property-filtered MATCH scans: up to 1.44x. Two agreeing release runs at 50k rows, against an unchanged-path control: suffix filter on a type’s title 1.43-1.44x, on a stored string property 1.37-1.40x, CONTAINS 1.16-1.28x, numeric range 1.29-1.32x, > 1.15-1.16x, float > 1.32-1.34x, boolean equality 1.11-1.12x, string equality flat (its byte fast arm was already the tuned path). The per-row work removed is the field-name comparisons, the schema hash probe that resolved the property’s column slot, and the store’s bounds and tombstone re-checks — all functions of the node’s type, which a scan already resolves once. Part of the numeric gain (6-13% on the four numeric cells) is the column’s heap/mmap dispatch being taken once per scan rather than three times per row, the hoist string columns already had. Predicate semantics are the row route’s own two functions, called from the new path rather than reimplemented, and a differential sweep of 33 queries x every column shape (including relocated strings, tombstones, delete-then-create, a Mixed list column, NaN floats, sparse columns and row-storage graphs) asserts the two routes agree row for row.

  • BREAKING (JSON output shape) — nodes, relationships, paths, dates, points and durations are real JSON now, not Rust Debug strings. The shared outbound converter kglite_value_to_json had a catch-all arm that rendered every variant it did not name through {:?}, so RETURN n reached a JSON consumer as the string "Node(NodeValue { id: 7, labels: [\"T\"], .. })" — the engine’s own pretty-printer, on the wire. That hit three shipped surfaces at once: the C ABI’s kglite_cypher_result_rows_json, the CLI’s --mode json / --json, and the MCP server’s recipe-query results. (Bolt and the Python wheel were never affected — both build their own structured values.) The shapes now mirror the Python binding’s exactly, so the same query read two ways has the same field names: Node{"id", "labels", "properties"}, Relationship{"id", "start", "end", "type", "properties"}, Path{"nodes", "relationships"}, DateTime/Timestamp → ISO-8601 strings, Point{"latitude", "longitude"}, Duration{"months", "days", "seconds"}, UniqueId/NodeRef → numbers. Filed as Changed rather than Fixed because a consumer that was parsing the Debug strings — or merely storing them — now receives objects and numbers where it received strings; nothing else about a result changes. The match is exhaustive with no catch-all, so a future Value variant has to choose a JSON shape at compile time instead of inheriting the leak.

  • compact()’s documentation now says what it does. It merges a disk graph’s overflow edges into the CSR arrays and has never touched a single property row, but its name and docstring read as general-purpose compaction — so “call compact() to reclaim deleted rows” was a reasonable and wrong reading. The docstring, the guide and the vacuum() reference now name the three mechanisms separately: compact() for overflow edges, save() for dead columnar rows, and nothing at all for freed node slots.

  • BREAKING (Rust API) — DirGraph::check_auto_vacuum returns Option<NodeRemap> instead of bool. None means no vacuum ran; Some(remap) carries the old new node mapping the compaction produced, so a caller holding node indices can follow it. The bool was a footgun in the exact case that mattered: on the disk backend vacuum() is a no-op — its CSR arrays are frozen mmap, with no petgraph slot to compact — yet the trigger still answered true, and its caller read that as “indices moved”. A returned mapping cannot lie that way: NodeRemap::describes_rebuild() distinguishes a no-op from a rebuild, including a rebuild whose survivors numbered zero. NodeRemap gains describes_rebuild(); kglite::api::CurrentSelection gains remap_indices(&NodeRemap); the GraphRead trait gains edge_bound(), implemented by all five backends.

  • BREAKING — a durable graph refuses a caller-supplied duplicate id. On a graph opened with durable= (or written through a durable Session), CREATE (:T {id: 1, …}) for an id that node type already carries now fails with an actionable error instead of being accepted. It was never really accepted: the write-ahead log names every entity — nodes, and both endpoints of every edge — by its logical (node_type, id), so a log in which one id denotes two nodes is unwritable, and reopening the graph folded the two into one. A node disappeared across recovery, with nothing at write time to say so. The error names the two routes that work — MERGE to upsert, or a declared primary_key (define_schema) to enforce identity in every storage mode. Migration: an application that supplies its own ids and relied on duplicates should switch the duplicating statement to MERGE, or give the second node a distinct id. Non-durable graphs are unaffected: id uniqueness stays opt-in there, and two CREATE (:T {id: 'k'}) still make two nodes. add_nodes is unaffected in every mode — it upserts by id by construction.

  • The Cypher write path honours a type’s declared id/title field names. After add_nodes(df, 'Person', 'person_id', 'person_name'), every read route resolves those spellings onto the node’s identity fields — but the write path did not, so CREATE (:Person {person_id: 99, person_name: 'C'}) stored both as ordinary properties beside an engine-minted id and a fabricated Person_3 title. Because the dot read resolves the alias to the identity, p.person_id then answered with the minted id while properties(p) showed 99 — one node, two answers — and p.person_name returned the engine’s fabricated string over the caller’s own value. CREATE and MERGE’s create arm now promote those values into the identity fields (the key leaves the property map, exactly as add_nodes keeps its unique_id_field / node_title_field columns out of the property columns), MERGE’s match arm resolves them, and SET / REMOVE route a write spelled with the title field to the title. A SET or REMOVE on the id field is refused as immutable, the same answer SET n.id has always given. Supplying both id and the type’s own id spelling with different values is refused rather than silently resolved one way. Declared constraints and indexes keep the spelling their declaration used.

  • Cross-type ordering is now total, and follows Neo4j 5. Sorting no longer has an “incomparable” outcome: when two sort-key values are of different types they are ordered by type, ascending map < node < relationship < list < path < date/datetime < duration < point < string < boolean < number, with NULL last ascending (subject to the clause’s NULLS FIRST/LAST, which still wins). Within the number rank integers and floats compare numerically, never by variant; dates and datetimes share a rank and compare chronologically with a date counting as midnight; lists compare element-wise then by length; maps entry-wise then by size. ORDER BY, ORDER BY ... LIMIT (the fused top-K), window OVER (ORDER BY ...), min(), max() and the fluent sort= all use this one order, so they cannot disagree — min(x) is now exactly x ORDER BY x ASC LIMIT 1. This changes the emitted row order for queries whose sort key held more than one type; a single-typed key (the normal case) is unaffected. Two deliberate deviations, both documented in CYPHER.md: Point and the internal node handle have no slot in Neo4j’s list and take one here, and Neo4j applies a different, aggregate-specific rule to min/max on mixed input (numbers below strings below lists) which KGLite does not — one order everywhere was chosen over matching that quirk. Value comparison is untouched: WHERE a < b across types still yields no row, per Cypher’s three-valued logic. Ordering became total; comparison stayed partial.

  • Rust API: kglite::api re-exports Direction, NodeIndex and EdgeIndex. They were already unavoidable in the curated surface — edges_directed, count_edges_filtered and fluent::filter_by_connection all name Direction, and every slot handle is a NodeIndex — so an embedder had to add a direct petgraph dependency and pin the same major the engine links, a mismatch surfacing as a type error at the call site rather than a version warning. The version coupling is now the engine’s to carry.

  • Rust API (BREAKING): NodeData::id and NodeData::title are no longer public fields. Read them through the existing id() / title() accessors, or — for a resolved value — through GraphRead::get_node_id / NodeView::id. The fields were a footgun since 0.16.0 made every ingest path columnar: on the memory and mapped backends the inline field holds a Value::Null sentinel while the node’s identity lives in its type’s column store, so a field read returned Null with nothing to warn the caller. The accessors carry that contract in their docs and name the resolving reads. NodeData::new / new_preinterned still take id and title by value; node_type stays public.

  • Rust API (BREAKING): TypeLookup::from_id_indices is removed. It had no caller in the workspace — add_nodes uses TypeLookup::new and the edge path uses CombinedTypeLookup::from_id_indices, which is unchanged — and its fast path materialised a whole type’s id map, the cost this release removed from add_connections.

Fixed

  • Opening a file that is not a kglite graph no longer blames an old kglite. Every unrecognised header — a PNG, a CSV, a half-finished download, a mistyped path — produced “This file was saved with an older version of kglite” plus instructions to open it with the old binary and export a portable copy: a false statement about the user’s data, and advice that sends them looking for a binary that never existed. load_file and load_kgl_bytes now discriminate on the container magic: bytes starting RGF really are a kglite container and keep the rebuild-and-re-save path (now naming the version it found), and anything else is refused as “not a kglite graph”, naming the path and the first bytes it saw (hex, plus the ASCII spelling when printable — which is how you recognise your own CSV) and pointing out that a disk-mode graph is a directory, not a file inside one. The v3 hard break and the too-new-container refusal are unchanged. All three entry points shared the message and now share the discriminator; the container magics and their refusals moved to graph::io::magic.

  • CREATE CONSTRAINT IS :: <TYPE>’s refusal advised a key the schema parser ignores. The message pointed at define_schema({'nodes': {'T': {'field_types': …}}}) — the Rust field’s name. The dialect’s key is types, and an unrecognised key is silently dropped, so a user who followed the advice declared nothing and validate_schema() then reported no violations: precisely the enforces-nothing-but-reports-success outcome the refusal exists to prevent. The message now names types and is binding-neutral (no kg. prefix), since the schema route is reachable from every binding now that the C ABI has kglite_define_schema.

  • Reading an element of a stored list costs the element again, not the list. n.vec[i] reads the container through the node’s property store, and 0.16.0’s always-columnar construction moved every CREATEd list out of the storage arm that lends the value and into the one that cloned it — so each element access copied the whole list, and 0.15.11’s fix for exactly this was silently undone. Measured on 200 nodes with 16 subscripts each, release build: 0.19 µs per access at a stored length of 16 rising to 3.95 µs at 1024 — the per-access cost was the list’s length. It is now flat at ~0.11 µs across the same range (37x at length 1024), and the shape most exposed to it is the vector-scoring idiom reduce(i IN | n.emb[i] …). In-memory columnar reads borrow again; mmap-backed and overflow-bag reads still decode their value once, which is inherent to those layouts. A unit test now asserts the borrow rather than the results, so the next storage change that reintroduces the clone goes red on contact — the gate the original fix lacked.

  • A disk save no longer writes the rows deleted nodes left behind. A DELETE on storage="disk" tombstones the node’s slot and leaves its property row in place — the store is append-only under mutation, and vacuum() cannot reclaim it because a disk graph’s node numbering is frozen mmap — so the garbage accumulated for the process’s whole life and then got written, and reloaded, and written again. Measured: a 20,000-node graph with half its nodes deleted wrote 848,890 bytes of columns against 424,445 for the same graph built from the survivors alone (2.00x), and reloading it censused 20,000 rows for 10,000 live nodes. A save now rewrites each type’s columns without the unreferenced rows and renumbers the surviving rows together with the node slots that name them, in the same published generation: the fixture writes 424,445 bytes — byte-identical to its compacted equivalent — and reloads with total rows equal to live rows. Row ids are private to the column file and the slot array, so nothing else moves: node indices, edges, held selections and every persisted index are untouched. Types with no dead rows keep their store, mmap base included. What a save still does not reclaim is the node slots themselves (16 bytes plus a free-list entry per deleted node) — on the same fixture that leaves the published directory 1.29x its compacted equivalent, down from 1.70x.

Auto-vacuum reset the held selection, and a reset selection reads as “no filter has been applied” — so one held handle answered ids() with [] and len() with the whole graph, two contradictory answers about the same set, triggered by a DELETE the caller never asked to touch its selection. Reproduced at 1,000 nodes: select 100, delete 400, len() went 100 → 600 and ids() 100 → 0. Both vacuum() and auto-vacuum now carry the selection through the compaction — survivors keep their place at their new indices, deleted nodes drop out, and after a traversal a group whose parent was deleted is dropped whole rather than re-parented. A vacuum that moves no index (the disk backend, or a columnar-only reclaim) leaves the selection completely untouched; disk used to pay the reset while reclaiming nothing. vacuum()’s documented “resets the current selection” was a limitation of not having the mapping to hand, not a contract — the mapping was there all along and the binding discarded it.

  • A backtick-quoted label spelled like a parameter is a label. MATCH (n:`$label`) failed to parse: the MATCH path re-serializes its token stream for the pattern parser, and a name beginning with $ was written back unquoted, so the pattern lexer re-read it as a parameter reference. Same class as the TRUE-as-a-label trap below — the emitter’s quoting rule now covers every word that lexer would read back as something other than a name, and both directions are pinned against the lexer itself. (Without the fix the new dynamic-label syntax would have made it worse than a parse error: a literal label would have silently become a parameter reference.)

  • TRUE, FALSE and NULL work as names everywhere, or nowhere — the mint-but-never-query trap is closed. CREATE (:`TRUE` {x: 1}) succeeded while MATCH (n:`TRUE`) failed with a syntax error, so a caller could mint a label it could never query back. The MATCH path re-serializes its token stream for the pattern parser, and the backtick escape was dropped in transit: the bare word was re-read as a boolean. Escaping now survives the round trip, and the three words are additionally accepted bare in the name positions — label, relationship type, property key, and the type after a | alternation — matching openCypher, which spells a schema name as SchemaName = SymbolicName | ReservedWord and lists all three under ReservedWord, and Neo4j 5/25, whose labelType : COLON symbolicNameString does the same. So CREATE (:TRUE {null: 1})-[:FALSE]->(:Thing) and the matching MATCH both parse, in both parsers, with the verbatim source spelling preserved as for every other keyword name. Position still decides, and value positions are unchanged: {x: true} is a boolean property, WHERE n.x = true a boolean comparison, RETURN null null. Variable positions are the one place the words stay reserved — MATCH (true:Thing) is still an error, in both parsers, because openCypher’s Variable = SymbolicName excludes them and a bare true in an expression is the literal, so such a variable could never be read back; backticks make it an ordinary variable. The Java query DSL follows: Ident.label("TRUE") and its relationship-type and property-key siblings used to throw IllegalArgumentException as unrepresentable, and now build an identifier that emits bare; a variable named that way still emits backtick-quoted.

  • OPTIONAL MATCH ... WHERE no longer deletes the rows it was supposed to null-extend. The predicate now belongs to the OPTIONAL MATCH, as openCypher’s grammar and Neo4j both define it: it is applied while looking for matches, so a row whose candidates all fail it comes back with the optional variables set to NULL. KGLite parsed the WHERE as an independent pipeline filter and ran it over the already null-extended rows, which deleted them — MATCH (p:P) OPTIONAL MATCH (p)-[:KNOWS]->(x:Q) WHERE x.w > 5 RETURN p.name, x.name returned one row where Neo4j returns two, so an OPTIONAL MATCH carrying a WHERE silently behaved like a plain MATCH. The inline spelling of the same filter (OPTIONAL MATCH (p)-[:KNOWS]->(x:Q {w: 9})) was always correct, so the two spellings of one filter disagreed. This holds whichever variables the predicate mentions: WHERE p.age > 35 on an outer variable now decides whether the optional part matches, and no longer drops the person. count() over such a clause consequently keeps its zero groups. What changes for you: queries of this shape return more rows than before — the null-extended ones — and any code that used a trailing WHERE to filter rows should move it to a following WITH ... WHERE (which filters, unchanged) or onto a plain MATCH. MATCH ... WHERE, WITH ... WHERE and every other WHERE position are unaffected.

  • The unknown-relationship-type warning claimed “returns no rows” about patterns that return rows. MATCH (p)-[:MENTORS|KNOWS]->() on a graph without a MENTORS edge type warned “unknown relationship type ‘MENTORS’ — the graph has no such edge type, so this pattern returns no rows”, while the query matched through KNOWS and returned rows: the warning collector flattened an alternation into one name per branch and lost the context needed to word it. An unknown branch that has surviving siblings is now worded per-branch — “…so that branch matches no edges; the pattern can still return rows via ‘KNOWS’” — and the no-rows claim is kept only when every branch of the pattern is unknown. The “did you mean?” hint is unchanged in both arms.

  • Relationship-type alternation [:A|B] returned answers for one branch only. MATCH (p:Person)-[:KNOWS|WORKS_AT]->(x) RETURN count(*) counted the KNOWS edges and dropped the WORKS_AT ones; writing the same pattern as [:WORKS_AT|KNOWS] returned a different number, and leading with a type the graph does not have returned zero. EdgePattern keeps the full branch list in connection_types and, for back-compat, the first branch alone in the singular connection_type — seven consumers read the singular field and silently narrowed the pattern to that one branch. Every one produced a wrong answer rather than a slow plan:

    • the fused simple counter (count(*), count(r), grouped RETURN x, count(*), undirected -[:A|B]-, and WITH count(*)),

    • the fused two-hop counter ((a)-[:A|B]->(b)-[:A|B]->(c)),

    • the anchored-count fusion (MATCH ({id: V})-[:A|B]->(v) RETURN count(*)),

    • the WITH tgt, count(src) peer-count histogram path,

    • the skip_target_type_check planner annotation — the one that corrupts projections rather than counts: because KNOWS guarantees a Person target, the label check was skipped for the whole alternation and -[:KNOWS|WORKS_AT]->(x:Person) returned WORKS_AT’s Company nodes as if they were Persons,

    • the EXISTS/pattern-predicate fast path (WHERE (p)-[:A|B]->()), which ran identically with the optimizer disabled and so was wrong on both plans,

    • the connection-type inverted index used to pick start nodes, which dropped every start node whose only matching edge was on a later branch (disk and mapped graphs).

    All seven now honour every branch, and duplicated branches ([:A|A]) are deduplicated so the per-branch counters cannot double-count. The join-order cost proxy also charges the sum of the branches instead of the first one. Single-type patterns take exactly the code path they did before. Note that alternation still cannot be parsed inside an EXISTS { } subquery or a size((n)-[…]->()) pattern expression — a pre-existing parser gap, not a wrong answer.

  • A multi-part CREATE fabricated anonymous nodes instead of reusing the variables its earlier parts had just bound. CREATE (a:T {id: 5}), (b:T {id: 7}), (b)-[:E]->(a) produced four nodes — the two you asked for, plus two untyped Node-labelled ones — and wired the :E between the two junk nodes, leaving a and b unconnected. The variable map was rebuilt for every comma-separated pattern part and seeded only from the incoming row, so each part was blind to the ones before it. A variable introduced anywhere in a CREATE is now a reference in every later part of the same CREATE, matching Neo4j. Statements that already worked — a single inline pattern, endpoints bound by a preceding MATCH, and a variable name reused in a later statement (which still creates a new node, since variable scope ends with the statement) — are unaffected. Note that an occurrence of an already-bound variable that carries a label or properties references the bound node and drops them, rather than raising Neo4j’s “variable already declared” error; that is now consistent whether the binding came from MATCH or from an earlier part of the same CREATE.

  • CREATE rejected anonymous relationship endpoints, including CREATE (:A)-[:R]->(:B) — the most common CREATE form there is. Any pattern whose edge endpoint had no variable name failed with “CREATE edge requires named source and target nodes”, so MATCH (h:H) CREATE (h)-[:R]->(:Q {…}) and CREATE (h)-[:R]->() were both unwritable and the node had to be given a throwaway variable. The endpoint node was in fact being created; the edge pass simply had no way to find it, because the record it resolved through was keyed by variable name. Endpoints now resolve by position, so anonymous and named ones behave identically.

  • ORDER BY over a mixed-type sort key crashed the query engine. A sort key holding more than one type — a CASE returning a number on some rows and a string on others, coalesce over differently-typed properties, a property read across two node types — aborted with PanicException: user-provided comparison function does not correctly implement a total order, deterministically from 21 rows up. The comparator skipped a pair of values it could not compare, so a string-vs-number pair reported “equal” while number-vs-number pairs ordered: intransitive, which is exactly what Rust’s sort detects and refuses to run. Through Python it surfaced as a BaseException that except Exception could not even catch; the Bolt server has no catch_unwind, so there the same query was an availability bug. The bounded top-K heap has no such check and silently returned different rows than the same query without LIMIT; min() and max() rejected every candidate whose type differed from the incumbent’s, so their answer depended on which row arrived first. The fluent API’s sort= had the same defect, plus one of its own: it stopped at the first comparable field even when that field tied, so second and later sort fields never broke a tie. All are fixed by one total order (see Changed), now shared by every sorting and min/max path.

  • Numbers past 2⁵³ sorted incorrectly and unstably against floats. The comparison converted the integer to f64 first, so 9007199254740993 and 9007199254740992 both compared equal to the float 9007199254740992.0 while ordering against each other — wrong, and intransitive in its own right. Integers now compare exactly against floats.

  • A blueprint chain sorted a mixed-type order_by column with the same intransitive comparator, so the same 21-row crash was reachable from the blueprint compute path. Its min/max/first/last accumulators shared the comparator and the same first-arrival dependence. Both now use one total order (NULL first, then list < string < boolean < number — blueprint’s own NULL placement is unchanged), defined once instead of copied into two modules.

  • The same partial_cmp(..).unwrap_or(Equal) shape in median() / percentile_*() and in Point ordering is replaced with a NaN-total comparison. No reachable input produces one today — a NaN normalises to NULL on the way into a property or out of an expression — so this closes the class rather than a reproduced failure.

  • Data loss: a disk-mode graph saved after a delete-then-create could never be loaded again. Deleting a node frees its slot and the next create takes it, so the node type’s index bucket stops ascending. type_indices.bin requires every per-type payload to be strictly increasing — the loader validates it, and the mmap membership test binary-searches it with no linear fallback — but the writer emitted the bucket in append order. The result was the worst possible shape: save() reported success, and the next kglite.load() refused the whole graph with “invalid type_indices.bin: node indices are not strictly increasing”. A four-node script reproduced it; a delete-only save and a create-only save both round-tripped, which is why it survived. The writer now emits each payload in ascending node order, and a bucket that lists the same node twice — a reordering cannot repair that — fails the save with a message naming the type instead of shipping a file that only fails on the next load. Sorting cannot move any property value: a disk graph binds a node to its column row through the row id persisted in its slot, and the portable .kgl path that does bind rows positionally never reads this file. Existing unloadable directories stay unloadable — the ordering was lost at write time — but re-saving from a still-live graph now produces a loadable one.

  • NodeData::id() and NodeData::title() no longer claim to read from the ColumnStore. Their rustdoc said “In mapped mode (Null sentinel), reads from ColumnStore” while the body is Cow::Borrowed(&self.id) — it consults no store; NodeView does. The only note saying so was a // comment, invisible to rustdoc and to IDE hover, so the sole documentation a Rust embedder could see promised exactly the resolution the method does not perform. Both now document what they return (the raw stored field, which is the Value::Null sentinel on the memory and mapped backends since 0.16.0 made every ingest path columnar) and point at NodeView / GraphRead:: get_node_id for a resolved read. DirGraph::get_node, which had no doc comment at all, now states the same contract and names the backend asymmetry outright: the disk backend materialises real id/title into its arena copy while memory and mapped return the sentinel, so it is for topology and existence, not values. Reported by codingest, who lost two behaviours to the gap and caught both on their own goldens. The [0.16.0] section is amended with the Rust-API entry it was missing, and its “no user-visible behaviour changes” sentence now says which surface it means.

  • save_subset no longer documents a load(path, storage='disk') call that does not exist. kglite.load takes only a path; passing storage= raises TypeError. The docstrings (Python and Rust) now point at kglite.open(path, storage='disk'), which is the real load-or-create entry point that takes a mode.

  • The Cypher CREATE VECTOR INDEX rejection no longer says vector indexes are reachable only from Python and Rust. Every binding has reached build_vector_index since 0.15.11, through the C ABI’s kglite_session_build_vector_index; the message named two of them and sent Java and C consumers looking for a route they already had.

  • WHERE n.prop = 'literal' disagreed with IN and with <> when the stored string was a single-element JSON list. A row storing '["Oslo"]' satisfied neither n.tag = 'Oslo' nor n.tag <> 'Oslo', while n.tag IN ['Oslo'] matched it — three spellings of one question with two different answers, and one row lost from a partition that must be complete. KGLite treats a single-element JSON string list as equal to its inner string (values_equal), and seven of the eight routes that decide string equality implement that; the eighth — the byte-equality fast arm that answers a bare property equality after the index-selection pushdown claims it — used a plain ==. The same predicate spelled so the planner left it in the WHERE clause returned the other answer, so the two plans disagreed. All routes now share one implementation of the rule (str_values_equal), which also replaces the two hand-copied versions that existed before, and the storage layer’s str_prop_eq documents that its equality is the engine’s rather than str’s. Both directions match (a plain literal against a stored list and a list literal against a stored plain string), on the inline pattern form (MATCH (n {tag: 'Oslo'})) as well as WHERE, and in memory, mapped and disk modes. Ordinary strings pay one byte test, which the JSON arm needs to be entered at all. Documented in CYPHER.md.

[0.16.0] - 2026-08-14

Changed

  • Every graph is columnar from its first node; save() no longer changes the write regime. A graph used to be built one way and saved another: node properties were laid out per node, in a row, and only the first save() (or an explicit enable_columnar()) rebuilt them into the per-type column stores the file format, the memory limit and the mapped/disk modes all require. That made “has been saved once” a permanent, invisible property of a live graph — writes cost differently on either side of it, and a defect reachable only on one side was a defect most testing never reached. Node creation is now columnar on every path and in every storage mode (Cypher CREATE and MERGE, add_nodes and every frame-shaped ingest built on it, WAL replay, the N-Triples loader), so a freshly built graph and a reloaded one are the same shape. save() becomes a consolidation pass that a settled graph skips outright, rather than a mandatory O(N) rebuild, and the regime’s public controls are removed with the regime (see Removed).

  • One property shape, not two. A node’s properties used to take one of two durable layouts — a row-shaped block on the node, or a row in the type’s column store — with the shape decided by how the graph had been built and changed under it by save(). Construction became columnar in every storage mode earlier in this release; the row layout is now deleted outright, so every read, write, undo and save path has one shape to serve. Only a transient staging form survives (values held inline for a moment before they reach a store: disk write-staging, .kgl deserialization, the bulk funnel, the RDF loader), and nothing persists it. Nothing on the Python surface behaves differently; what changes is that the second layout can no longer be reached, so the defect classes that only appeared on one side of it cannot recur. A Rust embedder reading identity off a raw node record does see a change — see the next entry.

  • Rust API: get_node(idx).id() / .title() return the Value::Null sentinel on a never-saved in-memory graph; read identity through node_view. (Amended 2026-08-14, after the release, on a report from codingest — a correction to this section, not a new change.) Because construction is now columnar on every path, the NodeData a DirGraph::get_node hands back carries the sentinel in its inline id and title fields on the memory and mapped backends, where 0.15.13 returned the real values on a freshly built graph. The disk backend materialises real values into its arena copy, so the same call still answers with values there. The resolving readers — GraphRead::node_view (.id() / .title()) and GraphRead::get_node_id — answer identically on all three backends and are the supported route; NodeData::id/title are documented as raw stored-field reads. [0.15.9]’s closing sentence (”NodeData keeps id(), title() and node_type_str()”) described the methods surviving that release’s property-reader removal, and is not a carve-out promising resolved identity reads. No Python or C-ABI surface is affected.

  • A node’s title is written where the node’s other values are. A SET n.title / SET n.name, an add_nodes update or replace, and a connection title all used to write onto the node itself, leaving the column store’s copy stale until the next save() noticed the divergence and rebuilt every store to reconcile it. Title writes now go through the store like any other value, so a title write no longer costs the next save a full rebuild. One user-visible consequence: SET n = {…} clears a node’s title when the map omits it, on every graph. It always did so on a graph that had not been saved, and never did on one that had; the two now agree, on the never-saved behaviour.

  • Adding a property that a node type has never carried no longer rebuilds the type’s column store. Every newly seen property key used to re-push every row already stored into a fresh store, so an ingest stream whose columns widen over time paid for all the rows already loaded, again, per new column; the rebuild also dropped deleted rows’ tombstones, resurrecting them. A new property now appends a single column. Measured on a 500-row batch introducing one new property: 44.6 µs/row with 5k rows already present, 147.1 at 20k and 558.4 at 80k, against 18.3 / 41.6 / 131.3 after the change — the remaining growth is the batch loader’s existing per-row cost and tracks the row-shaped path within 8% at 80k.

  • A graph built in-process with storage="mapped" is actually mapped. storage="mapped" is a zero memory limit, but nothing enforced it on the ingest path, so an in-process mapped graph stayed wholly heap-resident until its first write — only a load() ever produced mapped columns. Measured on a 20,000-row three-column build: 589 kB heap-resident before, 20 kB after (the tombstone bitmap, which has no file form).

  • unspill() and vacuum() rebuild their column stores directly. Both used to reach their end state by de-columnarizing the graph and immediately re-consolidating it, which materialised every row onto its node only for the very next pass to read it back off. They now do the rebuild in one pass, for the same result: dead rows reclaimed, columns back on the heap, the memory limit preserved.

  • vacuum()’s columnar_rebuilt reports whether rows were actually reclaimed. It used to report whether the graph was columnar at all, which was a fair proxy only while a graph could be non-columnar. A vacuum with no dead rows to reclaim now reports False.

  • The .kgl container is now v6, and files this version saves cannot be read by kglite 0.15.14 or earlier. This build reads both v6 and v5, so every existing file keeps loading and a save() migrates it forward; the break is one-way and deliberate. An older binary handed a v6 file refuses it by version number rather than misreading it — 0.15.14 raises kglite.FileFormatError: File uses .kgl container version 6, but this library only supports up to version 5. Please upgrade kglite. for load(), open() and from_bytes() alike. Bundled MCP and Bolt binaries link the engine, so a prebuilt one from an earlier release cannot read files this one writes and must be rebuilt.

    What v6 buys: an integer column is written as zigzag-varint deltas whenever that is smaller than the fixed-width array, chosen per column and recorded in the column’s own type tag, then re-typed to the same in-memory column on load. Nothing above the loader can tell which form a file used. This closes the size regression the identity-column typing opened — the __id__ column became a raw 8-byte-per-row array, which is smaller than the previous postcard encoding at 50k rows but larger at 6k — and goes well past merely closing it. Measured on the three fixtures the program’s file-size goalpost is stated against, against what 0.15.14 writes for identical content: a clean 50k-node build 396,369 → 370,171 bytes (0.934×), a schema-growth ingest stream 52,899 → 41,720 (0.789×), and a 50k-node graph with 40% deleted 312,020 → 133,231 (0.427×). The delete-heavy shape moves furthest because a strided survivor set is what most disturbs the byte-level regularity the fixed-width form was relying on compression to exploit, and least disturbs a delta. Disk-mode graph directories are a separate format and are unchanged.

  • The tracked benchmark cell set is 24 cells, was 27. bench-check’s baselines lose test_bench_columnar_enable (the operation it named — a storage-regime conversion — no longer exists, so its fixed 0.13.2 anchor value is not comparable to anything the cell could measure now) and test_bench_columnar_cypher_{where,match} (their fixture was character-identical to the plain one, so they duplicated test_bench_cypher_{where,match}). test_bench_columnar_save_kgl and test_bench_save_v3 are renamed to test_bench_save_kgl and test_bench_save_kgl_new_file, values carried across — same operation, same fixture. Nothing user-facing; recorded because the committed baseline files changed shape.

  • Text filters, grouped counts and bulk upserts are back at their pre-columnar cost. Moving every graph to the column layout moved the reads with it, and four things that used to borrow a value now copied one. A CONTAINS / STARTS WITH / ENDS WITH / = filter materialised an owned String per candidate row before testing it, and every string read hashed the (almost always empty) string-update overlay first; a grouped count() re-hashed its own property name and re-resolved the type’s column store once per scanned row; and add_nodes resolved every update’s property keys back to strings only to intern them again one call later, an allocation, a hash-map insert and an interner probe per property per row. Filters now test the bytes where they lie, a same-length title rewrite lands in place instead of in the update overlay, and the upsert path keeps its interned keys throughout. Measured against 0.15.14 on the tracked benchmarks (release, min, two agreeing runs): suffix-filtered two-edge path +173% → +9-12%, add_nodes +24% → −3-6%, grouped count top-K +24%/+20% → +3%/−3%, WHERE n.value > +5% → parity. On the same graph read directly against the published 0.15.14 wheel (controls flat), a title ENDS WITH scan goes 2.88× → 1.08×, CONTAINS 1.87× → 1.15×, STARTS WITH 1.93× → 1.17×, a title equality 2.32× → 0.85× and a property ENDS WITH 2.14× → 1.10×. What remains is the column layout’s own read cost — the value lives in its own column rather than beside the node — and closing it needs a vectorised scan, not a further micro-fix.

  • A mutating statement no longer copies the property catalogue. Every statement that can fail after its first write opens a rollback checkpoint, and that checkpoint took a copy of the graph’s whole schema surface — one String pair per declared property of every declared type — so the fixed cost of writing anything grew with how wide the schema was, regardless of how many rows the statement touched. The six schema-scale maps (node_type_metadata, connection_type_metadata, type_schemas, the two field-alias maps, parent_types) and the string interner are now shared copy-on-write: the checkpoint takes a pointer, a statement that changes the schema forks the one map it changes exactly once, and a statement that declares nothing new copies nothing at all. Measured (release, min of two runs, controls flat): a no-match SET on a 200-type × 50-column schema 227.0/225.8 µs → 8.29/8.46 µs, of which 1.9 µs is the identical MATCH without the write — the checkpoint itself is now flat in schema width at 0.5 µs, down from 337 µs at that shape. A 100-type single-row SET goes 18.6/18.3 µs → 5.67/5.83 µs, and a 100k-row SET 46.1/45.8 ms → 41.5/42.7 ms. Rollback semantics are unchanged: a failed statement still restores the pre-statement catalogue exactly, live and through a subsequent save().

  • Appending rows to a large node type costs the rows, not the type. add_nodes derived its per-row “does this id already exist?” answer by materialising the whole type’s id index into a throwaway map, and then threw the real index away and rebuilt it from every node of the type — two O(type) passes per call, however few rows the call carried. Appending ten rows to a 200k-row type spent 84% of its time in index maintenance and 1.4% on the rows. The index is now built once (the first call to a type, as before) and afterwards only the call’s own creations are folded in. Measured (release, min of two runs): a 10-row append into a 200k-row type 9.67/9.65 ms → 83.8/84.4 µs (115×), and one-shot bulk ingest holds at 255.3 → 246.7 µs (−3%). Streaming ingest — repeated small appends into a growing type — was quadratic in the type’s size and is now linear in the rows appended.

  • …and the same for a type’s own indexes. add_nodes also rebuilt every property, range and composite index covering the type, from every member, once per call — so a single index put the cost back that the id-index fix had just removed. A creation-only batch now gives each appended row the same per-node index maintenance a Cypher CREATE gives it. Measured (release, min, 10 rows appended to a 200k-row type): with one property index 6.09 ms → 91.0 µs, with two 29.9 ms → 89.5 µs — the append is now flat in the number of indexes. Batches that also update existing rows, and types carrying a UNIQUE or non-id PRIMARY KEY tuple, keep the rebuild: an update can move a row between index buckets, and unique occupancy is re-derived from live data by design. Index contents and bucket order are identical either way.

  • Deleting one node no longer costs the size of its node type. A DETACH DELETE swept the whole type’s member list with a hashed probe per member, twice when a statement checkpoint was open (once to journal the removal’s position, once to perform it), so removing a single node from a 1M-row type cost 3.9 ms while the same statement against a 1k-row type in the same graph cost 7 µs — and a delete loop was quadratic. The doomed members are now located directly and their gaps closed with a memmove, with the sweep kept as the fallback for the cases that cannot be located (a member list that lost its ordering when a freed node slot was reused) and for deletes large enough that the sweep is the cheaper answer. Bucket order — the row order an un-ORDER BY’d MATCH returns, and what statement rollback restores — is preserved exactly. Measured (release, min of two runs): a single DETACH DELETE from a 1M-row type 3.91/3.91 ms → 3.67/3.62 µs, from a 1k-row type in the same graph 6.88/6.79 → 3.21/3.13 µs, and the tracked single_delete scaling cells go 14.7 µs / 764 µs / 7.94 ms at 1k / 100k / 1M to a flat 8.0–8.8 µs at every size. The cost that remains tracks the deleted node’s position rather than the type’s size: at 1M rows, 3.3 µs deleting the newest node, 35 µs mid-list, 66 µs deleting the oldest.

  • A storage="mapped" graph no longer re-runs its whole spill pass after every statement. set_memory_limit is enforced by a pass that compares the column stores’ heap footprint against the limit; that footprint included bytes no spill can move — the tombstone bitmap, Mixed columns, the string write overlay, the overflow bag — so on a mapped graph (a zero limit) the total was permanently over and the pass never converged. Every mutating statement therefore walked every node type, sorted them by size and made a directory-creation syscall per type, to spill nothing. The comparison is now against the bytes a spill can actually reclaim, and a statement that grew no spillable bytes — an ordinary SET of an existing property, which writes through the mapping — skips the walk outright. graph_info()’s columnar_heap_bytes is unchanged: the floor is excluded from the decision, not from the reading, and the limit still bounds the reported total. Measured (release, min of two runs) on a 100-type mapped graph, single-row SET: 248.3/244.4 µs → 5.0/5.5 µs, i.e. from 47× the same statement on an in-memory graph to parity with it (0.97×).

  • Faster hashing on five hot maps. FxHash replaces the default cryptographic SipHash where the key is already a well-distributed integer or an id value and the map is probed per row or per node: the string column’s relocation overlay, save()’s row-order drift check, the mapped column lookup, the statement journal’s touched-node/edge sets, and the id indices (TypeIdIndex, and the add_nodes conflict-check maps). Measured (release, min of two runs): a 1M-node unchanged save() 46.6/46.3 ms → 37.1/37.8 ms (−20%); the scan penalty a single differing-length string write leaves on its column +34.6%/+39.5% → +2.5%/+3.1% (median, against the same column unwritten — the overlay itself is still not compacted, which is the remaining term); a 30-column CREATE batch 3.94/3.95 → 3.82/3.85 ms. Nothing about stored bytes or result ordering changes — none of these maps reaches a file in hash order.

  • Materialising a node no longer walks its type’s whole declared schema. RETURN n, properties(n), keys(n), n {.*} and the exporters complete a node’s properties with what its type declares but its row does not store — a spatial virtual, a structural name/label/node_type. That completion used to be attempted for every declared property of the type, on every materialised node, with a full property resolution (alias lookup, key interning, store probe, spatial config) whose answer was discarded whenever it came back null: on a type declaring 30 columns of which a row carries 5, 25 discarded resolutions per node. Which properties can be recovered is a fact about the type, not the row, so the pass now visits only the names that can produce something and resolves those exactly as before. Output is unchanged, key for key, including on the sparse rows, alias columns, spatial virtuals and provenance keys that a new lockstep test pins. Measured (release, min of two runs, 20k nodes): properties(n) on a 30-declared / 5-populated type 34.0/33.8 ms → 9.9/9.5 ms (3.5×), which is 1.18–1.22× the same query on 0.15.14 rather than the 3.5× it was; keys(n) on a fully populated 30-column type 60.5/60.5 ms → 43.2/43.0 ms (−29%), now at or just under 0.15.14. (keys(n) still materialises every value to return names — that cost is untouched here and is the bulk of what remains.)

  • CREATE no longer reads the node back to see which property types the type has registered. Each created node materialised its own freshly written row — allocating a vector, a key string and a cloned value for every column the type has, not every column the node carries — to answer a question the create path already answers from the values in hand before the write. The read-back is gone; property-type registration and the type’s own declaration are unchanged, and so is what describe() and the saved schema report for a type created with no properties at all. Measured (release, min of two runs): 5,000 two-property nodes created into a 30-column type 3.80/3.83 ms → 3.20/3.17 ms (−16%).

  • A multi-row SET resolves the facts about its node type once, not once per row — and a type with no index no longer pays for index maintenance at all. Two costs, both per written row, both about the (type, property) pair rather than the row: the write re-derived the type’s name, its updated_at opt-in and its schema-key registration, and handed node_type_metadata a freshly built map naming a property type it had recorded on the previous row; and incremental index maintenance ran in full on types carrying no property, range or composite index — a value read-back, a resolved-field string and a hash probe per index family, to edit maps holding no key for the type. A statement now answers each question once and skips maintenance (and the old-value read that only maintenance consumes) for a type that has nothing to maintain. Index contents, bucket order, unique-constraint occupancy and the property catalogue are unchanged — including the last-write-wins type name a SET whose rows carry mixed value types leaves behind. Measured (release, min of two runs) on a 100k-row SET over a type with no index: 40.8/43.1 ms → 22.0/22.1 ms, i.e. 408/431 → 220/221 ns per row.

  • A multi-node CREATE journals one row-append pre-image per node type, not one per node. The undo for appended columnar rows is absolute — truncate the type’s store back to the row count the statement started at — so the first capture is the whole story and every later one described an intermediate state the first then overrode, at a journal entry and a schema-Arc clone per created node. Rollback is unchanged and now pinned by tests that a last-capture-wins dedup fails: a failed CREATE of several nodes of a brand new type leaves no store, no bucket and no metadata behind, and one into an existing type truncates to its pre-statement row count exactly.

  • A filtered scan resolves what n.prop means once, not once per row. The fused single-node scans — the operators behind MATCH (n:T) RETURN <keys>, <aggregates> and MATCH (n:T) RETURN ORDER BY LIMIT k — evaluated every group key, sort key, aggregate argument and surviving WHERE through the general interpreter, which re-derives per access what is fixed for the whole scan: the row’s variable lookup, the node type’s column store, the id/title alias resolution, and two hashes of the property name. Each scan now compiles its expressions once, against property routes resolved per node type, and reads through a single view per row. Anything the compiler does not model — subqueries, functions, list operations, the disk backend, any graph with a spatial configuration — falls through to the interpreter unchanged, so a shape that is not accelerated is only not faster. Measured (release, min of two runs, 50k-row type): marginal cost per property read in a scanned aggregate 23.0/23.6 → 17.5/17.3 ns, in a top-K sort key 16.1/16.0 → 12.4/12.4 ns; sum(n.a + n.b + n.c + n.d) over 50k rows 5.05/4.78 → 3.88/3.89 ms, sum(n.a) 1.59/1.58 → 1.22/1.23 ms.

  • A WHERE comparison against a text literal no longer copies the value out of the column to compare it. A predicate the planner cannot push into the pattern — <>, an OR-combined comparison, or the safety net retained behind a text-index probe — allocated an owned string per row purely to compare and drop it, which made a string filter measurably more expensive than the same filter on a number. Those comparisons now read the string in place. Equality keeps its full semantics, single-element-JSON-list equivalence included, and a column holding non-strings still falls back to the values the interpreter would have compared (so a date column filtered against a date literal still parses the literal). Measured (release, min of two runs, 50k rows): the gap between WHERE n.city <> and WHERE n.age <> +24.6/+25.3 → −3.3/−4.0 ns per row — the string filter is now the faster of the two; a retained STARTS WITH net 3.67/3.55 → 1.75/1.73 ms, CONTAINS 3.57/3.42 → 1.63/1.60 ms.

  • A scanned aggregate folds its argument’s constants before the row loop. sum(1 + 2 + 3) re-evaluated the addition tree for every scanned row where the materialized aggregation path folds it once, because the fused operator pre-folded its group keys and its WHERE but not its aggregate arguments. Measured (release, min of two runs): a folded-expression aggregate over 50k rows 2.50/2.50 → 0.76/0.78 ms.

  • keys(n) no longer builds the property map it only reads the names off. It was defined as keys(properties(n)) and implemented that way too: the full materialisation pass ran, cloning every value out of the column store into a BTreeMap whose values were then dropped. Names and values now share one collection pass through different sinks, so the key set is still identical to properties(n)’s by construction — the enumeration is what changed, not the answer. Measured (release, min of two runs, count(keys(n)) over a 20k-node / 30-column type): 42.3/42.6 → 37.2/38.0 ms (three post-change runs spanned 37.2–38.7), with count(properties(n)) flat as the control.

  • property_count() counts a row’s properties without materialising them. The columnar route built the row’s whole (key, value) vector to take its len(), and both callers — calculate()/statistics()’s capacity hint and the GraphML export’s “does this node carry anything?” test — then built the same row again immediately afterwards, so every such node was assembled twice. Measured (release, min of two runs, calculate() over a 20k-node / 30-column type): 27.9/28.2 → 25.7/25.8 ms.

  • vacuum() and unspill() stopped rebuilding what they already knew. The compaction built a second, hash-keyed copy of the old→new node mapping purely to hand back to the caller, next to the dense vector its own edge pass was already using (an O(V) hash insert per live node and ~30 MB of transient allocation at 1M nodes); the consolidation pass behind both — and behind save() — kept a HashMap<NodeIndex, u32> per type recording row ids that are handed out densely from zero, and allocated one throwaway property vector per node of the graph. DirGraph::vacuum now returns a NodeRemap (a get/len/iter view over that dense vector) instead of a HashMap. Measured (release, min of two runs, 1M nodes, machine not idle): vacuum after deleting 30% of a type 133.7/128.3 → 95.1/90.2 ms, unspill of a clean 1M-node graph 140.0/140.7 → 104.1/103.8 ms.

  • The planner reads the edge-type counts instead of copying them. get_edge_type_counts returns a cached map keyed by connection-type name, and every caller — including two planner passes per replanned statement — got a fresh deep copy of it, one String allocation per connection type. It is shared by Arc now. Measured (release, min of two runs, a replanned statement on a graph with 200 connection types): 6.5/6.4 → 4.4/4.3 µs, which is the same cost the identical graph with 2 connection types pays — the planner’s per-edge-type overhead is gone rather than reduced.

Removed

  • enable_columnar(), disable_columnar() and is_columnar are gone from KnowledgeGraph. They were the controls for a storage regime that no longer exists: every graph is columnar from its first node, in every storage mode, so there is nothing to enable, nothing to leave, and nothing to interrogate. Calling any of them now raises AttributeError. There is no replacement for is_columnar — the honest answer it would give is a constant True — and none is provided; what a caller actually wanted from it is in graph_info(), whose columnar_total_rows, columnar_live_rows, columnar_heap_bytes and columnar_is_mapped keys report the columns’ size, occupancy and residency and are unchanged. For the two real operations the pair was being used for: unspill() rebuilds the columns heap-resident, reclaiming the rows deleted nodes left behind, and vacuum() does the same as part of compacting the graph. save() still consolidates on its way out, as it always did.

    On the Rust side the same three go from kglite::api: DirGraph::disable_columnar and DirGraph::is_columnar are deleted outright, and DirGraph::enable_columnar becomes crate-internal — it is the consolidation primitive save(), vacuum() and enable_disk_mode() run, not a mode switch a caller has any reason to reach. A consumer that needs the pass run reaches it through the operation that needs it: the new kglite::api::io::prepare_kgl_write(&mut Arc<DirGraph>) does everything a .kgl write needs done before its bytes exist (metadata stamp plus that pass), and is what save_graph and the wheel’s to_bytes() both now go through — previously they each open-coded it, and only one of the two kept the graph’s copy-on-write lineage across the mutation. NodeView:: properties_are_columnar is crate-internal for the same reason. The C ABI is unaffected: none of these were exported symbols.

Fixed

  • A transaction’s first write no longer copies every column of the type it touches. A begin()/commit() block runs against a working copy that shares its column stores with the graph it forked from, so the first write inside the transaction has to make its own copy before it can mutate — isolation depends on it. That copy was whole-store: a one-cell SET inside a transaction deep-copied every column of the node type, so the cost of opening a transaction scaled with the type’s width as well as its length. Columns are now shared individually and copied one at a time, so the write copies the column it writes and nothing else. Measured on a 50 k-row Item with 20 SETs per transaction, per-statement overhead above the same graph’s non-transactional SET: 1 / 4 / 12 / 24 columns → 11.2 / 12.5 / 16.6 / 23.4 µs before, 0.43 / 0.48 / 0.45 / 0.47 µs after — flat in width instead of growing at ~0.5 µs per column, and 26–49× cheaper at 12 and 24 columns. The transaction fork itself went from 462 µs to 10 µs at 50 k × 24, and from 968 µs to 18 µs at 100 k × 24. A held view or copy() that writes gets the same reduction. The cost is one atomic uniqueness check per column write, which shows up on the bulk-write shapes as +5.3 % on a 100 k-row mass SET (23.1 → 24.4 ms) and +6 % on a wide 1 k-node CREATE batch (3.02 → 3.21 ms).

  • Two concurrent transactions could be served each other’s query plans, and one plan shape turned that into a wrong answer. The Cypher plan cache decides “same graph, same state” from (graph_id, version), and a transaction working copy used to inherit its parent’s graph_id. Two transactions opened against the same graph then bumped version in lockstep, so they arrived at an identical cache key holding different graphs. For nearly every plan that is invisible — the optimizer’s choices are cost and ordering estimates, so a sibling’s plan is at worst mis-ordered. The exception is the anchored-edge-count fusion, which resolves a literal {id: …} anchor to a physical node index at plan time: reused across lineages it counted a different node’s edges and returned a plausible wrong number with no error. A transaction working copy now takes a fresh graph identity, which is what its own soundness argument always assumed. No configuration or API change, and no cache reuse worth having is lost — a working copy’s first write already moved it past every plan its parent’s key could match.

  • Statement planning no longer scales with the number of declared node types on graphs where it is never consulted. A gate used by one count-fusion shape (RETURN n.type, count(*) — does any type declare a property that shadows the primary type?) walks the whole schema catalogue, and it was being evaluated as a call argument, so every statement the planner touched paid it whether or not the query had that shape. Measured at ~23 ns per declared node type: on a 200-type schema this alone was 4.6 µs per planned statement. It is now computed where it is read, behind the shape gates that already stood in front of it. On a 200-type × 50-column schema, a single-statement write drops from 8.4 to 3.4 µs and a read following a write from 7.6 to 2.9 µs; the cost no longer varies with schema width at all. Cached-plan reads, which never paid it, are unchanged.

  • A mutating statement against a saved (columnar) graph no longer copies the touched node type’s whole column store. Statement rollback works from an undo journal, and the journal’s pre-image for a columnar property write used to be a handle on the type’s entire ColumnStore — which is what made the write itself deep-copy every column of that type to change one cell. The cost was paid per statement, released at commit, and paid again by the next statement, so it scaled with the type’s row count and never amortised: an ordinary single-row SET cost a small constant on a freshly built graph and two orders of magnitude more on the same graph after save() or load(). The journal now records the prior value of each (row, property) a statement changes — plus one entry when a SET introduces a property the type did not have, so a rolled-back statement also drops the column it appended — and the store is mutated in place. Journal cost is O(cells changed) instead of O(rows × columns); rollback fidelity is unchanged and pinned by new tests for schema-growing writes, cells written more than once in one statement, and cells that were absent before the statement. Writes taken while a .copy() or a held query result is sharing the graph still copy once per type, as before, because the reader’s snapshot must keep the store it was given. Measured (release build vs the published 0.15.14 wheel, two agreeing runs, flat controls): single-row SET after save() 327.9 → 4.3–5.0 µs at 50k×12 columns (~70×) and 683.5 → 4.3 µs at 100k (~155×) — parity with a never-saved graph at every size and column count measured; the same statements inside one transaction 355 → 14–45 µs; a spilled graph’s single-row SET 4,961 → 4.4 µs with its mapping intact.

  • A write to an mmap-backed (spilled or mapped-mode) column no longer brings it back to the heap at all, so set_memory_limit survives ordinary writes. The whole-store copy above brought every column of the type onto the heap on the first write of every statement; removing it left the single touched column still being copied. It is now written through its mapping instead — the byte lands in the spill file, which is a process-owned temporary file in every case (a mapped open() copies each column into its own temp file before mapping it, so no write ever reaches a user’s .kgl), and the graph stays spilled with its heap flat. Measured at 50k rows × 12 columns under a 1 MB limit: 1.65 MB before and after twenty single-row SETs, where 0.15.14 went 1.65 MB → 7.99 MB and lost the mapping entirely. A query result or .copy() holding the graph still forks the store to a heap copy for the writer, leaving the reader’s mapped bytes untouched. One limit remains, unchanged and pinned by a test: a column a type-mismatched SET demotes becomes untyped and cannot be mmap’d — correctness over memory, and bounded by how rare a genuinely heterogeneous property is.

  • set_memory_limit now holds across writes that create a property, and the columnar heap floor dropped by 1.6 MB at 50k rows. A columnar column had only ever been given a storage type when it was built from a type’s declared metadata; every write that had to create one — a SET for a property the type had never carried, and the id column of every graph — built an untyped column instead, which holds one boxed value per row and has no file representation at all, so nothing could spill it. Writing five new properties to a spilled 50k-row graph grew its heap by 8 MB against a 1 MB limit, and the id column alone was a permanent 1.6 MB the limit could never touch. A created column is now typed from the type’s declared metadata when it has any and from the value being written otherwise, and a completed mutating statement re-enforces the limit — so twenty new properties on that fixture now peak at 0.95 MB against the same 1 MB limit, and the at-rest floor is the tombstone bitmap alone (50 kB).

  • A copy of a spilled graph no longer writes into the original’s columns. A graph that has spilled its columns to disk keeps reading them through their file mappings, and a copy of that graph — .copy(), a transaction fork, a held query result — inherited the same spill directory and the same file names. The moment the copy spilled anything of its own it overwrote the original’s column files, and the original then read the copy’s values back through its mapping: a write on one graph appearing on another, which is the one thing copy-on-write must never do. Each column store now spills under its own name, re-drawn whenever the store is copied.

  • add_nodes(conflict_handling="replace") drops the properties the batch omits even while a query result or a .copy() is holding the graph. On a saved (columnar) graph in that state the write went through a separate code path that merged the incoming columns over the existing row instead of rewriting it, so a property left out of the batch survived — a replace silently behaving as an update, and only when a view happened to be held.

  • A node created after a deletion is saved against itself. The .kgl column section is positional: row k of a node type belongs to that type’s k-th node. A creation reuses a deleted node’s slot but appends its row at the end, so a delete-then-create pair left the two orders disagreeing, and every row after the divergence was saved against the wrong node — ids, titles and properties all shifted by one, and the edges consequently appeared to connect different nodes after a reload. The consolidation pass has always sorted rows to match; what was missing was noticing that it needed to run.

  • Crash recovery keeps every property value’s type. A property carrying different types on different nodes — legal in a live graph, where a value is a sum type and a columnar column simply demotes to mixed — came back from WAL replay with all of its values rewritten into one type: an int alongside a string on another node recovered as '1', an int alongside a float as 1.0, and a point() as its WKT text. Replay folds a whole node type’s logged upserts into one bulk load whose columns are singly typed, and the bulk loader’s type promotion — correct for the data loads it was written for — was silently converting cells on the recovery path. Replay now routes only the columns that survive a load unchanged through it and writes the rest one value at a time afterwards, for node and edge properties alike, so a recovered graph is value-faithful. This was data loss no re-query could undo: the coerced value was all that remained after the crash.

  • Crash recovery keeps node ids and titles as themselves, and no longer invents nodes. The same coercion reached the identity columns: a node type holding an integer id on one node and a string id on another recovered with the integer id rewritten as text — and because an edge addresses its endpoints by those ids, a stringified endpoint id matched nothing and the loader vivified a stub node under it, so recovery added a node that never existed. Mixed-type titles were rewritten the same way. Identity columns cannot be held back from the bulk load, so replay now splits such a type’s rows by shape and loads each shape on its own; a type with uniform ids and titles — the ordinary case — still replays in a single bulk call.

  • A CREATE naming a property the schema declares is no longer rejected as a typo. The unknown-property guard read only the property metadata built up from values already written, so a property declared through define_schema (required, optional, types, a primary key, a unique tuple) but not yet stored was reported as Unknown property 'x' on T. Did you mean ...?. Since a CREATE is how such a property would come to be written, the rejection was self-perpetuating: a Cypher statement stream could not grow a type’s schema, whatever the caller had declared. The guard is unchanged for properties nobody declared, which is the typo case it exists for.

  • Deleting nodes and creating replacements no longer hides fragmentation from auto-vacuum. The trigger measured free graph slots, which a later create takes back, so replacement churn left dead property rows accumulating at a reported fragmentation of zero — measured at 43% dead rows on a 2,000-node type after 1,500 delete/create pairs. It now also measures the rows no live node points at, and vacuums on whichever kind of fragmentation is worse.

[0.15.14] - 2026-08-13

Added

  • kglite-bolt-server --save-on-exit writes the served graph back to --graph when the server shuts down (env mirror: KGLITE_BOLT_SAVE_ON_EXIT=1). A Bolt server’s writes used to be process-local — they lived in the served graph’s in-memory state and reached the file only if a client saved it some other way — so a stopped server lost every write it had accepted. With the flag, shutdown runs one fsync’d, atomic save and logs the saved graph version; a failed save is logged as an error and exits non-zero, so a supervisor sees it. What it is not is a substitute for the write-ahead log added below: at --durability off a SIGKILL, a crash or a power loss still lose everything since the last save, and this flag only covers the two signals a graceful shutdown gets. Under the default --durability normal those commits are in the sidecar and the next start replays them, and the exit save’s job becomes folding the log into the file (which it now does, after flushing it) rather than being the only thing standing between a stop and data loss. Because connections are not drained a commit that races shutdown can land after it — which is what the logged version is for. Refused at startup for --readonly (nothing to write back) and for disk-mode graphs (every disk save publishes a new generation and nothing prunes them).

  • CALL db.checkpoint() over Bolt writes the served graph back on demand, returning Neo4j’s success, message shape (an optional YIELD of either or both columns is honoured). This is a bolt-server verb, not a Cypher procedure: it is recognised and executed by the server, so it exists only over Bolt — embedded bindings keep their own save calls, and the engine’s procedure list is unchanged. A checkpoint whose graph has not changed since the last one in this process is skipped and says so; the first call of a process always writes. Refused inside an explicit transaction (a checkpoint writes the committed graph, which excludes that transaction’s uncommitted writes), on a --readonly server, and for disk-mode graphs — the same reasons --save-on-exit refuses them. A save that fails is reported as a failure to the client, never a silent success.

  • kglite-bolt-server --checkpoint-interval <SECS> checkpoints the served graph on a timer (env mirror: KGLITE_BOLT_CHECKPOINT_INTERVAL=<secs>), bounding what an unlogged crash can lose to the writes since the last tick rather than to the whole run — and, under the write-ahead log added below, bounding instead how much sidecar a run accumulates and how long its replay takes, since every checkpoint truncates the log. A background task saves the graph every SECS seconds and logs the version it wrote; a tick whose graph is unchanged since the last checkpoint — by this task or by CALL db.checkpoint(), which share one recorded version — writes nothing, so an idle server does not rewrite its file. The first checkpoint of a process always writes, because the file on disk may predate the process. A failed tick is logged as an error and the server keeps serving: degraded durability is worth saying loudly, not worth disconnecting every client over. The interval is validated at startup (0 and anything that is not a whole number of seconds are refused, rather than starting a server that silently never checkpoints), refused for --readonly and for disk-mode graphs exactly as --save-on-exit is, and combinable with it — the interval bounds the window while running, the exit save catches the tail, and periodic checkpointing is stopped before the exit save runs.

  • SIGTERM now triggers the same graceful shutdown as SIGINT in kglite-bolt-server. Only Ctrl-C was wired, so systemctl stop, docker stop and a Kubernetes pod shutdown terminated the process through the default handler — no connection shutdown, and (with the flag above) no exit save. Both signals now run one shutdown path.

  • Durable sessions in the Rust API: Session::open_durable(graph, path, level). The engine has shipped the write-ahead log itself since 0.14, but the orchestration around it lived only in the Python wheel; it is now part of kglite::api::session, so every binding gets the same behaviour instead of reimplementing it. open_durable performs the whole open ordering — recover the sidecar, replay the frames the loaded checkpoint does not already contain, then wrap the backend for write capture (replaying after the wrap would log every recovered op a second time), then open the log for append. Session::commit appends the transaction’s frame between the OCC check and the publish, so a log that cannot be written blocks the commit rather than reporting success over an unlogged write; the new CommitOutcome::DurabilityFailed { error } says so, and the graph, its version and its readers are untouched. Session::save becomes the four-step checkpoint (flush the log → stamp checkpoint_lsn → write the .kgl → truncate the log), and forces fsync on a durable session because it destroys the log that would otherwise still describe those commits. New Session::sync() takes the on-demand barrier that makes level normal usable, and Session::durability() reports the level. Refusals are explicit: disk-mode graphs at any logging level, a graph another durable owner already wrapped, and — the data-safety one — level off over a sidecar holding commits the checkpoint does not contain, which would otherwise be ignored and then truncated away. Session::write / Session::transact are not logged paths and are documented as unsupported on a durable session; taking one anyway now latches the session so every later durability operation fails loudly until a checkpoint folds the write in. Non-durable sessions are unaffected in behaviour and in cost.

  • kglite-bolt-server --durability {full,normal,off} puts the write-ahead log under the Bolt server (env mirror: KGLITE_BOLT_DURABILITY=<level>). Until now the server’s writes were process-local until something rewrote the whole graph — the exit save, a db.checkpoint(), or an interval tick — so a SIGKILL between checkpoints lost every commit since the last one. At full and normal each commit is appended to <graph>-wal before it is acknowledged: full barriers the frame to the device (an acknowledged commit survives power loss), normal hands it to the kernel (survives this process dying — SIGKILL, an OOM-kill, a panic — but not an OS crash or power loss). A commit whose frame cannot be written is not applied and the client is told so, rather than acknowledging a write the server has discarded. Recovery runs at startup at every level: at full/normal a sidecar holding commits the graph file does not contain is replayed before the port is bound, and at off it is a startup error naming both ways out — never a server quietly serving a graph that is missing acknowledged writes. The three existing checkpoint routes become true checkpoints under a log: each folds the log into the .kgl and truncates it, and the graceful shutdown path takes a final log flush before the exit save. Refused with --readonly (a server that never commits has nothing to log; --durability off beside --readonly is unchanged and fine) and for disk-mode graphs (a disk graph commits by publishing an immutable generation, so it keeps no logical log).

    The default is normal — a commit this server acknowledges now survives the server process dying, without any flag. The level was picked by measurement, not preference: at 4 contended Bolt writers on a 10k-node graph, normal cost nothing measurable against off (two runs straddled zero at ±8% noise), while full cost 88% of committed throughput — one device barrier per commit, taken inside the session lock every Bolt commit already serializes on, which also moved p95 latency from 0.9 ms to 76 ms and the transaction-conflict rate from 1.5% to 17%. Power-loss safety is therefore opt-in via --durability full, and the numbers live in tests/benchmarks/test_bench_bolt_writers.py::test_durability_sweep.

    Two consequences of the default, both deliberate: a served graph now grows a <graph>-wal sidecar beside it (~95 bytes per single-node commit, truncated by every checkpoint), and the configurations that cannot carry a log — --readonly and disk-mode graphs — serve at off with a log line saying so instead of failing to start. A level you ask for is still refused there, rather than quietly weakened.

Changed

  • kglite::api::io::open_or_create_graph_in_mode takes the durability level the caller is about to attach (breaking for direct Rust callers: pass DurabilityLevel::Off for today’s behaviour). The unrecovered-sidecar refusal above is correct for an opener that attaches no log and wrong for one that is about to — a server restarting at --durability full must be allowed to open the very path that refusal protects, because its log is the recovery. The level is declared rather than inferred, so an off open still gets the refusal and a graph created at a logging level over an orphaned sidecar replays it instead of discarding it. open_or_create_graph (the creation-default entry point) is unchanged and attaches no log. The C ABI’s kglite_open_or_create_graph_in_mode signature is unchanged.

  • CommitOutcome is now #[non_exhaustive] (breaking for out-of-crate code that matches it exhaustively — add a catch-all arm). A commit can fail in ways that did not exist when a binding was written, and such an outcome must reach the binding’s error path rather than falling through to success.

  • Documented the write-amplification ladder, and what save() does to later writes. A graph that came from a .kgl file — load(), open(), or a save() earlier in the same process — holds its properties in per-type column stores, and there every mutating statement re-images the store of each type it writes, once per statement however few rows it touched. Measured with 12 declared properties: ≈ 40 µs per single-row SET at 5 k nodes and ≈ 380 µs at 50 k, against ≈ 5 µs on a graph that has never been saved; an explicit transaction does not amortize it, CREATE and reads are unaffected. This was undocumented and is easy to walk into, so the data-loading guide gains a write-throughput ladder (bulk loaders ≈ 1 µs/row → one multi-row statement ≈ 1–2 µs/row → statement-per-row) with the batching mitigation measured at ~80× on a 50 k graph, and the primary-store guide gains a section on the regime itself, how to observe it (is_columnar, graph_info()['columnar_heap_bytes']), and the disable_columnar() escape hatch with its cost and its mapped/memory-limit caveats. The is_columnar and disable_columnar docstrings carry the same facts. The data-loading guide also now states the declared-schema-width cost: per-row scan cost tracks the properties declared on the scanned type, not those the query reads (≈ 10–15% more per row at 10 declared properties than at 2, even when the extra ones are null everywhere).

  • A range index no longer makes every write against a shared graph pay a full copy of the index (up to ~45× on the first write after a fork). property_indices and composite_indices have been stacks of shared, immutable levels since 0.15.9, so forking a graph that carries them copies pointers. range_indices was still a plain BTreeMap, so any graph with a CREATE RANGE INDEX (or create_range_index) deep-copied the whole B-tree — one value key and one posting list per distinct value — every time a write followed a held result view, a copy(), or any other outstanding reader. It is now the same level stack over ordered levels, so an unforked graph reads through the plain B-tree exactly as before and a fork shares it. Measured on this machine, release build, against the 0.15.13 wheel on the same interpreter (two agreeing runs each): first write with a view held over a 50k-node graph with a 50k-value range index 622.35 µs → 13.79 µs median (45×), landing on the equality-index control’s 13.96 µs; at 100k nodes 0.932 ms → 0.061 ms mean / 0.920 ms → 0.033 ms median, against an equality-index control of 0.053 ms and a no-index floor of 0.032 ms that both stayed put. The read path is untouched: an indexed range scan measured 2.14 ms before and after, beside an unindexed control of 4.16 → 4.24 ms and a point-lookup control of 4.7 → 4.6 µs. Rollback behaviour is unchanged — range_indices is still parked by the statement checkpoint and restored by the undo journal’s per-bucket inverse edits, now pinned with a fork outstanding.

  • Multi-key ORDER BY LIMIT now takes the bounded-heap top-K path (~7× on a 50k-node graph). Both fusion passes required exactly one sort item, so adding a tie-breaker to a leaderboard or paging query silently bought a full O(n log n) sort plus a full projection of every row. They now accept any number of keys, each with its own ASC/DESC and NULLS placement, and rank through the shared comparator. A key written as a RETURN alias resolves to that item’s expression, so the alias spelling is no longer slower than the property spelling, and that column is projected from the computed key rather than re-evaluated. Measured (release build, min-of-N, two agreeing runs against the published 0.15.13 wheel, 50k nodes, LIMIT 10): two keys 10.9 ms → 1.64 ms (6.7×), three keys 11.6 ms → 2.35 ms (5.0×), mixed DESC, ASC 12.4 ms → 1.65 ms (7.5×), a leading key with 5 000-way ties 15.1 ms → 2.31 ms (6.5×), LIMIT 1000 11.1 ms → 2.37 ms (4.7×), and the same query written over RETURN aliases 11.1 ms → 1.63 ms (6.8×), and ORDER BY <alias> LIMIT on one key 3.57 ms → 0.94 ms (3.8×). Single-key ORDER BY — the path that already fused — improves in both directions: ascending 1.47 ms → 0.94 ms (1.5×) and descending 2.75 ms → 1.75 ms (1.6×) at LIMIT 10, 3.11 ms → 2.18 ms (1.4×) at LIMIT 25, a string key 2.15 ms → 1.59 ms (1.3×) and an expression key 3.51 ms → 2.43 ms (1.4×). Control cells are flat within 1–4%: the same two-key sort without LIMIT (14.41 ms → 14.49 ms), a single-key ORDER BY without LIMIT (7.64 ms → 7.61 ms), an unrelated filtered count (1.13 ms → 1.17 ms) and a group aggregation (2.86 ms → 2.92 ms).

    The descending figure above is a correction. The commit that landed the multi-key path (perf(planner): multi-key ORDER BY..LIMIT takes the top-K path) reported “single-key control improved 1.24 → 0.96 ms”: that probe measured the ascending cell only, and descending had in fact regressed ~20% — one direction stood in for both. The two are not interchangeable. Scanning a column that ascends with node order (ORDER BY value DESC, the leaderboard shape) beats the retained worst on every row, so top-K retention runs once per row rather than once per winner and its per-row cost becomes the query’s cost; ascending fills the heap and then rejects every later row on one comparison. The retention path is now allocation-free (the evicted entry’s key buffer is reused, and the sort specs live on the collector instead of inside every entry) and short-circuits through a direction-folded f64 stand-in for the leading key, with ties, NULLs, strings and integers past 2^53 handed back to the one comparator. Both directions are now benchmarked side by side in tests/benchmarks/test_bench_hotpaths.py.

  • A point lookup on an id that does not exist no longer scans the whole node type. MATCH (n:Item {id: X}) (and the WHERE n.id = X / $param / id-alias spellings) resolves through the per-type id index, but when the key was absent the anchor treated the miss as “no index built” and fell through to a full scan of the type — which could only ever re-derive the same empty answer, at O(nodes) per absent key. Absent keys are the common case for upsert probes and for SET / MATCH driven by an externally-sourced id list, and the equivalent IN-on-id anchor already did this correctly. Measured (release build, min-of-N, two agreeing runs against the published 0.15.13 wheel, controls flat): a missing-key lookup at 50k nodes 0.40 ms → 0.0024 ms (164×) and at 200k nodes 1.56 ms → 0.0023 ms (672×) — now flat in graph size and at parity with a hit (0.0025 ms) — and UNWIND over 2 000 absent ids 809 ms → 0.54 ms (1 480×). Hit lookups, the same UNWIND over present ids, and unrelated property scans are unchanged.

  • IN membership no longer costs O(rows × |list|). Every one of the five places that answer x IN <list> — the pattern matcher’s pushed-down IN matcher, the EXISTS fast path’s inline property check, and the executor’s In / InLiteralSet / InExpression predicates — walked the whole list per row with a coercing comparison, so a selective filter over a big list spent all its time proving non-matches. The InLiteralSet form advertised an O(1) HashSet, but Value’s structural hashing cannot express the numeric coercion the comparison performs, so it kept a linear fallback behind the set and every non-matching row paid it. All five sites now share one coercion-normalized membership set, built once per query: keys fold Int64 / UniqueId / integral Float64 together and match a single-element JSON list string to its inner string, exactly as value equality does, with a documented fallback for the values where that equality is not injective (magnitudes past 2^53, NaN, non-scalars). Lists of 8 elements or fewer keep the previous linear scan, so short IN lists cannot regress. Three-valued (Kleene) NULL semantics are unchanged at every site. Two further defects surfaced with it: the fused MATCH WHERE path — the common shape — never constant-folded its predicate, so an all-literal list never reached the indexed form at all and a $param list was re-cloned per row; and IN over a general list expression cloned the entire list for every row. Measured (release build, min-of-N, two agreeing runs against the published 0.15.13 wheel, 50k rows): a 1 000-element param list 137 ms → 0.63 ms (217×), 16 000 elements 1 884 ms → 2.22 ms (849×), 64 000 elements 3 506 ms → 6.73 ms (521×); the literal-list spelling 136 ms → 0.65 ms (209×); strings 164 ms → 1.06 ms at 1 000 (154×) and 2 121 ms → 8.10 ms at 16 000 (262×); a projected WITH WHERE v IN [...] 213 ms → 4.56 ms (47×); and a shape that re-resolves its pattern per row (a deferred {name: var} alongside a 1 000-element IN) 28.1 s → 0.10 s (279×). Control cells — an 8-element IN, a 2-element IN, a range predicate, an equality predicate and a bare count — are flat within 4%.

  • A string of the exact form ["] no longer panics the engine. Value equality treats a single-element JSON list string (["Oslo"]) as equal to its inner string by slicing off the four delimiter bytes; on a three-byte string whose delimiters overlap, that slice ran backwards and aborted the query thread. RETURN '["]' IN ['x'] was enough to trigger it.

  • An id stored as a whole-valued float is matchable by an integer literal through the id index. CREATE (n:Doc {id: 5.0}) followed by MATCH (n:Doc {id: 5}) matched only because the anchor fell through to a scan, whose comparison coerces across the numeric family; the index itself declined the coercion, so the WHERE n.id IN [5] spelling returned nothing. The index now coerces Int64 / UniqueId queries against Float64 keys the same way value equality does, and all spellings agree.

Fixed

  • CREATE no longer hands out an id that another node already holds. A CREATE with no id property asks the engine for an identity, and the allocator was node_bound() — an index-space bound, not a counter. It shrinks when the highest-indexed nodes are deleted and stalls while freed slots are refilled, so ordinary histories minted collisions: CREATE×5 → DELETE two → CREATE×3 put two nodes on one id, and a save()/load() across earlier deletes put three consecutive nodes on the same id. The duplicates were silent in both directions that matter — MATCH (n {id: X}) returns only one node per id, and WAL replay folds ops by (node_type, id), so a durable graph recovered the collided nodes as one and lost the rest. Ids are now drawn from a monotonic high-water mark that never reuses a value and is re-seeded above a loaded graph’s own ids, and a caller-supplied id raises the mark so the engine cannot later mint it. Ids handed out by an append-only workload are unchanged (0, 1, 2, ); after a delete the counter keeps climbing instead of reusing the freed value. Uniqueness for ids the caller supplies is unchanged and still opt-in (define_schema’s primary_key, or MERGE).

  • kglite.open(path, durable=False) no longer silently discards committed writes still sitting in the WAL sidecar. Recovery used to be conditional on the level being asked for, so opening a crashed graph at "off" (or False) ignored every frame the checkpoint did not already contain — the graph came back missing writes that had been acknowledged, with no error, and the first later save() truncated the log and destroyed them for good. Recovery on open is now unconditional: opening a path is a decision about that path’s data, not only about how future writes will be logged. An "off" open over a sidecar holding unrecovered commits raises ValueError naming the sidecar and both ways out (reopen at "full"/"normal" to replay them, or move the sidecar aside to discard them deliberately). Frames the checkpoint already contains — the harmless residue of a crash between a save() and its truncation — are not grounds to refuse and still open at every level. The wheel and the engine’s Session now perform the same open sequence through one shared function (kglite::api::durable::open_log), so the two cannot disagree about what a sidecar means.

  • A server-style open over an unrecovered WAL sidecar no longer lets a later durable open replay stale frames over newer saved data. The MCP server, the Bolt server and the CLI open graphs through kglite::api::io::open_or_create_graph[_in_mode], which read the .kgl checkpoint alone and attach no log. Over a path whose sidecar still held frames the checkpoint had not folded in — a durable writer that died between a commit and its next checkpoint — that open silently returned a graph missing those commits, and any subsequent save made it worse rather than better: the save stamps no checkpoint_lsn and truncates nothing, so the stale frames survived in front of the newer checkpoint and the next durable open replayed them back over it, reverting saved state to an older commit. These openers now refuse such a path on the same terms as durable="off" (kglite::api::durable::ensure_recovered, shared with the wheel’s refusal), naming the sidecar and both ways out: open it through a durable entry point to replay the frames, or move the sidecar aside to discard them deliberately. The refusal covers read-only opens too, because an opener that may later publish cannot be told apart from one that only looks, and it covers a sidecar found beside a missing checkpoint, where a fresh graph would replay every frame in it. Frames at or below checkpoint_lsn — crash residue between a save and its truncation — still open fine. load_file is deliberately unguarded: it is the primitive durable recovery is itself built on, and the way to read a graph another process is writing durably, where a sidecar running ahead of the checkpoint is the steady state.

  • load() → mutate → save() over a live WAL sidecar is now refused instead of being silently rolled back later. kglite.load(path) (and kglite.open_session(path)) read the checkpoint alone by design, so a path whose sidecar still held commits the .kgl did not contain came back missing them — and saving back over that path stranded the frames in front of the new checkpoint, so the next durable open replayed them over it. Measured end to end: a graph saved with age=3 came back as age=2. The save now refuses, in the single save dispatch (kglite::api::io::save_graph[_with], which the wheel, the MCP server, the CLI, the C ABI and Session::save all route through), so every binding gets the same refusal: ValueError in Python — the class every other durability refusal already raises — naming the sidecar and both ways out (reopen the path durably to replay the commits, or move the sidecar aside to discard them deliberately). The rule is “the sidecar holds frames past the checkpoint_lsn this save is about to write”, which is exactly the set a later durable open would replay over it; the target path’s sidecar is what counts, so a “save as” onto such a path is refused too. A durable owner’s own checkpoint is never refused, and not by an exemption: its prologue stamps checkpoint_lsn before the write, so its frames are already at or below the stamp. Crash residue (frames at or below the stamp) still saves fine. save_graph/save_graph_with now return a typed SaveError whose Refused variant means nothing was written, so a binding can map a refusal to its own bad-request class instead of an I/O failure.

  • enable_columnar()’s docstring example called is_columnar as a method. It is a property, so assert graph.is_columnar() — copied from help() — raised TypeError: 'bool' object is not callable. The primary-store guide’s claim that there is no JVM binding is also gone: Java has been official since 0.15.9 (io.github.kkollsga:kglite on Maven Central).

  • A fused ORDER BY LIMIT no longer drops rows whose sort key is NULL. Both fused top-K executors skipped any row with a NULL key, while the ordinary ORDER BY pipeline places NULLs by the openCypher/Neo4j 5+ rule (NULLS FIRST for DESC, NULLS LAST for ASC, overridable per key). So ORDER BY score DESC LIMIT 10 over a partly-populated property returned the wrong ten rows — the NULL-keyed rows that should have led the result were silently discarded — and LIMIT k could return fewer than k rows when the graph held k of them. Sort order is now defined in exactly one place (executor/ordering.rs) and shared by the full sort, the streaming top-K operator and both fused top-K executors, so the fused and unfused paths cannot disagree; the corresponding orderings are pinned by golden expected values in tests/test_cypher_top_k_ordering.py. Two further defects went with it: a sort key written as an expression over a RETURN alias (RETURN n.x AS a ORDER BY a + 1 LIMIT k) fused into a clause that cannot evaluate the alias and returned zero rows — that shape now declines to fuse; and a RETURN column that is also the sort key was projected from the f64 the old heap ranked on, rounding integers past 2^53.

[0.15.13] - 2026-08-12

Changed

  • describe() / describe(types=[...]) are ~2.3× faster on property-heavy graphs, with byte-identical output. 0.15.9 routed the property-stats scan through the storage accessors so that saved (columnar) graphs stop reporting empty stats — a real fix that stays — but it paid for every row twice over: the per-node accumulator was keyed by String, so a 50k-node × 13-property type allocated and hashed ~650k short-lived key strings per call, and every columnar row enumeration allocated a HashSet for a merge step that only the mmap-backed path performs. The scan now accumulates by interned key (a Copy u64) and resolves names once at the end, and the row enumerator builds its merge set only when there is an mmap store to merge with — which speeds up every columnar row read, not just describe. Measured (release build, min-of-12, two agreeing runs, unchanged-path controls flat within 4%): synthetic law-like graph (50k nodes × 13 string properties + 5 smaller types), saved and reloaded, describe() 49.4 ms → 21.9 ms and describe(types=["Decision"]) 48.7 ms → 21.4 ms; a 40-type narrow-numeric graph’s describe(types=[...]) 3.19 ms → 0.88 ms (3.6×). Output is unchanged — counts, distinct values, ordering and every byte of the XML match, verified fixture-by-fixture before and after.

Fixed

  • A join filtered on a type’s title/id field is no longer planned as if the filter matched everything. The planner estimates a non-indexed equality (and IN) filter as type_count / distinct_values, but the distinct-value scan read the property map only — and a type’s node_title_field / unique_id_field (add_nodes(..., unique_id_field="wlbWellboreName"), and the canonical title / id) do not live there. The scan therefore found nothing, reported one distinct value, and the filter scored as excluding nothing, so the join anchored on the other, larger end of the pattern and drove every one of its rows through the filter. Present since 0.11.9, when the distinct-value estimate was introduced. The statistic now resolves the same field aliases the matcher does, and an empty scan reports no information rather than “one distinct value” — no estimate can be manufactured from an absent property again. Traversals filtered on a title or id field over a large type get order-of-magnitude speedups. Measured (release build, min-of-12, two agreeing runs, unchanged-path controls flat): a 20k-node synthetic join filtered on the title field 1.09 ms → 0.039 ms (28×) and on the id field 1.02 ms → 0.006 ms (168×), against 0.023 ms for the same join filtered on an ordinary property; on a real 195 MB legal graph MATCH (d:CourtDecision)-[:HAS_KEYWORD]->(k:Keyword) WHERE k.name = 'Erstatning' 5.79 ms → 0.92 ms, and on a 135 MB petroleum graph a four-hop anchored on w.wlbWellboreName 2.41 ms → 0.22 ms. Results were always correct — only the plan was wrong. create_index on the filtered property remains faster still (it answers the lookup outright rather than scanning the now-correctly-chosen type), so an index added as a workaround is still worth keeping.

  • An indexed MATCH could return phantom or duplicate rows after a Cypher CREATE / SET on a type whose indexed property is an id/title alias spelling. For a type loaded as add_nodes(..., unique_id_field="term_id", node_title_field="term_name") and indexed with create_index("Term", "term_name") (or CREATE INDEX FOR (t:Term) ON (t.term_name)), the index is built from the node’s title — the value a MATCH on that name compares against — but incremental maintenance read the node by the user-facing key instead. A written node was filed under a bucket value no scan can produce: MATCH (t:Term {term_name: …}) returned rows that the same query without the index did not, an inline-map predicate and its WHERE spelling disagreed with each other, and a written value colliding with an existing node’s title added a bogus member to that node’s bucket. Index updates now resolve field aliases exactly like index rebuilds and scans do, so incremental maintenance and create_index agree by construction. Two adjacent defects fixed with it: a SET on title left an index registered under the title-alias spelling stale, and a property carrying both a hash and a range index (Neo4j-style CREATE RANGE INDEX) had newly created nodes appended to each bucket twice, so an indexed lookup returned them twice.

  • .statistics() on a type’s own title or id field returned nothing. For a type loaded as add_nodes(..., unique_id_field="term_id", node_title_field="term_name"), select("Term").statistics("term_id") — and equally "term_name", "id", "title" — reported count = N with valid_count = 0, no min/max/avg, and value_type = "null": a silently empty answer rather than an error, on a column every node carries. The ungrouped path read the property map directly, where identity columns do not live, while its group_by= sibling already resolved the alias. Both now read through the same alias-resolving route as every other read, so the two entry points cannot disagree about what a field name means. A genuinely absent property still reports valid_count = 0.

  • Disk mode: concurrent reads no longer grow the process without bound. Serving a disk graph to more than one reader at a time — a Bolt server, a thread pool, any concurrent Session — grew RSS for as long as the load lasted and never gave it back: 60 MB → over 10 GB on a 43 MB graph, and a measured 87 MB → 4.4 GB in five seconds of eight-thread reads. Disk reads build node/edge records on demand and park them in a per-query arena; the arena could only be reclaimed while the graph was completely idle, which sustained concurrency never is. Records now carry the query epoch that created them and are dropped as soon as every query that could hold one has finished, so the retained set is bounded by the queries actually in flight (same 8-thread load: 91 MB → 128 MB, flat, over 23 000 queries). Scans and filters — the bulk of the traffic — no longer touch the arena at all, materializing into the caller’s frame instead, which also removes the arena mutex from the concurrent read path: filtered scans and parameterised traversals went from losing throughput as readers were added (0.29× at 8 threads) to scaling 4.6–4.8×, and are 10–16 % faster single-threaded. In-memory and mapped modes are untouched — they have no arena, and their read paths were left byte-for-byte as they were.

[0.15.12] - 2026-08-12

Changed

  • The Bolt server’s write-concurrency contract is now written down. Bolt server → Write concurrency states what concurrent writers actually get — flat committed throughput as writers are added (the commit point is single, so more clients raise latency rather than capacity), conflicts that stay rare and are absorbed by driver-managed retry, a worst-case tail owned by the driver’s backoff policy rather than the server, and batching as the dial that actually moves the write ceiling (measured: ~an order of magnitude more committed writes/s at 100 writes per transaction). Backed by a new contended-writer load test (tests/benchmarks/test_bench_bolt_writers.py, opt-in via -m "benchmark and bolt_stress") that sweeps the writer count and the writes-per-transaction batch size and records the curves, so the prose is measured rather than asserted.

Fixed

  • Bolt: a lost OCC race is now retriable, so managed transactions retry it themselves. A stale-snapshot commit reported Neo.ClientError.Transaction.ConflictDetected — a code invented here, in the class every Neo4j driver reads as “do not retry”. Drivers therefore raised the conflict straight through to the caller and the losing writer’s work was silently dropped, even though the operation would have succeeded on a second attempt. Conflicts now report the published Neo.TransientError.Transaction.Outdated, whose class is the retriable one: session.execute_write (and its equivalents in the JS and Java drivers) re-runs the unit of work on a fresh transaction with a fresh base version, with no retry loop in your code. Hand-rolled begin_transaction / commit() code still owns its own retry, and can keep branching on the status code — the new one.

    Breaking for Bolt clients that catch the conflict by class. A conflict is now neo4j.exceptions.TransientError, not ClientError, so except ClientError no longer sees it (except Neo4jError, or a check on .code / .is_retryable(), still does). The embedded Python path is unaffected: kglite.TransactionConflictError, .code == "TransactionConflict", kglite.retry_on_conflict, HTTP 409 and the C ABI status code are all unchanged.

[0.15.11] - 2026-08-11

Added

  • text_score() accepts a query vector, so every scoring function is usable from every binding. text_score(n, 'col', $q) now scores a list-valued $q — or an inline [0.1, 0.2, …] — directly as your query vector, needing only the embedding store; a string $q is embedded first, as before, via set_embedder(). text_score is vector_score after a plan-time rewrite, and a vector query passes straight through it, so both spellings return identical scores, honour the same optional metric argument and ride the same fused HNSW top-k path. Any language that can send a list parameter through cypher() can now query by vector under either spelling.

    The query argument’s type selects how it is scored: in text_score a list is a vector and a string is text, so text_score(n, 'col', '[1.0, 2.0]') embeds that 10-character string. vector_score reads a list as a vector and also parses a JSON-array string as one (a legacy form kept for compatibility) — pass a list to have both spellings agree. A $param used as the query argument must be bound to a string or a list, and plan-time validation reports the type of anything else.

  • kglite::api::embeddings — embedding ingest is now Rust-callable. set_embeddings, add_embeddings and build_vector_index existed only as Python methods, so every other consumer (the MCP and Bolt servers, the C ABI and the bindings above it) could query vectors but never write them. They are now engine primitives over &mut DirGraph, taking (id, vector) pairs from any iterator — a borrowed &[f32] out of a packed buffer works with no intermediate copy — and returning EmbeddingIngestReport / VectorIndexReport. store_key is the one place the "{text_column}_emb" store key is derived.

    Each ingest call resolves every id and checks every dimension before it touches a store, so a rejected batch leaves the graph as it found it, and it bumps the graph version on a non-empty write while an empty batch writes and bumps nothing. That makes them all-or-nothing under a plain &mut DirGraph. The Python methods are now thin shells over these; the HNSW defaults, which were spelled independently in the engine and the Python builder, are resolved in one place.

  • C ABI: embedding ingest on the session. Four additive symbols let a non-Rust binding write vectors and build the ANN index it can already query: kglite_session_set_embeddings and kglite_session_add_embeddings take the vectors as a packed const float * (dim × count, row-major) with the ids as a JSON array, kglite_session_build_vector_index builds the HNSW index, and kglite_session_list_embeddings enumerates the stores. Each returns an owned JSON report. The ingest calls run under the session write lock and are all-or-nothing; the stores are checkpoint-only, so call kglite_session_save to persist them. Existing symbols are unchanged — the generated include/kglite.h gains only these four.

  • Java: ingest embeddings and query the graph by vector. KnowledgeGraph gains setEmbeddings(nodeType, column, byId[, metric]), addEmbeddings(nodeType, column, byId[, metric]), buildVectorIndex(nodeType, column[, m, efConstruction, efSearch, metric]) and listEmbeddings(). Bring your own vectors as a Map<?, float[]> keyed by node id; the wrapper flattens them into one packed-float buffer at the FFM boundary. A float[] or List<Float> is now a bindable Cypher parameter, so vector_score(n, 'col_emb', $q) and text_score(n, 'col', $q) score against your own query vector. save() carries the store and its HNSW index in the .kgl checkpoint, so a store written from Java reloads in every binding.

Changed

  • list[i] is now O(1) in list length, not O(list length) per access. Indexing a list in Cypher (n.emb[i], $q[i], a projected q[i]) used to clone the entire list on every element access, so a dot product spelled reduce(i IN range(0, d) | s + n.emb[i] * q[i]) over a d-dimensional embedding cost O(d²) per row. It now borrows the list and clones only the selected element. A per-element scoring reduce over a 384-dim stored vector drops from ~340 ms to ~13 ms across 2 000 nodes (~26×), and per-element cost is now flat in vector width — on par with a scalar property read. Bring-your- own-vector scoring written by hand in Cypher, not just the native vector_score, is now practical.

  • add_embeddings requires its source column to exist, matching set_embeddings. add_embeddings('Doc', 'summary_emb', …) — the store name where the column name belongs — used to create an unreachable summary_emb_emb store and report success; it now raises the same ValueError set_embeddings has always raised, naming the column and type. A call that passes a real column is unaffected.

Fixed

  • Three shipped messages named a method that does not exist. The Cypher CREATE VECTOR INDEX and CREATE FULLTEXT INDEX refusals, and the Cypher DDL coverage note in describe(), directed users to create_vector_index; the method is build_vector_index. The vector-index refusal also asked for an embedder — it needs an existing embedding store and HNSW build parameters. Same corrections in CYPHER.md, the Neo4j migration guide and the Java README.

  • Rust API: the write-side migration promised in 0.15.9 is now actually reachable — and safe. Three parts:

    • GraphWrite is exported from kglite::api. The 0.15.9 changelog directed embedders to GraphWrite::set_node_property, but the trait was never re-exported, so the advertised migration could not compile from outside the crate (every internal consumer reaches it through pub(crate), which is why no build here noticed). Reach it as graph.graph.set_node_property(..) with the trait in scope.

    • DirGraph::set_node_property(idx, "key", value) and DirGraph::remove_node_property(idx, "key") — one-call string-keyed replacements matching the removed NodeData mutators’ ergonomics. Prefer these: the trait method takes an InternedKey, and a key built with InternedKey::from_str (which does not register the name) reads back in-session but breaks enumeration and is silently dropped by save_graph. The kglite::api interner docs, which previously recommended exactly that bridge for direct graph access, now spell out the write-side rule.

    • EdgeData is exported from kglite::api beside NodeData — public signatures (GraphWrite::add_edge, DiskGraph::from_stable_digraph) name it, so it must be publicly nameable.

    The packaged Rust embed-consumer fixture now exercises the documented migration (NodeView read, both write routes, key registration, enumeration, save/load) from outside the crate, so an advertised route that is not publicly reachable or drops data fails the fixture. Corrections riding along: two 0.15.9 changelog migration lines were fixed in place (NodeView::from(&node_data) was itself removed — use graph.node_view; property_iter’s replacement is property_pairs_named, not a same-named method), four stale doc comments recommending removed NodeData methods were corrected, docs/rust/api-reference.md no longer promises that patch releases never break the API (this project deliberately ships documented breaking changes in patch bumps — pin exact versions), and its compute_description/compute_schema paths gained their real introspection:: segment.

[0.15.10] - 2026-08-11

Added

  • The Java wrapper is packaged and publishable. The JAR now bundles the native engine for darwin-aarch64, linux-x86_64, linux-aarch64 and windows-x86_64 under /natives/<os>-<arch>/; at first use the running platform’s copy is extracted to a content-addressed per-user cache (~/Library/Caches/kglite/natives, $XDG_CACHE_HOME/kglite/natives, %LOCALAPPDATA%\kglite\natives) and linked from there, so a consumer needs no toolchain and no -Dkglite.native.path. That override still wins when set, and a workspace target/{release,debug} build still outranks the bundled copy for development. Intel macOS (darwin-x86_64) is not bundled: on that platform build cargo build -p kglite-c --release once and pass -Dkglite.native.path, which is what the loader’s error tells you. .github/workflows/publish_java.yml builds the four natives on a release tag, runs the Java suite against the extracted-resource path, and deploys io.github.kkollsga:kglite to Maven Central. Published 2026-08-10: io.github.kkollsga:kglite:0.15.9 is live (core wrapper; the Transaction and DSL classes in this changelog ship with the next release).

  • The Java JAR declares Automatic-Module-Name: io.github.kkollsga.kglite. Without it the JPMS module name was derived from the file name, so requires kglite bound to whatever JAR happened to be called that, and the derived name changed if the artifact was ever renamed.

  • The Java binding applies several statements as one atomic unit. graph.beginTransaction() returns an AutoCloseable Transaction: add(cypher[, params]) stages a statement, commit() runs the whole batch in one engine transaction and returns the per-statement rows in staging order, and rollback() — or simply closing without committing — discards it having executed nothing. If any statement fails, none of the batch reaches the graph and commit() throws that statement’s engine status; which statement failed is not reported, because the ABI’s batch call carries a status and a message and no index. It runs over the C ABI’s existing kglite_session_execute_mut_batch, so no ABI surface was added. Four differences from a JDBC transaction are documented rather than blurred: statements are staged, so Java cannot branch on an intermediate result (read-your-writes still holds inside the engine, and a staged MATCH sees a staged CREATE); commit() publishes to the session, not to disk, and save(Path) is still the only thing that persists; the batch holds the session’s write lock for its whole duration, so a new query() waits while a large one commits; and there is no cross-process transaction — the writer lease remains the only cross-process mechanism, and it is advisory. A transaction is confined to the thread that began it, an empty commit() makes no engine call at all, and closing the graph makes an open transaction’s commit() throw rather than touch a freed session.

  • A Cypher query builder ships inside the Java JAR (io.github.kkollsga.kglite.dsl), at the same version as the engine it emits for. import static io.github.kkollsga.kglite.dsl.Cypher.*; is the only import: from there the step types offer only the continuations the grammar allows, so an out-of-order clause is a compile error. It covers MATCH / OPTIONAL MATCH (node, relationship and path patterns), the whole WHERE predicate set, WITH as project-aggregate-filter, RETURN with DISTINCT, the aggregates (count/collect/sum/avg/min/max) and the structural functions (properties/labels/id/type), ORDER BY / SKIP / LIMIT, and CREATE, MERGE (+ ON CREATE SET / ON MATCH SET), SET (including += $map), REMOVE, DELETE, DETACH DELETE and the UNWIND $rows batch form. stmt.cypher() and stmt.params() are exactly what will run; stmt.on(graph) picks cypher() or query() from the statement’s own type, so the binding’s most-documented footgun is unreachable through it, and stmt.on(tx) stages the same statement into a transaction instead. A caller value can only ever become a parameter — no method anywhere takes Cypher text in a value position — and an identifier is validated where it is constructed, so caller data cannot become syntax on this route. Rows come back as List<Map<String, Object>>, identical to the raw route: there is no typed row, no object mapping, and no returning(node) while RETURN n still crosses the ABI as a debug string.

  • The Java DSL has a three-tier escape hatch, so what it does not model it hands back rather than blocks. Cypher.raw(fragment[, params]) is an expression or predicate usable anywhere either belongs; Cypher.rawClause(fragment[, params]) and the rawClause on the chain are a whole clause, at the start of a statement or in the middle of its pipeline; and cypher()/params() hand the finished text to query()/cypher() for full drop-out. Procedures, graph algorithms, vector search, subqueries, UNION, DDL, scalar functions, CASE, map projections and variable-length paths reach the engine this way, deliberately — a builder adds nothing where the string is already the best Java for the job. A raw fragment’s own named parameters are emitted unchanged (the emitter’s $p<digits> namespace is reserved and a fragment claiming it is refused when built). The fragment itself is emitted verbatim, so it is the one path where injection safety is the caller’s responsibility, and it is documented as such rather than implied to be covered: keep the fragment a constant and put every varying value in the parameter map.

Changed

  • add_connections resolves its edge property columns once per call instead of once per cell. Column names are turned into keys and positional indexes a single time, and those keys travel to the edge store unchanged; previously every cell of every property column paid a name-keyed frame lookup, a key clone into a per-row map, and a second interning pass on the way out. Measured on 60,000 edges carrying 10 property columns (release build, in memory): 66.7 ms to 30.4 ms per call, with the property-specific part of the work down 59%. Edges without property columns improve about 12%; null cells are still skipped, so an all-null column stores nothing.

  • The Java binding’s documentation is rewritten against a docs-blind consumer run. kglite-java/README.md is now one page covering the things a consumer previously had to discover by experiment: the cypher (write) versus query (read) contract and the error each throws when misused; a value-mapping table — including that integers always return as Long, so an Integer parameter comes back widened, and that RETURN n on a whole node yields a debug string rather than a structured value (use properties(n), labels(n), id(n)); that save() is the only thing that persists anything and close() discards unsaved work silently; the threading guarantees; the writer lease being cooperative rather than enforced, and its two sidecar files persisting after release. The quickstart now compiles and runs exactly as printed. Every public member carries javadoc, and the javadoc build runs -Xdoclint:all -Werror, so a missing @param fails the build instead of printing a warning nobody reads.

  • The ecosystem version-consistency checker now reads a Maven XML <dependency> block, not only the group:artifact:version coordinate form. A stale <version> in an install snippet was previously invisible to it.

Fixed

  • A mutation batch that writes nothing no longer advances the graph version. Session::transact forked, bumped and swapped unconditionally, so an empty batch — or one made only of read statements — published a new graph Arc and incremented the version with zero writes, contradicting CommitOutcome::NoWritesNoOp on the sibling commit path. The cost is not cosmetic: a spurious bump makes a concurrent optimistic-concurrency committer fail its base_version check and retry against a graph nothing changed. transact now detects the no-write outcome from the fork’s version delta (DirGraph::bump_version being the canonical mutation signal) and skips both the bump and the swap. Reachable from the C ABI’s kglite_session_execute_mut_batch and kglite_create_edges_batch; add_edges_from_specs also returned early-but-bumping on an empty spec list and now returns without touching the graph at all.

  • Quoted identifiers support backtick escaping. A doubled backtick inside a backtick-quoted identifier now reads as one literal backtick, per openCypher: CREATE (:`Weird) `` creates the label ``Weird``. Previously the tokenizer read to the first closing backtick and doubling was a syntax error, so an identifier containing a backtick was unrepresentable — and a caller that interpolated a label, relationship type, property key, alias or pattern variable into query text had no escape to apply, so such a name terminated its own quote and the remainder was parsed as grammar. Both tokenizers (the Cypher one and the secondary pattern lexer that re-reads re-serialized EXISTS { } / count { } patterns) implement the same rule, and the emitters that write quoted identifiers back out — the pattern re-serializer and kglite._cypher_identifier, which previously rejected an embedded backtick for want of an escape — now emit the doubled form. CYPHER.md documents the escape and states the interpolation obligation.

  • A RETURN or WITH that names one column twice is now rejected instead of silently losing both values. RETURN 1 AS x, 2 AS x answered {x: 2, x: null}; RETURN n.a AS x, n.b AS x answered with n.b alone and dropped n.a without a diagnostic; RETURN count(n) AS c, count(n) AS c answered null. A row is one name-keyed map, so two items sharing a name were never two columns. The parser now raises “Multiple result columns with the same name are not supported” (Neo4j’s wording) naming the offending column, for RETURN, WITH, and subquery bodies alike. Column names stay case-sensitive: AS x and AS X are two columns.

  • datetime() no longer drops the time of day and the zone. datetime('2024-01-15T10:30:00Z') returned 2024-01-15T00:00:00 — as did every zoned stamp, every fractional-second stamp, and …T10:30 — because the fallback split any input on T and re-parsed the date half. The parser now accepts YYYY-MM-DD, …THH:MM, …THH:MM:SS[.fff], and RFC 3339 zoned forms (Z, ±HH:MM); a zone is normalised into UTC (10:30+02:0008:30) because Value::Timestamp has no zone field to carry it, and sub-second digits truncate to second precision. A stamp that carries a time part and does not parse is now NULL — the documented contract — rather than a silently invented midnight. localdatetime(str) had the same defect and takes the same parser, keeping the wall-clock reading and dropping only the zone label, which is what “local” means.

  • Integer overflow and integer division/modulo by zero are query errors. 9223372036854775807 + 1 returned -9223372036854775808, * 2 returned -2, and 1 / 0 and 1 % 0 returned null — a wrong number and a missing one, both silent. + - * / % and unary - on two integers now raise CypherExecutionError when the result leaves the signed 64-bit range, and integer division or modulo by zero raises. This closes the gap between the integer operators and the magnitude/error policy CYPHER.md already declared for temporal and duration arithmetic (“never narrowed, wrapped, or silently truncated”), and matches Neo4j. Float division by zero deliberately stays NULL: the IEEE answer is ±Infinity / NaN, which no wire format this project ships over can carry. Measured in release mode against an identically-shaped wrapping twin, the checked path costs 3.667 ns/op versus 3.648 (+0.5 %, ~0.02 ns) — below the cost of the surrounding value clone.

  • close() on the Java KnowledgeGraph and WriterLease could free the same native handle twice, although both documented themselves as idempotent. Two threads closing at once each saw a non-null pointer and each called the ABI’s _free; a call already in flight could also use a pointer another thread had just freed, and a thread that never synchronized with the closer could keep reading a stale one indefinitely. Both now hold their pointer in a shared guard: a call takes a read lock and makes the native call with it held, close() takes the write lock, so it waits out every in-flight call, frees exactly once under any interleaving, and is a no-op afterwards; a call arriving after it throws IllegalStateException instead of touching freed memory. Concurrent calls stay concurrent — the threading guarantees are unchanged — and the one interleaving a read/write lock cannot serve, closing from inside a call on the same thread, reports an IllegalStateException rather than deadlocking.

[0.15.9] - 2026-08-10

Added

  • A Java wrapper over the C ABI (kglite-java/), unpublished. A lean Panama/FFM binding — KnowledgeGraph, WriterLease, StorageMode, KgliteException, and a Cypher-first surface — plus a pinned ABI contract (src/test/resources/abi-contract.txt) that fails on any added, removed or reshaped kglite_* declaration in crates/kglite-c/include/kglite.h. It is source only: no artifact is published to Maven Central, and no CI job builds or tests it — packaging (natives-in-JAR) and the CI leg are a deliberately deferred follow-up phase, so its 12 tests are verified by running gradle -p kglite-java test locally against a freshly built libkglite_c (green at this release). Consumers cannot depend on it yet. Note it is also the only gate that pins the ABI’s numeric status discriminants: the cbindgen header-drift check regenerates both sides, so a renumbering passes it.

  • The C ABI gained the writer lease. kglite_writer_lease_acquire / kglite_writer_lease_free expose the cross-process single-writer lease (KgliteWriterLease) that the wheel’s kglite.open(..., lock=True), the CLI and the MCP server already take, so a non-Rust binding can hold write ownership of a path across its whole read-modify-save interval instead of racing another process to publish a snapshot. timeout_ms = 0 is fail-fast; a refusal returns the new KGLITE_STATUS_CODE_WRITER_LEASE_HELD (409 as an HTTP status, retriable) with a message naming the holding process and when it took the lease. Freeing the handle releases the lease; never freeing it holds it until the process exits.

  • The C ABI gained the mode-aware open. kglite_open_or_create_graph_in_mode opens or creates a graph at a path and honours the storage mode on both branches — a missing path is created in it, an existing graph in a different mode is converted to it, and a conversion with no in-place transition is refused with the reason named. Passing a null mode means unspecified: the graph comes back in the mode its checkpoint recorded. A conversion is reported through the new out_converted_from out-parameter rather than performed silently. Previously the C boundary could only kglite_load_file an existing graph or create an empty one, with no way to express “open this in this mode”.

  • The C ABI can now be asked which storage mode a graph is in. kglite_graph_storage_mode returns "memory", "mapped" or "disk" for a graph handle, reading the same classification behind the wheel’s graph_info()["storage_mode"]. out_converted_from above only speaks when a conversion happened, so a binding that created a graph, or opened one in unspecified mode, previously had no way to find out which backend it actually got — it could only infer the mode from a report that is silent in exactly that case.

  • kglite_status_code_name_static names a status code without allocating. Same text as kglite_status_code_name but the returned pointer is 'static library data that must not be freed, so a binding that renders the name on every error — the usual shape, since the name goes into the exception it raises — no longer pays an allocate/copy/free round trip per failure. kglite_status_code_name is unchanged and still returns an owned copy: this ABI is additive-only within a major version, and flipping who frees the existing pointer would have turned a correct caller into a double-free.

  • The C ABI can now save a graph it mutated. kglite_session_new takes ownership of the graph handle, and every save entry point required one, so a binding could open-and-mutate or open-and-save but never open-mutate-save — the cycle the writer lease exists to protect. kglite_session_save closes it, with the same fsync durability choice as kglite_save_graph_durable and the same mode-aware dispatch, so a checkpoint reopens in the mode it was written in. The header now also states when that ownership transfer happens: the graph is consumed only on Ok, and on any error the caller keeps it and must still kglite_graph_free it. It always behaved that way — the move sits after argument validation — but the header said “MOVED” unconditionally, so a binding that believed it leaked the graph on every failed session open.

  • Session::save (Rust API). Persists a session’s graph through the session’s own Arc under its lock. Saving a Session::snapshot() cannot do this: a save mutates the graph it writes (save metadata, index keys, columnar consolidation), so a snapshot clone hands Arc::make_mut a shared pointer and deep-copies every node, edge and index on every checkpoint. Any binding that holds a Session — the Bolt server, the C ABI, a future JVM/Go binding — now has a no-copy checkpoint, which was previously unreachable outside the engine because the session’s Arc is private.

Fixed

  • A columnar SET / REMOVE no longer re-points every node of the type. Each node used to hold its own Arc of its type’s column store, so a single-row write forked the whole store and then swept every node of that type to re-point it — O(N_type) per clause regardless of how many rows changed, on every graph that had been saved. A node now carries only its row id and the backend owns the store, so a one-row write mutates one row in place. Rolling a statement back likewise restores one Arc per touched type instead of re-pointing every node.

  • enable_columnar() spilling to disk now actually reclaims the memory. With every node holding a strong handle, the Arc::make_mut inside maybe_spill_columns forked: the spilled, file-backed copy became the master while all N nodes kept the pre-spill in-heap store alive. Reads were correct; nothing was reclaimed. The store is now uniquely owned, so the materialisation happens in place.

  • A REMOVE on a saved graph now journals its pre-image. The columnar REMOVE fast path wrote the type’s master store without capturing an undo entry. That was survivable only while every write forked the store; with the backend as sole owner the write lands in place, so a failed statement had nothing pristine to roll back to. Both SET and REMOVE now go through one primitive that captures first and asserts the ordering.

  • A saved graph no longer exports empty node properties. GraphML (to_graphml) and D3-JSON (to_d3_json) emitted every node with zero properties once the graph had been saved (or otherwise converted to columnar storage) — the property count was right, the property set was empty. The same read shape also blanked the property statistics and node samples in describe(), dropped every property from add_properties’ copy-from-ancestor modes and from connect’s property collection, and made statistics() / calculate() expressions evaluate against an empty object on a saved graph. All of these now read through the storage backend, which resolves the node’s column store.

Changed

  • A write while a query result, freeze(), Session or open transaction is held no longer copies the graph. Holding any of those pinned a second reference, and the next write deep-copied every node, edge and index. The writer now forks to a copy-on-write overlay and shares the untouched data with the reader. Held-view first write at 1M nodes:

    graph

    before

    after

    plain

    36.3 ms

    4.6 µs

    saved / columnar

    ~17 ms

    4.0 µs

    2 property + 1 composite + 1 range index

    ~180 ms

    ~97 µs

    resident growth, 20 writes under a held view

    +668.8 MB

    +0.0 MB

    Held views keep their own pre-write rows exactly as before; the graph folds back to the flat representation on the first write after the reader drops. Two honest limits: a node/edge removal (and a statement rollback that undoes a CREATE) still flattens the overlay once per fork, because an overlay cannot express an adjacency edit; and a reader held continuously across many writes pays an amortised flatten of |index| / 32 per write — on a 1M indexed graph that is ~4 ms, so the median improves ~1,500x and the mean ~32x. range_indices is deliberately not shared and is now the entire remaining fork cost on an indexed graph (~90 µs for ~1,000 distinct values, and linear in that count). Design record: docs/rust/structural-sharing.md.

  • A snapshot no longer reports the writer’s edge-type counts as its own. edge_type_counts_cache and type_connectivity_cache were Arc-shared by a plain clone, so a fork that recomputed them wrote a value the other holder read back as its own — a wrong observable, not merely duplicated work. Both are now fork-private: a clone starts with an empty cache. wkt_cache (a pure function of its key) and property_ndv_cache (version-tagged, and only a planner estimate) stay shared deliberately.

  • Rust API: DirGraph::property_indices and DirGraph::composite_indices now hold LayeredIndex<Value> / LayeredIndex<CompositeValue> instead of a bare HashMap<_, Vec<NodeIndex>>. LayeredIndex keeps the map shape the field had — get, get_mut, contains_key, len, iter, remove, clear — with entry_or_default(&key) in place of entry(key).or_default() and retain_members(f) in place of values_mut(). Also part of the same change: GraphBackend::Memory / Mapped now carry an Arc, a GraphBackend::Forked variant exists, GraphBackend::is_forked() is public as a diagnostic, and edge_type_counts_cache / type_connectivity_cache are ForkPrivateCache.

  • A fired auto-vacuum pauses ~45% shorter. The compaction rebuild now relocates node weights instead of deep-cloning them, remaps edge endpoints through a dense table instead of a hash map, and rebuilds type indexes once per type instead of once per node. Trigger thresholds, post-vacuum semantics, and the vacuum-off path are unchanged (off-arm measured within 0.6%).

  • Rust API: node property reads have a single authoritative route, kglite::api::NodeView, obtained from GraphRead::node_view(idx) or DirGraph::node_view(idx). GraphRead gains node_view, node_row_properties, node_property_keys, node_has_property and node_property_count; unlike NodeData::property_iter, every enumeration on NodeView is complete for columnar rows. discover_property_keys_from_data, discover_property_keys_excluding, cypher::resolve_node_property and the three fluent::node_* temporal predicates now take NodeView instead of &NodeData; pass graph.node_view(idx) where you passed a &NodeData before. (Correction, 0.15.11: this entry originally also suggested NodeView::from(&node_data), but that From impl is removed by the entry below — graph.node_view(idx) is the route.)

  • Rust API: NodeData’s property readers are removed — get_property, get_property_value, get_field_ref, property_keys, property_iter, property_count, has_property, properties_cloned, to_node_info, get_node_type_ref, field_contains_ci, field_starts_with_ci. Every one of them read the node’s own replica of a columnar type’s column store, and property_iter silently yielded nothing there. Use NodeView, whose methods carry the same names (except property_iter, whose replacement is property_pairs_named) and are complete for every storage variant: graph.node_view(idx) instead of graph.get_node(idx). NodeData keeps id(), title() and node_type_str().

  • Rust API: the storage backend is now the sole owner of a columnar type’s ColumnStore. DirGraph::column_stores (the public field) is replaced by delegating accessors — column_store, column_store_mut, install_column_store, take_column_store, clear_column_stores, column_stores_by_name, column_store_count — and GraphRead /GraphWrite gain column_store / column_stores_iter / has_column_stores and the install/take/clear pair. DirGraph::sync_disk_column_stores and sync_column_stores_from_disk are removed, along with DiskGraph::set_column_stores: there is no second copy to mirror. NodeData::set_property / remove_property / clear_property are removed — a columnar node has no per-node storage to write into; use GraphWrite::set_node_property and its four siblings, which route by storage variant. impl From<&NodeData> for NodeView is removed because a view can no longer be built without the backend; use GraphRead::node_view. graph_info() gains columnar_heap_bytes and columnar_is_mapped.

[0.15.8] - 2026-08-09

Added

  • A saved graph now records its storage mode, and reopening honours it. A .kgl written by a mapped graph comes back mapped — from kglite.open(path), kglite.load(path), the CLI and the servers alike — with no storage= argument anywhere. A memory-saved graph still comes back memory, and a checkpoint written before the mode was recorded carries no record and loads as memory exactly as it always did. graph_info() gained a storage_mode key ("memory" / "mapped" / "disk") so a caller can confirm which backend an open actually landed on.

  • storage= on an existing path now converts instead of refusing. kglite.open(path, storage="mapped") on a memory-saved graph switches the loaded graph onto the mapped backend — same nodes, edges and rows, no re-ingest and no copy of the topology — and the next save() records the new mode. storage="memory" on a mapped-saved graph converts the other way. Both portable modes wrap the same graph structure, so the switch changes the backend and the column-spill policy, not the data. Previously the only route to a mapped graph was to rebuild it from the original source.

  • The servers’ --storage now applies to an existing graph too. kglite-bolt-server and kglite-mcp-server used to parse the flag and drop it whenever --graph already existed: an operator who wrote --storage mapped in a unit file got a memory server and no message. It now means the same thing on both branches — create a missing graph in that mode, convert an existing one to it — matching kglite.open(path, storage=...). The Bolt startup log records converted_from when a conversion happened, and a disk request on a .kgl (or a portable request on a disk directory) fails startup naming enable_disk_mode() instead of serving a mode nobody asked for. Omitting the flag serves whatever mode the graph recorded.

  • Workspace graph producers now receive filtered filesystem changes. Lazy watcher rebuild requests distinguish full builds from deterministic, deduplicated changed-path hints while still returning a complete graph.

  • Boot-validated Cypher recipe catalogs for MCP agents. Manifests can declare grouped, parameterized read-only operations under extensions.cypher_recipes; non-empty catalogs expose list_recipe_queries and run_recipe_query with progressive graph_overview discovery, strict variables, structured success/error envelopes, stale-graph rejection, and a 200-row all-or-error payload cap.

  • Rust API additions for the storage-mode feature: kglite::api::io::open_or_create_graph_in_mode, kglite::api::storage::{live_storage_mode, convert_dir_graph_to_mode}, and a new OpenGraphResult::converted_from field. The added field is a semver-major change for code constructing OpenGraphResult with a struct literal (documented breaking change, shipped in a patch per project policy).

Fixed

  • A write loop no longer evicts every other graph’s cached query plan. The optimized-plan cache is process-global and holds 512 entries, and every CREATE/SET/DELETE/REMOVE/MERGE used to store its plan under a key containing the graph version that the same statement then bumped — so the entry could never be read back, and 600 writes were enough to cycle the whole cache through entries nobody could use. A graph being written to now stores no plan: writes are marginally faster (a single-node CREATE 1.333 → 1.292 µs, a SET 3.166 → 2.959 µs at 100k nodes), and a reader whose plan used to be evicted by an unrelated writer keeps it (3.125 → 2.375 µs, -24%). Read plans are cached exactly as before. The one behaviour given up is deliberate: two transactions forked from the same version, or a retry of a write that failed before the version bump, previously reused a cached plan and now re-plan.

  • A corrupt or unrecognised storage mode in a saved graph is now refused by name. A .kgl claiming a mode this build does not know, one claiming disk (a disk graph is a directory, never a portable file), or a disk directory whose metadata.json claims a portable mode all fail the load with an error naming the offending value, instead of silently loading as memory. The two disk conversion directions refuse structurally for the same reason, naming enable_disk_mode() as the alternative.

  • The writable Bolt server now takes the cross-process graph writer lease. kglite-bolt-server opened its --graph without one, so a second writable server — or a concurrent kglite CLI write, MCP server or kglite.open() — could load the same path and silently overwrite the other’s work at save time. The lease is now acquired before the graph is read and held until shutdown; a contended start fails immediately naming the holding process instead of serving. --readonly servers take no lease and still start alongside a live writer.

  • Negative numeric literals now parse in MATCH inline property maps. MATCH (n {temp: -1}) and -[r:DELTA {change: -1.5}]-> raised Pattern parse error: Expected value, got Dash; the sign is now lexed as part of the literal (so -9223372036854775808 reaches i64::MIN) and negative variable-length hop counts are rejected with a clear message.

  • MCP selftests now require positive activation and hydration evidence. Missing workspace paths and absent active graphs can no longer pass merely because their tool failures arrived in a successful MCP response envelope.

  • Manifest-declared MCP Cypher tools now refresh watched workspace graphs before querying. After a relevant filesystem change, tools[].cypher previously served the stale active graph until a different graph tool triggered the lazy rebuild.

  • Concurrent MCP reads now wait for an in-flight workspace graph rebuild. A second request could previously query the old graph while another request prepared and installed the refreshed generation.

Removed

  • kglite skill install / kglite skill uninstall are gone. The code-review Agent Skill is installed by codingest, which also builds the code graphs the skill queries: run codingest skill install instead. It removes a CLI-managed legacy copy from an earlier kglite skill install as part of installing its own, and leaves an unmanaged copy untouched. The bundled skill assets ship with codingest and are no longer compiled into the kglite CLI or the wheel.

[0.15.7] - 2026-08-06

Changed

  • The MCP server now uses mcp-methods 0.4.4 and rmcp 3.1.1. Dynamic tool handlers use rmcp’s response envelope while preserving the existing tool result payload and MCP behavior.

[0.15.6] - 2026-08-06

Added

  • api::fluent::get_node_degrees collects owned title/degree rows for a selection under one storage read pass, giving non-Python bindings the same bulk primitive used by the wheel.

Changed

  • Multi-type exact vector search caches embedding-store resolution by interned node type. The measured two-store exact scans are 34–36% faster with identical IDs, ordering, scores, and stored-metric behavior.

  • HNSW traversal uses integer-specialized visited-set hashing. Across the measured index configurations, builds are 7–19% faster and fixed-topology searches are 14–25% faster with unchanged ordered results and recall.

  • Coreness reuses its final degree storage as the result and builds overwritten bookkeeping buffers directly. The in-memory 2,048-node gate is 6–16% faster with exact memory, mapped, and disk parity.

  • Euclidean DBSCAN avoids zero-initializing its full distance matrix before overwriting it. Safe capacity-plus-push construction is 23–25% faster on the 2,048-point gate, with exact cluster/noise and cancellation parity.

  • Clustering and triangle counting skip sorted-neighbor prefixes that cannot intersect. Clustering is 12% faster on the dense gate and 80–81% faster on the sparse hub gate, with exact HashSet-oracle parity.

  • Bulk degrees() no longer opens query guards and materializes full nodes once per selected row. On the 10,000-node ring gate this is 6–18% faster in memory and 49–51% faster on disk than the 0.15.5 wheel, with exact memory/mapped/disk parity.

  • Bulk selected-node updates reuse their initial liveness/type validation instead of materializing each disk node and then checking every target a second time. The 10,000-node update gate is 16–27% faster on disk, with no memory or mapped regression and exact parity across all three backends.

Fixed

  • Mixed-type vector search no longer chooses a stored distance metric from randomized map iteration. When metric is omitted, only embedding stores contributing selected vectors participate; searches use their unique metric, fall back to cosine when none contributes, and require an explicit metric when selected stores disagree.

  • Community-detection modularity now reports the complete Newman score. Louvain, Leiden, and label propagation include the expected-degree term for non-edges and honor connection-type and node-scope filters when scoring the returned partition, including weighted, parallel, and self-loop edges.

  • Sampled centrality rejects an empty source sample. Passing sample_size=0 to betweenness or closeness centrality now returns a clear error instead of producing NaN betweenness scores or an empty closeness result.

  • Persisted HNSW indexes are validated before attachment. Malformed topology is now discarded as a rebuildable cache while exact vector search remains available; malformed embedding-store cardinalities fail loading cleanly instead of panicking. Vector queries also bypass an index built for a different distance metric rather than navigating with the wrong metric.

  • Vector search now dispatches mixed selections correctly. A same-sized selection that omitted embedded nodes but included unrelated or unembedded nodes could be mistaken for the whole embedding store, allowing fluent HNSW search to return an omitted node or a fused Cypher top-k to return fewer than its limit. Selections spanning multiple embedded types also now rank across every type instead of silently returning only the first candidate’s type, including with exact=True.

[0.15.5] - 2026-07-31

Added

  • workspace.sandbox_root — a real containment boundary for set_root_dir. Requires mcp-methods 0.4.3. Opt-in: without the key, a root swap remains unbounded, which is the prior behaviour, preserved so the upgrade breaks nobody. With it, a swap outside the boundary is refused and the active root does not move.

  • workspace.adopt_client_roots — adopt the MCP-client-advertised root as a fallback when no explicit root is configured. Explicit configuration always wins. Pair it with sandbox_root: an adopted root is proposed by an external party. Note that MCP roots was deprecated upstream in protocol revision 2026-07-28 (SEP-2577) — the key works today and is inert when unset, but passing the directory as a tool parameter or server configuration is the spec’s own migration path. See the manifest guide.

Fixed

  • Documentation claimed a sandbox boundary that did not exist. Three places described workspace.root as an “immutable sandbox boundary” that set_root_dir validated against — the manifest guide’s tool table, the local-code-review example, and the manifest-workspace example, which even showed a refusal ("Error: path '/tmp' escapes the workspace root.") the server never produced. No containment existed: the read window was derived from the active root, so the source tools’ checks bounded reads relative to wherever the server already pointed and never constrained where it could be pointed. All three corrected, and the boundary they described is now real — behind workspace.sandbox_root.

Changed

  • Minimum mcp-methods is now 0.4.3 (from 0.4.2). Beyond the two keys above, 0.4.3 refuses a manifest with watch: true and no root at load time (previously accepted, then silently dead), and stops an activation refresh from superseding a bind.

[0.15.4] - 2026-07-31

Added

  • DirGraph::checkpoint_lsn (Rust API). Public because the wheel crate sets it across a crate boundary at save() and reads it at load to gate WAL replay. Additive: the field defaults to 0, which is the pre-gate replay-everything behaviour, so existing embedders are unaffected.

Changed

  • Mapped graphs no longer copy the whole graph before every mutating statement. MappedGraph now carries a statement-scoped undo journal, so a mutating statement records inverse operations instead of forking an O(V + E) checkpoint. Previously the journal was vetoed for both mapped and disk backends, which since the columnar-journal work was the only remaining veto term — so mapped writes kept paying the pre-journal cost, scaling with graph size in exactly the mode chosen for large graphs.

    The veto’s stated reason was wrong for mapped: MappedGraph holds the same heap StableDiGraph as MemoryGraph, so it could always express an inverse edit. Disk is unchanged and still takes the checkpoint — it has no petgraph and no stable node identity to restore, so the reason holds there.

    Rollback fidelity on mapped graphs is now covered by the full mid-statement failure matrix rather than a single shape; before this change no test in the suite executed the mapped rollback path at all.

Fixed

  • WAL replay is now gated on the checkpoint, so a stale log cannot roll a durable graph backwards. A .kgl checkpoint records the highest log-sequence number it already contains, and reopening a durable= graph replays only the frames above it. Previously replay started from zero and folded in every frame the sidecar held, so a <graph>-wal that predated the checkpoint — restored from a backup, carried along in a half-copied graph directory, or otherwise surviving the truncation — would overwrite already-durable properties with their values as of an earlier commit.

    0.15.0 added a log barrier before the checkpoint, which prevents a stale prefix from arising under a clean crash; this makes recovery robust to one that arrives any other way. The frame counter also no longer restarts at each checkpoint, so a pre-checkpoint frame can never reuse a live frame’s sequence number.

    The stamp is additive and written only by a durable save, so existing .kgl files load unchanged (replaying everything, as before) and a graph that was never durable serializes byte-for-byte as it did previously.

[0.15.3] - 2026-07-29

Added

  • A tested sdist, so platforms without a wheel can install at all. kglite publishes wheels for the common targets; anyone else — an older-glibc Linux (glibc < 2.17), a BSD, an unusual arch, or an aarch64-musl box on a release where that best-effort leg did not build — had no install path, and at least one downstream ended up vendoring a fork over exactly that. pip now falls back to building from source (a Rust toolchain is required).

    This reverses a documented policy, deliberately: platform-support.md previously said “no supported sdist” because an untested source distribution is a liability. So the release job proves the artifact rather than producing it — it unpacks the tarball into an empty directory to confirm it resolves with no sibling checkouts present, then pip installs it, compiling the engine exactly as an uncovered-platform user would, and imports and queries the result. A source fallback that cannot build is worse than none: it turns a clear “no matching distribution” into a compile error inside someone else’s install log. Wheels remain the supported path, and the sdist is verified on the CI runner rather than on every target it may reach.

Fixed

  • The crates.io publish workflow executed a line of its own explanatory text. An index-propagation fallback printed Proceeding anyway `cargo publish` for the next crate will surface a real error, but unescaped backticks inside a double-quoted shell string are command substitution — so the sentence ran cargo publish, inside the publish job, immediately before publishing the next crate, against whatever package cargo picks by default. Confirmed by executing it rather than reading it. Escaped, and a test now rejects unescaped backticks in any run: block across every workflow.

  • The workspace now builds at its own declared dependency minimums, and CI enforces it. 0.15.2 fixed 17 understated floors but the build at those minimums still failed, on a floor that was not ours: mcp-methods declared ignore = "0.4" while calling WalkBuilder::filter_entry, which arrived in 0.4.15. That is fixed upstream in mcp-methods 0.4.2, so the requirement here moves to 0.4.2 and same-file to 1.0.4 (ignore 0.4.15 needs it). With those, resolution and the build both succeed, and the minimal-versions CI job became an ordinary blocking gate — no continue-on-error anywhere in it. A consumer pinning our declared floors now gets a workspace that compiles.

[0.15.2] - 2026-07-28

Fixed

  • from_blueprint(save=True) no longer silently does nothing. save defaulted to True, but the save only ran when the blueprint declared an output / output_file setting — so the common call, with the flag left at its default, persisted nothing while reading as if it did. Two independent reports landed on the same day. On a storage="disk" build the consequence is worse than a missing file: the directory passed as path is the live working directory, publication happens only at save(), and the skipped save left behind .kglite.lock, a .working-<pid>-<n>/ directory and a partial seg_000/ — a directory that looks like a graph and that kglite.load() rejects with “Directory does not contain a valid disk graph (missing disk_graph_meta.json)”. A downstream package’s disk cache had been built on that call and had therefore never once committed a graph.

    A build now has a save destination when the blueprint declares one or when storage="disk" was given a path; in disk mode the directory is the graph, so that is what the flag has to mean, and from_blueprint(storage="disk", path=out)kglite.load(out) now round-trips. The flag itself stops overpromising: save now defaults to None — save if a destination exists, build in memory if not — while an explicit save=True with nowhere to write raises ValueError naming both ways to give it a destination, matching KnowledgeGraph.save(), which already refuses rather than guesses when it has no path. save=False is unchanged, and so is every build whose blueprint declares an output.

  • “Did you mean?” suggested names that were not close. The edit-distance threshold behind every suggestion — unknown node label, unknown relationship type, unknown property, and the mutation-path validators — was input.len().clamp(2, 4), so a four-character name admitted a distance of 4 and matched very nearly any word of similar length. Scored over 66 cases drawn from kglite’s own vocabularies, that rule produced 18 confidently wrong suggestions, among them IsuePaper against a {Person, Paper} schema, LineFile, and WROTEKNOWS. A confidently wrong suggestion is worse than none: it names a real but irrelevant thing the reader may act on. The threshold is now rustc’s max(len, 3) / 3 over character count, and the distance is Damerau–Levenshtein, so an adjacent transposition (Persno) costs one edit rather than two — swapped letters are among the most common real typos, and charging them double forced any threshold loose enough to catch them to also admit unrelated words. A case-only typo now gets a suggestion as well (personPerson), which the old filter discarded because its distance is 0. The text_edit_distance() Cypher function is unaffected: it is a user-facing string metric, not a typo heuristic.

  • A typo’d label or relationship type inside a subquery produced no warning. kglite warns non-fatally — on stderr and in result.diagnostics — when a read pattern names a node label or relationship type the graph has never seen, the most common cause of a silently empty result. The collector walked only top-level MATCH / OPTIONAL MATCH, so the identical typo inside CALL { }, WHERE EXISTS { } or a UNION branch said nothing, and the shape of the query decided whether you were told. The warning and the locked-schema check now reach patterns through a single traversal, so the two surfaces cover exactly the same clauses and a newly-covered form lands on both at once. That traversal is not yet exhaustive — COUNT {} subqueries, EXISTS {} in expression position, and update clauses inside FOREACH reach patterns through a different node type and are still unvisited — but it is now one list for both consumers instead of two that could drift. Nothing became an error, and write patterns (CREATE, MERGE) are still never warned about — on an open schema that is how a node type comes into existence.

  • Seventeen dependency requirements that understated the version our code actually needs (30 requirement entries, since several crates declare the same dependency and cargo unifies the version across the workspace). The minimal-versions CI job added in 0.15.1 found the first one on its first real run, and pulling that thread surfaced the rest. A requirement like async-trait = "0.1" or stacker = "0.1" is invisible to us — our lockfile already holds a working version and a fresh resolve picks the newest match — but a consumer whose lock pins the declared floor gets a resolution failure or a compile error in their repo. Each new floor was determined by compiling against it, not by reading metadata: stacker 0.1.0–0.1.3 abort the build on aarch64-apple-darwin, tokio gained the JoinSet that rmcp 2.2 needs in 1.21.0, regex gained the std feature tracing-subscriber requires in 1.3.0 (and tokenizers 0.22 pushes it to 1.10), and anyhow 1.0.0 lacks the Context impl for Option that kglite-cli uses.

    One floor needed a stronger test than compiling. Below anyhow 1.0.47, anyhow!("failed: {e}") compiles and emits only a warning, but the macro sent a lone literal straight to Error::msg, so the error a user saw printed the characters {e} instead of the value — kglite-cli formats several of its errors that way. That floor was fixed by running each candidate version and reading the output (1.0.45 prints failed: {e}, 1.0.47 prints the value; 1.0.46 is yanked).

    Affected requirements: anyhow, async-trait, clap, flate2, hyper, hyper-util, indexmap, libc, memchr, regex, same-file, stacker, subtle, tempfile, tokio, tracing, tracing-subscriber.

    Resolution at declared minimums now succeeds, where before it failed outright. The build at those minimums does not yet complete, and the remaining blocker is not one we can reach: mcp-methods calls WalkBuilder::filter_entry and sort_by_file_path while declaring an ignore floor older than both, and ignore is not a direct dependency of this workspace. Nothing about the released artifacts depends on this — it is a statement about what a consumer pinning our declared floors would get.

[0.15.1] - 2026-07-27

Added

  • A declared minimum supported Rust version on every published crate. All five crates we publish to crates.io shipped without a rust-version, so a consumer on an older toolchain got a compile error from somewhere inside the dependency tree instead of cargo’s clear “package X requires rustc 1.88”. The floors are now declared, and they are not uniform because the dependencies are not: kglite, kglite-c and kglite-mcp-server require 1.88.0 (the workspace default, inherited via [workspace.package]), kglite-cli requires 1.89.0, and kglite-bolt-server requires 1.91.0. Each number was determined by taking the maximum rust-version across that crate’s resolved dependency tree and then confirming the crate actually builds on it — not by reading metadata alone, which understates the answer: rustyline 18 declares no rust-version at all yet calls File::lock_shared, stabilized in 1.89, which is the whole reason kglite-cli sits above the workspace floor. A new CI job builds every crate on exactly the version it declares, with the matrix derived from the manifests, so the contract cannot drift away from its check.

Fixed

  • A storage="disk" graph containing a node type with no rows saved a directory that could not be loaded, failing with File format error: invalid id_indices.bin: directory contains an unresolved type key. Declaring a type that a given data slice leaves empty is ordinary — a blueprint with 26 node types legitimately populates a handful — and the build, the queries, and save() all succeeded, so the failure only surfaced on the next load(). Disk index sidecars address types by interner hash, and the writer derived those hashes from the type name alone instead of resolving them against the interner it persists alongside; a type whose name the interner never carried (nothing interns a type that has no nodes) therefore got a key that nothing could resolve. The writers now resolve, so the directory they emit is always one the same build can read back. The same failure could be triggered on any disk graph by a read-only query that merely mentions an unknown label (MATCH (n:Ghost {id: 1})) before the next save(). Directories already written that way load again without a rebuild: the stale entry is empty, and an id index is a cache the read path rebuilds on demand, so it is dropped at load. A populated entry under an unresolvable key is still rejected — that is a damaged sidecar, not this bug.

  • Two dependency requirements that understated their real minimum. fastembed = "5" selects the ort-download-binaries-native-tls feature, which did not exist until 5.9.0; mimalloc = "0.1" selects the v2 feature, which did not exist until 0.1.49. Both now declare those minimums. Neither could fail for us — our lockfile has held newer versions throughout — but either would fail at resolution for a consumer whose lock pinned an older version, with “package does not have that feature”. This is the same class as the mcp-methods = "0.4" fix that shipped just before it, and it was found by the new minimal-versions CI job rather than by a downstream report. serde, serde_json and chrono requirements that differed between our own workspace members were aligned to the tightest floor any member already declares.

[0.15.0] - 2026-07-27

Added

  • A stable .code on every kglite exception. exc.code is the wire-stable classifier ("ConstraintViolation", "TransactionConflict", "CypherSyntax", …) that the C ABI already exposed as KGLITE_STATUS_* and the Bolt server already mapped to Neo.*, but which had no way of reaching Python — dir(exc) previously showed only add_note, args, and with_traceback, so applications had no choice but to match on message prose. It is readable on the classes too (kglite.ConstraintViolationError.code), so a dispatch table can be built without an instance, and is None on the three abstract bases (KgError, CypherError, ConstraintError) which span several codes.

  • kglite.TransactionConflictError, raised when Transaction.commit() loses an optimistic-concurrency race. This previously arrived as ArgumentError — “Invalid argument: Transaction conflict…” — which is the wrong class for a condition whose correct handling is “retry the transaction”, not “fix the call”. The message now also reports the version gap, and the C ABI gains KGLITE_STATUS_CODE_TRANSACTION_CONFLICT = 20 (appended, so existing discriminants are unchanged). The Bolt status string Neo.ClientError.Transaction.ConflictDetected is unchanged.

  • kglite.retry_on_conflict(graph, work) — the commit-retry loop every concurrent writer needs, with exponential backoff and full jitter. work(tx) is re-run against a fresh begin() on each attempt; only conflicts are retried, and the final conflict is re-raised unchanged. Conflicts are ordinary rather than rare here, because OCC compares a whole-graph version counter (see the documentation fix below).

  • kglite.open(..., durable="normal") — a middle durability level. Until now durable offered only “barrier on every commit” or “no log at all”, and the gap between them is where most applications actually live. durable now takes SQLite’s synchronous vocabulary, naming what a committed mutation survives rather than which syscall runs:

    • "full" (also spelled True, and still the default) — survives power loss. One barrier per commit.

    • "normal" — survives the process dying: SIGKILL, an unhandled panic, an OOM-kill. The log frame is in the kernel’s page cache before the call returns, and the page cache outlives the process. An OS crash or power loss loses commits made since the last save(). No barrier per commit.

    • "off" (also spelled False) — no log; save() is the only durability point.

    True/False are accepted spellings rather than a second code path, so existing calls are unaffected and the default is unchanged. The levels are stated as guarantees because the syscall behind them differs by platform while the guarantee does not — which is also why there is no separate “plain fsync” level: fsync is the power-loss barrier on Linux but not on macOS, so it could not be given one honest description.

    The levels are deliberately not uniform across storage modes. storage="disk" supports only "off"; both "full" and "normal" raise ValueError there, because a disk graph commits by publishing an immutable generation rather than by logging a write. The refusal now names the level that was requested and the modes that do support logging.

  • KnowledgeGraph.sync() — the on-demand barrier. Flushes every commit made so far to stable storage: the barrier durable="full" performs on every commit, taken when the caller wants it. This is what makes "normal" adoptable rather than merely fast — without it, a "normal" graph’s only route to power-safety is a full save(), which republishes the entire graph and is the wrong granularity for “flush at the end of a request” or “flush before shutdown”. Returns immediately under "full" (the guarantee already holds) and raises ValueError on a graph with no log, rather than silently doing nothing. Pending mutations are folded into the log first, so it cannot report success over ops that never reached it.

  • kglite-bolt-server --neo4j-compat (or KGLITE_BOLT_NEO4J_COMPAT=1) makes the server present a Neo4j-compatible agent in the Bolt handshake, so official drivers that refuse to connect to a non-Neo4j server will talk to it. The official Java driver is the one that does this: it requires the handshake agent to start with Neo4j/ and otherwise aborts with UntrustedServerException: Server does not identify as a genuine Neo4j instance before running a query, which left kglite unreachable from the JVM. With the mode on, the agent becomes Neo4j/5.26.0 (kglite-bolt-server/<version>) — the prefix the driver checks for, with the real product kept in the string, so the server stays identifiable in logs, in driver errors, and through ServerInfo.agent(). Only the handshake’s server field changes; bolt_agent still reports kglite. The environment variable takes 1/true/yes/on for containers and unit files, and the flag wins if both are set. This is the --neo4j-compat flag the 0.10.1 notes reserved for exactly this situation.

    It is off by default: presenting as a different product is the operator’s decision, and the official Python and JavaScript drivers never needed it — neither inspects the agent. When a client whose driver enforces the check connects while the mode is off, the server logs a warning naming both ways to switch it on, so the fix is discoverable from the server’s own log instead of only from a client stack trace. The identity is never switched automatically on the strength of a client-supplied string.

  • Cypher index DDL, in the Neo4j 5 grammar, so a schema-setup script ports unedited: CREATE [RANGE] INDEX [name] [IF NOT EXISTS] FOR (n:Label) ON (n.prop, ...), DROP INDEX <name> [IF EXISTS], and SHOW [ALL] INDEX[ES]. Statements are standalone and route to the existing index machinery — one property builds a hash equality index, two or more build a composite index, and the RANGE keyword additionally builds the B-tree range index, so the pair covers what Neo4j’s single RANGE index serves. The bare form stays equality-only on purpose (building both for every ported statement would double index memory); CYPHER.md documents the full mapping under “Cypher index DDL”.

  • DROP INDEX FOR (n:Label) ON (n.prop) — a KGLite descriptor form, since index names here are canonical and derived (Label.property, Label.(a,b)) rather than user-assigned. A name supplied to CREATE INDEX is accepted for portability but not persisted, and DROP INDEX accepts the dotted canonical name without backticks so SHOW INDEXES output pastes straight in.

  • SHOW INDEXES returns the same rows and columns as CALL db.indexes() (name, type, entityType, labelsOrTypes, properties, state). Neo4j’s id, populationPercent, indexProvider, owningConstraint, lastRead, and readCount are omitted — KGLite holds no equivalent state — and YIELD / WHERE modifiers are rejected in favour of CALL db.indexes() rather than silently ignored.

  • indexes_added and indexes_removed in graph.last_mutation_stats, mirroring Neo4j’s indexesAdded / indexesRemoved summary counters.

  • Index and constraint DDL that KGLite cannot serve — TEXT, POINT, FULLTEXT, VECTOR, LOOKUP, relationship indexes, OPTIONS { ... }, and every CREATE/DROP/SHOW CONSTRAINT form (including the Neo4j 4 ASSERT spelling) — now parses and fails with a specific unsupported-feature error naming the construct and the route that works today, instead of a syntax error or a silent no-op.

  • Real integrity constraints, enforced on every write path — Cypher CREATE / MERGE / SET / REMOVE and the bulk loader (add_nodes, and therefore blueprints, from_records, OKF, WAL replay and extend_graph). Declared through the existing define_schema:

    graph.define_schema({"nodes": {"Person": {
        "unique": [["email"], ["first", "last"]],
        "required": ["email"],
        "primary_key": "email",
    }}})
    

    unique takes a property name, a list of names, or a list of property tuples, so single-property and composite constraints share one surface. A tuple only constrains nodes carrying every property in it, matching Neo4j, where uniqueness does not apply to nodes missing the property.

  • required (NOT NULL) is now enforced at write time, not only by validate_schema(). A CREATE that omits the property, a SET that nulls it, and a REMOVE that drops it are all rejected. Auto-vivified edge stubs are deferred, not exempt: vivification may create an incomplete placeholder, but the later add_nodes upsert that promotes it is a normal, fully-enforced write, and an unpromoted stub stays reportable via validate_schema() and removable via purge_provisional().

  • primary_key accepts any property, not just id, and now means unique and present (NODE KEY semantics). A key on id still routes through the O(1) per-type id index; any other key is backed by a unique secondary index that persists and rebuilds on load like every other index. Older .kgl files load unchanged — both unique and the generalization are additive.

  • Declaring a constraint that the stored data already violates is rejected, and changes nothing, rather than installing a constraint that silently lies about the rows already present.

  • Typed constraint errors: ConstraintViolationError (a write broke a constraint) and ConstraintCreationError (a declaration cannot be installed), both under a new ConstraintError base class, so except ConstraintError catches either. Over Bolt they carry Neo.ClientError.Schema.ConstraintValidationFailed and Neo.ClientError.Schema.ConstraintCreationFailed; the C ABI gains ConstraintViolation = 18 and ConstraintCreationFailed = 19, appended so existing discriminants stay stable.

  • Index DDL that KGLite cannot serve — TEXT, POINT, FULLTEXT, VECTOR, LOOKUP, relationship indexes and OPTIONS { ... } — now parses and fails with a specific unsupported-feature error naming the construct and the route that works today, instead of a syntax error or a silent no-op.

  • Cypher constraint DDL, in the Neo4j 5 grammar, routing to the per-write enforcement above: CREATE CONSTRAINT [name] [IF NOT EXISTS] FOR (n:Label) REQUIRE n.prop IS UNIQUE | IS NOT NULL | IS NODE KEY, DROP CONSTRAINT <name> [IF EXISTS], and SHOW CONSTRAINTS. Composite tuples (REQUIRE (n.a, n.b) IS UNIQUE) constrain the combination rather than each property; the Neo4j 4 ASSERT spelling and the optional NODE / RELATIONSHIP scope word are accepted, so a 4.x-era script ports unedited. A declaration is not documentation — once it succeeds, a violating write is rejected on every path including the bulk loader.

  • IS NODE KEY is served as uniqueness and presence, installed atomically: if the presence half cannot be declared the uniqueness half is rolled back, so a statement that reported failure has changed nothing. DROP CONSTRAINT on a node key withdraws both halves, and a tuple that is unique and fully required now reports itself as NODE KEY rather than as plain UNIQUE.

  • CREATE CONSTRAINT ... IS :: TYPE / IS TYPED TYPE is rejected, not accepted-and-ignored. KGLite has no write-time property-type constraint — field_types is read only by the offline validate_schema(), and a locked schema type-checks against the node type’s recorded property types — so accepting the statement would report success while enforcing nothing, which is worse than an error for a promise users build data-integrity assumptions on. The message names both routes that do enforce.

  • Constraint names are persisted, deliberately unlike index names, so the dominant ported-script shape works: CREATE CONSTRAINT person_email_unique ... followed by DROP CONSTRAINT person_email_unique. A constraint declared without a name is addressable by its canonical descriptor (Label.property, Label.(a, b)), which is also what SHOW CONSTRAINTS prints for it, so that output pastes straight into DROP CONSTRAINT. Names are unique per graph, survive save/load, and a name whose constraint has been dropped is discarded at save time so it cannot resurrect. The registry is a lookup aid rather than the source of truth, so a lost name can cost addressability but never enforcement. Additive JSON metadata, skipped when empty — older .kgl files load unchanged and files without named constraints are byte-identical.

  • SHOW CONSTRAINTS is a read, like SHOW INDEXES: it works on a read-only graph and is unaffected by a write scope. Returns name, type (UNIQUENESS / NODE_KEY / NODE_PROPERTY_EXISTENCE), entityType, labelsOrTypes, properties. Neo4j’s id, ownedIndex, and propertyType are omitted — KGLite holds no equivalent state — and a node key is one row rather than a uniqueness row plus an existence row.

  • CALL db.constraints() returns the same rows from the same collector, so the two surfaces cannot drift. SHOW CONSTRAINTS YIELD ... now points at it rather than at CALL db.indexes(), which listed the wrong objects.

  • constraints_added and constraints_removed in graph.last_mutation_stats, mirroring Neo4j’s constraintsAdded / constraintsRemoved. They count constraints rather than the structures behind them, so IS NODE KEY reports 1.

  • describe() annotates a property with constraint="unique" | "not_null" | "node_key" when one is declared on it, so an agent can see a write will be rejected before attempting it.

  • LOAD CSV [WITH HEADERS] FROM <source> AS row [FIELDTERMINATOR <sep>], in the spelling other Cypher databases use, so a ported import script runs unedited. WITH HEADERS binds each record as a map keyed by the header row; without it, records bind as zero-indexed lists. Fields stay strings — CSV carries no types, and inferring them would corrupt leading-zero identifiers — so conversion is explicit (toInteger(row.id)); an empty field is null, and a short row nulls its missing columns instead of failing the load. FROM $path works. The clause must lead the query; anywhere else it is rejected with the positional rule rather than a confusing pattern error. CYPHER.md documents the full mapping under “LOAD CSV”.

  • LOAD CSV streams: the executor reads 1000 rows at a time and runs the following clauses once per batch, so peak memory does not scale with file size — a 5 MB and a 109 MB input both cost about 20 MB resident on a row-local pipeline. Batching applies to the clauses it is equivalent for (MATCH, WHERE, UNWIND, CREATE, MERGE, SET, DELETE, REMOVE, FOREACH, non-aggregating WITH/RETURN) — the ingest shape, which streams at any file size. A downstream clause that reasons over the whole result (aggregate, ORDER BY, SKIP/LIMIT, DISTINCT, UNION, CALL) cannot be batched without changing the answer, so those queries take a single capped pass and fail at 1,000,000 rows naming the clause that forced it, rather than exhausting memory.

  • LOAD CSV FROM 'http://…' is rejected with a message naming the network-free design and the local-file route, never a parse error: the engine ships no HTTP client (network dependencies were removed in 0.14.x), so there is nothing to fetch a URL with. Other URL schemes name the supported set. The CALL { ... } IN TRANSACTIONS batching modifier (and the older USING PERIODIC COMMIT spelling) remains unsupported — batching here is automatic, so there is no commit interval to declare.

  • --allow-csv-import <DIR> on kglite-bolt-server, and a csv_import field on kglite::api::session::ExecuteOptions. Reading local files through LOAD CSV is a capability the caller is granted, defaulting to denied: in-process callers (the Python API, the Rust library, the CLI) are allowed because they already have the host process’s filesystem access, while a Bolt client is remote and gets nothing unless an operator names an import directory. Imports are then confined to that directory after symlink resolution, so .. segments and symlinks cannot escape it. Without the gate, anyone able to open a Bolt connection could run LOAD CSV FROM 'file:///etc/passwd'.

  • Dotted property access on a map-valued parameter — $row.name, and nested chains like $cfg.a.b. The bracket form ($row['name']) and the via-variable form (WITH $row AS r RETURN r.name) already worked, so the dotted form raising a syntax error was an inconsistency rather than a decision; other Cypher implementations accept it, and a ported query passing a map parameter hits it immediately. Absent keys yield null, as elsewhere.

  • Conformance suites for the official JavaScript and Java Bolt drivers (neo4j-driver, neo4j-java-driver) under tests/conformance/, run in CI by the new bolt-driver-conformance job. Each covers the same 22 checks — session and explicit-transaction lifecycle, managed executeWrite, PackStream type round-trips, Node/Relationship/Path values, Neo.* error codes, OCC conflict detection, and the LOAD CSV capability refusal — and a source-level parity test fails if the two drift apart. Previously only the official Python driver was regression-tested; the other drivers “may connect”, which nobody had checked.

  • The migration guide (docs/python/migrations/neo4j-to-kglite.md) now documents three data-transfer routes rather than one — driver+pandas, export-to-CSV plus LOAD CSV (the route for consumers with no pandas), and pandas-in-between — with the four LOAD CSV behaviour differences a ported import script will meet spelled out.

  • kglite.open(path, storage="mapped", durable=True) now works — write-ahead logging is no longer restricted to in-memory graphs. A mapped graph mutates the same in-memory structure as the default backend and differs only in its file-backed property columns, so it gets the same per-commit crash safety with no change to the log format.

    storage="disk" still raises ValueError, now explaining why and what to use instead: a disk graph commits by publishing an immutable generation, so a logical write-ahead log is not its durability boundary. Use save() checkpoints for disk graphs.

  • Export a graph to SQLite as a dependency-free exit path. graph.export( 'dump.sql') / export_string('sqlite') and kglite export-sqlite <graph> [output] emit a deterministic SQLite-dialect SQL script — node types become tables, connection types become link tables — which sqlite3 out.db < dump.sql turns into a real relational database. No SQLite library is linked into KGLite; emitting a script rather than a .db file adds zero dependencies. Parquet is deliberately out of scope: it would mean taking on the arrow/parquet tree, and to_df().to_parquet(...) already covers it with the dependency in your environment rather than ours.

  • A user-schema version stamp persisted with the graph — your own data-model revision, distinct from the engine-owned .kgl format version and never interpreted by the engine. Read/write it via graph.schema_version / set_schema_version(n), graph_info()['user_schema_version'], or kglite schema-version <graph> [--set N]. Additive: .kgl files written before this field existed load at the unversioned baseline, and a graph that never sets one saves byte-for-byte as before. describe() reports it once set, so an agent opening a graph cold sees which schema generation it holds.

  • kglite migrate <graph> <dir> applies ordered <version>_<name>.cypher migrations and advances that stamp. Re-running is a no-op; statements run against an in-memory copy so a failure part-way leaves the .kgl byte-identical; and a stamp the migration set cannot explain, a duplicate version, or an unversioned filename are all refused rather than guessed at. New guide: Schema Migrations, which documents the recreate-the-node pattern for type changes — including that SET n:NewType adds a secondary label and does not change a node’s primary type.

  • Two documentation guides covering the two ways a kglite graph gets used. Derived index over another system of record documents the rebuild-and-swap pattern — incremental refresh and its delete caveat, carrying embeddings across a rebuild, freshness stamps, managed/runtime ownership layers. KGLite as a primary store: scope and limits states what holds when the graph is the authoritative copy, what the defaults are, and where the edges are: crash-safe open() and the state the log cannot express, constraint enforcement and the paths that bypass it, index and constraint DDL with their naming and equality-vs-range asymmetries, LOAD CSV’s default-deny file capability, forward-only migrations, and the storage modes that keep the whole-graph write checkpoint.

Changed

  • The undo-journal fast path now applies to graphs that have been saved. Statement rollback uses a cheap O(changes) undo journal wherever it can, and falls back to a whole-graph O(V+E) clone taken before every mutating statement otherwise. The fast path previously excluded any graph holding columnar property stores — which is every graph that has been through save(), since saving enables columnar storage and nothing on a mutation path turns it off again. A single save() therefore moved the process onto the clone path permanently, and the cost of each subsequent write scaled with the size of the whole graph rather than with what the write touched.

    The exclusion was aimed at one real side channel — SET on a columnar property writes the shared per-type store directly — which is now covered instead of avoided: the master store is restored from the checkpoint’s schema copy, and the per-node handles come back through the journal. CREATE and DELETE never touch a column store in memory mode at all.

    Rollback behaviour is unchanged; this is a cost change only. The rollback fidelity suite now runs every statement shape against a saved-graph fixture as well as a fresh one.

  • …and to graphs with user-created indexes. The same fast path was excluded for any graph holding a property, range, or composite index, for the same reason and with the same permanence: one create_index moved every later statement onto the whole-graph clone. That exclusion was the more expensive of the two, and unlike columnar mode there was no way to configure around it — dropping the index to buy back write speed turns the lookup it served into a label scan.

    Index maintenance is now journalled per bucket edit, with the position each edit touched, so a failed statement restores bucket order and not merely membership. Bucket order is the row order an indexed MATCH without ORDER BY returns, so anything less would have made a rolled-back statement observable. This uses the same BucketAppended / BucketRemoved entries that already covered the built-in type and label indexes.

    One behaviour change falls out of it: composite-index maintenance no longer opportunistically drops buckets that were already empty before the write. It only drops the ones the write itself emptied.

  • Breaking (Rust API): kglite::api::durable::Wal::open takes a second argument, sync: SyncMode, naming how each appended frame is made durable. Any Rust embedder calling it directly must pass a mode; Python callers are unaffected. Kept as a break rather than adding a second constructor alongside an unchanged open, for two reasons: a compat wrapper for a replaced function is exactly the dual old-vs-new API path this project does not carry, and an open that kept its old signature would have to default the sync mode — silently choosing a durability guarantee on the caller’s behalf, at the one boundary where a silent default costs data. Naming the mode is the point. DurabilityLevel::sync_mode() maps a level to one.

  • A WAL frame is written with a single write call instead of three (one each for the length prefix, the CRC, and the payload). Besides removing two syscalls from the per-commit path, this closes the window in which a process death could leave a frame torn between its prefix and its payload: a write(2) cannot be interrupted partway by a signal. The length/CRC torn-tail check remains the authority, since a short write is still possible in principle.

  • Documented what a crash actually costs a storage="disk" graph, and stopped steering growing apps toward it. Disk mode has no write-ahead log, which read as “no crash safety”; the real guarantee is narrower and is now stated (and kill-9 tested in crates/kglite/tests/disk_crash_guarantee.rs): a crash loses exactly the mutations made since the last save(), and the last published generation always reopens complete, never half-written. The durable-apps mode table previously offered disk for “graphs larger than RAM” with no size threshold and omitted mapped entirely — pointing the one audience that most needs per-commit durability at the one mode that lacks it. It now names mapped as the larger-than-RAM mode that keeps the guarantee and gates disk on Wikidata scale. The storage-mode decision table in core-concepts gained a crash-safety column, and the unqualified “open() is crash-safe by default” claims in the README and guide index now name the disk exception. Also documented that each disk save() retains a full superseded generation, so checkpoint frequency is a disk-budget decision.

  • The expression-nesting limit now names the fix instead of only the problem. The overwhelmingly common way to reach 512 levels is generated code — a filter or facet builder emitting one OR term per selected value — and each term costs a nesting level, so an app that worked with 400 selections breaks at 600 with nothing to go on but “simplify the query”. The error now points at the rewrite that actually works: x = a OR x = b OR ... becomes x IN [a, b, ...], which costs one nesting level however many values the list holds (and is faster, since it can be pushed into the MATCH and use an index). CYPHER.md documents the ceiling and the habit alongside the WHERE clause. Note the planner already folds single-property OR chains into IN automatically, but only once the query has parsed, so it cannot rescue a chain that is already over the limit.

  • Index DDL is classified as a mutation, since schema is graph state: it is blocked on a read-only graph and in a read-only transaction, rolls back with a failed statement, and is rejected on a schema-locked graph when the property is undeclared. write_scope=[...] covers it too, since an index belongs to one node type. SHOW INDEXES is a read: it works on a read-only graph and is unaffected by a write scope, which restricts mutations rather than visibility.

  • Lifted the backend routing behind create_index into the core as DirGraph::create_property_index_routed, so Cypher CREATE INDEX makes the same decision the Python API does: on a storage='disk' graph it builds the persistent mmap-backed index rather than the in-memory HashMap. On disk, a CREATE INDEX that indexes no values on a populated node type is now rejected with the reason (persistent property indexes cover string columns) instead of reporting success for an empty index.

  • KnowledgeGraph.define_schema can now fail: installing a schema installs the UNIQUE constraints it declares, so it raises ConstraintCreationError when existing data already violates one. Nothing is changed in that case, so the data can be fixed and the call retried.

  • Constraint DDL is classified as a mutation for the same reason index DDL is — schema is graph state — so CREATE/DROP CONSTRAINT are blocked on a read-only graph and in a read-only transaction, roll back with a failed statement, respect write_scope=[...], and are rejected on a schema-locked graph when the property is undeclared. SHOW CONSTRAINTS is a read.

  • Mutating Cypher statements no longer deep-copy the graph to stay atomic. A statement-scoped undo journal records the inverse of each write, so the cost of a write now scales with the number of changes instead of with the size of the graph — a single SET takes the same time on a thousand-node graph as on a million-node one. Rollback fidelity is unchanged: a failed statement restores node and relationship identity, properties, labels, index ordering, schema metadata, and the version counter exactly.

    Graphs in columnar mode, graphs with user-created property/range/composite indexes, and the mapped/disk backends keep the previous whole-graph checkpoint, so their write cost is unchanged.

  • The Bolt and MCP servers now give their tokio worker threads an 8 MiB stack (kglite::api::session::QUERY_THREAD_STACK_SIZE) instead of tokio’s 2 MiB default, matching the headroom the CLI and Python wheel already get on the main thread. Bindings that dispatch queries onto their own threads should size them with this constant.

Changed — behaviour

  • Documented: mapped and disk graphs take the whole-graph checkpoint path for statement rollback. No behaviour change — this is long-standing and deliberate, but it was recorded nowhere a user would look. One mutating Cypher statement is atomic; in-memory graphs deliver that with an undo journal costing O(changes), while mapped and disk graphs cannot express an inverse edit against their mmap-columnar indexes or generation overlays and so fall back to an O(V+E) whole-graph checkpoint before every mutating statement. This follows from “in-memory wins”, but it means per-statement write overhead scales with graph size in precisely the two modes chosen for large graphs. Now stated in the storage-mode guide, the KnowledgeGraph constructor docstring, and at the engine decision point.

  • Documented: the KnowledgeGraph(...) constructor is never durable. It takes no durable argument and returns a detached graph with no source_path, so there is nowhere for a write-ahead log to live, whereas kglite.open() defaults to durable="full". The asymmetry is structural rather than a defaulting inconsistency, but it is easy to trip over when comparing KnowledgeGraph(storage="mapped") against kglite.open(path, storage="mapped"); both call sites now say so.

  • define_schema() now merges per node/connection type instead of replacing the whole schema. A type the call names takes the new declaration entire; a type it does not name keeps the declaration it already had. Previously any define_schema call superseded the entire schema, so the natural per-module/per-type declaration pattern silently withdrew every constraint on every type the call happened to omit — a graph that correctly rejected a duplicate primary key one line earlier would accept it on the next, with no error and no warning. Merging is per type, not per field, so re-declaring a type is still how you narrow it.

    Pass replace=True for the previous whole-schema semantics. Because that withdraws enforcement from types the caller never mentioned, it emits a UserWarning listing each constraint it stops enforcing. clear_schema() removes everything.

  • clear_schema() now withdraws the constraints the schema installed. It previously dropped the declaration but left the unique indexes a primary_key/unique declaration had built still rejecting writes — with no SHOW CONSTRAINTS row explaining them and no way to drop them. Constraints declared through Cypher DDL are separate declarations and still survive; DROP CONSTRAINT withdraws those.

  • A required property named id or title is now enforced against an explicit null. Both are auto-supplied when a write omits them, so omitting one still satisfies the requirement — but CREATE (:T {title: null}), SET t.title = null, REMOVE t.title and a null title cell in an add_nodes batch all produce a node that genuinely carries a null, and those are now rejected. Previously they were waved through while SHOW CONSTRAINTS reported the constraint as NODE_PROPERTY_EXISTENCE (or NODE_KEY alongside a uniqueness declaration), and validate_schema() reported nothing. CREATE CONSTRAINT ... IS NOT NULL on id/title likewise now refuses to install against data that already violates it, as it does for any other property. Requiring type remains a no-op — it is the node’s label and cannot be absent.

  • kglite.open() is now crash-safe by default. durable defaults to on, so every committed mutation is fsync’d to the <path>-wal sidecar before the call returns and is replayed on the next open(). Previously a graph opened without durable=True lost every write since the last explicit save() whenever the process died — the docstring said as much, but it was the default.

    Three things to know when upgrading:

    • It costs one fsync per committed mutation. Writes now wait for physical storage, which is dominated by device latency rather than graph size — most visible in loops of many small writes, negligible for a few large ones. Reads are unaffected. Pass durable=False to opt out; it remains fully supported and is the right choice for bulk loading and for graphs rebuildable from source data. Batching mutations into one statement, or one begin() transaction, gives throughput and crash safety.

    • A with block is not a transaction. Mutations commit as they run, so an exception inside the block no longer discards them — they are recovered on the next open(). The failed exit still declines to write a checkpoint. Use begin() for discard-on-error, or durable=False for the old snapshot-only behaviour.

    • storage="disk" is unaffected — it opens non-durable, as before, rather than raising. Only an explicit durable=True raises there. durable is now tri-state (None/True/False) precisely so the new default cannot break disk callers.

    The MCP server, CLI, and Bolt server open graphs through the shared engine-level helper rather than this function, so none of them changes behaviour.

Fixed

  • A SET on a saved graph no longer costs one node copy per node of the type. Writing a single property to a type with N nodes journalled N NodeData pre-images rather than one, making a one-row SET on a saved 100k-node graph measurably slower (~1.8×) than the whole-graph clone the undo journal replaced. The cause is the end-of-statement sweep that re-points every node’s shared column-store handle after a columnar write forks the master: that sweep is bookkeeping rather than a logical mutation, but it ran through the recorded write path and so captured an undo pre-image for each node it touched. The sweep’s inverse is now journalled once per node type — the pre-statement master handle, no store copy — so the cost of a SET again scales with the number of rows it changes. CREATE and indexed-write throughput are unaffected, and rollback fidelity is unchanged: a failed statement still restores the master store, every node’s handle, and any unique claims the write moved.

  • REMOVE on a columnar node no longer leaves the property in the graph’s master column store, where a later write brought it back. Each node of a columnar type holds its own Arc<ColumnStore> handle and the graph holds the master. REMOVE wrote through the node’s handle, and Arc::make_mut forks it — so the node stopped reporting the property while the master kept it. The next SET on that type re-pointed every node’s handle at the master and the removed property reappeared, with no save() involved. REMOVE now clears through the master, the same chokepoint SET already used. This also removes a full ColumnStore clone per node removed, so REMOVE over R rows of a type with N nodes is no longer O(R × N).

  • Deleting a node no longer costs a full rebuild of its type’s id index. DELETE dropped the whole id_indices entry for every affected node type, so the next MATCH (n {id: …}) rebuilt the map by scanning every node of that type — one node-weight read and Value clone each. A single-node delete was therefore O(nodes of that type), while the create path had already been maintaining the same index incrementally. Deletes now evict just the removed ids in place. Types with duplicate ids still fall back to the full rebuild, since only a rebuild can surface a duplicate that the index had shadowed; that case is detected in O(1) by comparing the index length against the type’s live node count. Statement rollback is unchanged — it invalidates whole types by design, on the already-failed path.

  • kglite.open(path, storage=...) no longer ignores the mode when the path already exists. storage= selects a backend for a graph being created; an existing path is loaded, and the load decides the backend. Because a .kgl checkpoint records no storage mode, a reopened one always comes back as memory — so open(path, storage="mapped") produced a genuinely mapped graph on the call that created the file and a silently memory-backed one on every call after that, with nothing reported. An unknown mode such as storage="banana" was not even validated on that branch. Both now raise kglite.ArgumentError, naming the mode requested, the mode actually produced, and the way forward. This is a deliberate break: the previous behaviour was indistinguishable from success and had already invalidated a mapped-vs-memory comparison in which both arms unknowingly ran on memory. Callers who want whatever the file provides should omit storage=. Note there is no saved-graph-to-mapped conversion — a mapped graph has to be built with KnowledgeGraph(storage="mapped") and populated from source.

  • add_nodes() on a durable mapped or disk graph no longer writes a quadratic write-ahead-log payload. Appending rows to a columnar graph detaches every existing node of the type from its shared column store and re-attaches it afterwards — pure internal bookkeeping, but it ran through the recorded mutation seam, so each sweep logged one full property-map copy per pre-existing node. Because the append is chunked, an n-row call re-logged the whole type once per chunk: WAL bytes grew as , and a large enough single call could exceed the 4 GiB per-frame ceiling. Both sweeps now use the silent borrow that the columnar SET handle-refresh already used, so the log records exactly one op per row written. Measured on the regression fixture: a 1,000-row append logged 2,000 ops (84 B/row) and a 4,000-row append 20,000 ops (213 B/row); both now log one op per row at a flat byte cost. No behavioural change to the graph itself or to log replay — the ops removed were byte-identical restatements of nodes the sweep had not modified.

  • lock_schema() now catches a typo’d node label in a query, not just in a write. MATCH (i:Isue) RETURN i used to return [] with no error even on a locked schema, while the equivalent property typo — MATCH (i:Issue {titel: 1}) — was rejected with a message naming the mistake and listing the valid properties. That asymmetry was the bug: an empty result set reads as “no matching data” rather than “you made a mistake”, so a typo’d label survives code review and reaches production looking like a legitimate empty state, whereas a wrong property value is at least visibly wrong at the point of use. A locked schema now rejects an unknown label with the same shape of message the unknown-property path has always produced, and the same wording the CREATE write path has always used:

    Schema error: Unknown node type 'Isue'. Did you mean 'Issue'?
      Valid types: Issue, Person
    

    It is raised as kglite.SchemaError (.code == "Schema") from every clause that can carry a label — MATCH, OPTIONAL MATCH, MERGE, multi-label patterns like (n:A:B), WHERE EXISTS { } pattern predicates, CALL { } subqueries, and UNION branches.

    The schemaless default is unchanged. kglite is open-schema by design, so on an unlocked graph an unknown label still matches nothing without error — the zero-row existence-check idiom stays valid, and the existing non-fatal warning: on stderr is still how the typo is surfaced there. Labels applied via add_label count as known, and creating a genuinely new label on an unlocked graph is unaffected. Relationship types are deliberately still not rejected: only the node-label gap was asymmetric with properties.

  • save() now barriers the write-ahead log before writing its checkpoint. A checkpoint truncates the log, and recovery folds the surviving frames into net per-entity state. Previously the log was guaranteed to be on disk at that point only because every commit had already barriered; with a level that skips the per-commit barrier, a crash in the window between writing the checkpoint and truncating the log could leave a prefix of the frames, and replaying that prefix over the newer checkpoint would roll committed properties backwards — losing data that had already been durably saved. The checkpoint path now flushes the log first, restoring the invariant that the on-disk log is complete whenever a checkpoint supersedes it.

  • WAL recovery now rejects a zero-length frame explicitly. A run of zero bytes — the shape an OS crash leaves when a file’s length was extended but its data block never reached the platter — declares a zero-length payload, and crc32 of an empty payload is zero, so such a prefix passed the integrity check as a “valid” empty frame and was stopped only by the decoder failing further down. Recovery now stops at it by intent. No correct WAL can contain one.

  • kglite.open() now enforces one writer per graph, instead of silently losing one. Two processes that opened the same path both built a complete snapshot in memory and both wrote it at save(), so whichever saved last won and everything the other had done disappeared — with both processes exiting 0, nothing logged, and nothing in the file to show it had happened. That is the likeliest accident when deploying an embedded database: a multi-worker gunicorn pool, a cron job overlapping a request, or a stale process nobody noticed.

    open() now takes an exclusive cross-process writer lease on a <path>.lock sidecar and holds it until close() / with-block exit. A second writer fails immediately, naming the process that has it:

    KgError: app.kgl is open for writing by pid 4711 (since 2026-07-26T09:15:03+02:00)
    

    The lease mechanism itself is not new — the CLI and the MCP server have held it since disk generations shipped, and open_or_create_graph documents it as a caller’s responsibility. The Python binding was the caller that never opted in, which left the most-used surface unguarded.

    Three deliberate boundaries:

    • Readers are never blocked. load() and open_session() take no lease. save() republishes the whole graph, so a reader sees the last consistent snapshot; making reads exclusive would break read-replica and analytics deployments to fix a problem only writers have.

    • A crash releases it. The lock is owned by the OS, not by the sidecar file’s existence, so a writer lost to SIGKILL or a power cut frees it at once. The leftover <path>.lock (the lock, always empty) and <path>.lock-owner (the pid/timestamp used to name a holder) are records, not the lock — deleting them releases nothing, and the error message says so, since deleting lock files by reflex is how this class of guard gets defeated.

    Contention is classified from the platform’s lock errno via fs2::lock_contended_error() rather than from io::ErrorKind. The kinds differ per platform — EWOULDBLOCK maps to WouldBlock on Unix, but ERROR_LOCK_VIOLATION is uncategorised on Windows — so a kind comparison recognised contention only on Unix. On Windows that meant a blocked writer saw the raw OS error instead of a message naming the holder, and the retry loop was skipped entirely, so the 30-second lease timeouts used by kglite CLI commands and the MCP server returned instantly rather than waiting for the current writer to finish.

    The holder’s identity lives in the unlocked <path>.lock-owner sidecar rather than inside the lock file, because fs2 locks via flock on Unix (advisory — contenders can still read) but LockFileEx on Windows (mandatory over the whole range), where an exclusive lock makes the file unreadable to every other handle and a contender’s read fails with ERROR_LOCK_VIOLATION instead of returning the pid. Splitting the two keeps the holder named on every platform. <path>.lock is still the file that is locked, so binaries from either side of this change continue to exclude each other.

    • open(..., lock=False) opts out, explicitly and never by default, for deployments that coordinate writers externally.

    Disk-mode graphs were already protected by their own generation lock, but only failed at the first mutation with a bare Resource temporarily unavailable (os error 35); they now fail at open() with the same named message as every other storage mode.

    Possible impact: a deployment that genuinely opened one graph from two processes was already losing writes, and now gets an error instead. Two overlapping kglite.open() calls on one path within a single process are refused for the same reason (the error says so explicitly); sequential with kglite.open(path) blocks are unaffected, because the lease is released on block exit.

  • A constraint violation is now catchable by type from every write path. ConstraintViolationError existed but was reachable from nowhere: a violation raised through Cypher surfaced as CypherExecutionError, and one raised through the bulk loaders (add_nodes, and everything funnelling through it) as ArgumentError. The only handler an application could write was therefore a substring match on the message — in a signup path, for the single most common error a web application has. Both paths now raise ConstraintViolationError, so except kglite.ConstraintViolationError works and except kglite.ConstraintError still catches that or ConstraintCreationError. The messages are unchanged: they still name the constraint, the property, the offending value, and the remedy.

    The structured violation is carried out of the engine’s Result<_, String> write channel on the graph itself and drained by the adapter that builds the typed error, paired with the exact message it produced — if an intermediate frame rewrites the message the pair is discarded and the untyped error is used, so a mismatch fails safe rather than mis-attributing a violation.

  • The exception-hierarchy diagram in docs/python/error-handling.md omitted the entire constraint family (ConstraintError, ConstraintViolationError, ConstraintCreationError). It now lists them alongside TransactionConflictError, with new sections covering stable codes, constraint violations, and commit conflicts.

  • docs/python/guides/primary-store.md stated that independent transactions proceed concurrently, and that a Cypher constraint violation must be caught by matching the message. Neither was true. OCC compares a whole-graph version counter, not read/write sets, because a commit publishes the transaction’s working copy by pointer swap — so two transactions touching unrelated nodes do conflict, and the loser’s snapshot genuinely does not contain the winner’s write, which is why rejecting it is correct rather than over-cautious. The guide now says so plainly, points at retry_on_conflict, and suggests session() for workloads with many short concurrent writers.

  • The Bolt neo4j_status_code coverage test enumerated its codes by hand and had silently stopped covering Cancelled, ConstraintViolation, and ConstraintCreationFailed; all are now included.

  • ORDER BY is no longer silently ignored after an aggregating RETURN when the sort key is not one of the projected columns. MATCH (t:Task) OPTIONAL MATCH (t)-[:X]->(c) RETURN t.title AS title, count(c) AS n ORDER BY t.priority DESC — the shape behind every “list with a count, most important first” view — returned every correct row in insertion order, with the clause dropped and no error. Combined with SKIP/LIMIT it made pagination incoherent: because the underlying order was unspecified, pages could repeat some rows and skip others.

    Aggregation rebuilds its output rows, so a variable survives onto them only if the executor carries its binding forward. The three aggregation operators (streaming, materialized, and the fused OPTIONAL MATCH + count()) each carried a different subset, so whether the clause worked depended on which one the planner happened to pick — count() worked, collect() did not, and adding an OPTIONAL MATCH broke a query that was correct without it. Where the binding was missing the sort key evaluated to NULL on every row, all keys tied, and the stable sort returned the input order. All three operators now carry bindings for every variable the grouping keys read, so the same query orders identically whichever one runs.

    Ordering by a variable that is not determined by the grouping is now a query error instead of a silent non-sort. In RETURN t.title, count(c) ORDER BY c.label, c collapses into the aggregate and has no single value per group; Neo4j rejects this shape and kglite now does too, with a message naming the fix. The same applies to an aggregate in ORDER BY that is not projected (ORDER BY max(t.priority)) or that is projected under an alias (count(*) AS n ORDER BY count(*) — order by n). Ordering by a projected alias, by an unaliased aggregate’s expression form (ORDER BY count(p)), and by any property of a grouping variable all keep working.

  • A Cypher write clause fed zero rows no longer writes anything. MATCH (p:Project {key: 'NOPE'}) CREATE (t:Task ...) used to return a row and create the node even though the MATCH found nothing; it now returns no rows and creates nothing, matching Neo4j. The same fix covers MERGE and FOREACH after an empty match, and UNWIND [] AS x CREATE ....

    The two-variable form was the damaging one: MATCH (t:Task {...}), (u:User {...}) CREATE (t)-[:ASSIGNED_TO]->(u) where u matched nothing used to fabricate u as a real node and attach the relationship to it, leaving an edge pointing at a node with no label and no properties — invisible to any label scan but reachable by traversal. With no referential-integrity constraints in the engine, “MATCH the parent, then CREATE the child” is the only way an application can enforce a foreign key, and this silently defeated it while returning a plausible-looking id.

    Cause: CREATE, MERGE, and FOREACH each decided whether to supply Cypher’s implicit single start row by testing whether the incoming row set was empty — which is equally true at the start of a query and after a clause that matched nothing. That decision now lives in the clause pipeline, which is the only place that can tell the two apart. SET, DELETE, and REMOVE were never affected. Behaviour that deliberately does not change: a leading CREATE/MERGE/FOREACH with no preceding clause still runs exactly once, and OPTIONAL MATCH still yields one null-padded row that a following CREATE acts on.

  • define_schema() no longer withdraws a NOT NULL declared through CREATE CONSTRAINT ... IS NOT NULL. Presence constraints live in the same required_fields list a schema owns, so installing any schema silently un-enforced them — while the uniqueness half of a DDL declaration, whose index lives outside the schema, survived. A DDL constraint is now withdrawn only by DROP CONSTRAINT, in both modes.

  • SHOW CONSTRAINTS (and CALL db.constraints()) now report a stable name for a constraint carrying more than one registered name — the case where CREATE CONSTRAINT u IS UNIQUE and CREATE CONSTRAINT nn IS NOT NULL on the same property merge into one NODE_KEY row. The reported name was taken from the first hash-map match, so the same graph could name the row differently before and after a save/load round-trip.

  • A property named id, title or type no longer duplicates its column in the table exports. Those three columns come from a node’s canonical identity, but a node can also store a property under one of those names — Cypher CREATE (:T {title: 'a'}) sets title both ways, so this hit almost every Cypher-built graph. The affected surfaces were select(...).to_df(), collect(), sample(), and export_csv(); the SQL-dump, d3/JSON and to_text() exporters already dropped the colliding key and are unchanged.

    The duplicate was not cosmetic. The column map backing a DataFrame is keyed by name, so the second write overwrote the canonical value — a graph built with add_nodes(df, 'T', 'id', 'name') plus a separate title column silently lost the canonical title. DataFrame.to_parquet() rejects a non-unique header outright, so the documented to_df().to_parquet(...) recipe failed with ValueError: Duplicate column names found; pandas.read_csv and DuckDB silently renamed the second column to title.1/title_1, inventing a phantom column in what export_csv documents as the portable backup format.

    A property colliding with an emitted canonical column is now dropped and the canonical value wins. A canonical column the caller opted out of is not emitted and so cannot collide — to_df(include_type=False) still returns a stored type property as itself.

  • Disk storage mode now works on Windows. The backend memory-maps its CSR and index files, and Windows — unlike POSIX — refuses to resize, replace, or delete a file that still has a mapped view open. Several places rewrote a file while the graph was still mapping it, so save(), compact(), and building a disk graph from N-Triples all failed there with “The requested operation cannot be performed on a file with a user-mapped section open”. The mappings are now released before their backing files are rewritten, and MmapOrVec releases its view before resizing on Windows while keeping the POSIX ordering that makes a failed remap leave the buffer intact.

    Publishing a saved generation failed for an unrelated reason with the same symptom: the durability fsync over the staging directory opened each file read-only, and Windows requires write access to flush a file’s buffers, so the save aborted with “Access is denied” before the atomic rename it was protecting. Staged files are now fsynced through a writable handle.

    Saving also left the writer’s arrays mapping its scratch workspace instead of the generation just published. Windows will not delete a directory that still holds a mapped file, so those scratch directories accumulated inside the graph directory; the writer now re-maps onto the published snapshot, which also means it observes exactly what a fresh reader would on every platform.

  • Repairing a torn write-ahead log header now works on Windows. A .kgl-wal left truncated by a crash is repaired on open, but the repair truncated and rewrote the file through the log’s append handle. An append handle is not a general-purpose write handle — on Windows it is opened without the access right that truncation requires — so recovering from a torn header failed there. Header creation, repair, and version upgrade now happen on an ordinary handle before the append handle is opened.

    This was never caught because no CI job ran the engine’s test suite on Windows, even though Windows wheels are published. The native-lifecycle job now runs it.

  • Holding a deferred query result in a variable no longer makes the next write copy the whole graph. A deferred (streaming) result keeps a reference to the graph so it can serve rows later, and that reference made the next write through the same KnowledgeGraph deep-clone every node, edge, index and embedding — a cost that grows with graph size rather than with the work done, so it is invisible on a small test fixture and severe on a large one.

    The reach is narrower than it first looks, and worth stating precisely: only results that are deferred hold the graph, and deferral requires a query of just MATCH/OPTIONAL MATCH/RETURN/SKIP/LIMIT returning bare property accesses. Anything with WHERE, ORDER BY, DISTINCT, WITH, UNWIND, an aggregate, a whole-node RETURN n, or a computed value was already eager and never pinned anything. So the shape that paid was a read-modify-write handler whose read is an inline-filtered point lookup or unfiltered projection — rows = graph.cypher("MATCH (u:User {id: 1}) RETURN u.name, u.balance") followed by a write with rows still in scope. That query is common, but the WHERE-spelled equivalent of it never had the problem.

    Very small results — a budget of rows × columns, so a single-entity lookup or a handful of rows — are now converted up front and hold no graph reference, so that shape costs nothing extra. The budget is deliberately small: every deferred-eligible query pays the conversion, while only a result held across a write benefits, so the cost to readers has to stay inside noise before the saving counts. Larger results still defer and still hold the graph; ResultView’s documentation explains when that matters and how to avoid it. Behaviour is unchanged: a held result has always shown the data as of query time, and still does.

    Consumers gain a little either way — materialising a result in one batched pass is 12–15% faster than resolving it row by row, at every size measured.

  • KnowledgeGraph.begin() documented that embeddings are excluded from the transaction snapshot’s copy. They are not — embeddings, indexes and timeseries are part of the graph and are copied with it, so the note understated the cost of opening a transaction on an embedding-heavy graph. Corrected.

  • claude_config now reads and writes Claude MCP config files as UTF-8. On a non-UTF-8 Windows codepage the read mis-decoded the file and the atomic write committed the damage over every unrelated MCP server entry; non-ASCII text in other entries is now preserved byte-for-byte.

  • print() of a Cypher result no longer raises UnicodeEncodeError when stdout cannot encode box-drawing characters (redirected output, CI logs, captured subprocesses). Such output falls back to an ASCII table; set KGLITE_ASCII_TABLE=1/0 to force either style.

  • repr() of a result containing a string longer than 30 characters no longer panics when the truncation point falls inside a multi-byte character, and non-ASCII cells no longer skew column alignment.

  • LOAD CSV FROM 'file:///C:/data.csv' now resolves on Windows instead of producing the unreachable /C:/data.csv.

  • Declaring a UNIQUE or NODE KEY constraint no longer reintroduces a graph-sized copy on every mutating statement. The unique-occupancy map holds one entry per node of the constrained type, and it was being deep-cloned into each statement’s rollback checkpoint, so a constrained type gave back the saving the undo journal delivers. It is now parked like the other graph-sized structures and recomputed only for the node types a failed statement touched, moving the cost to the rare rollback path. Rollback fidelity is unchanged in both directions: a claim the failed statement added is released again (so the value stays insertable rather than being rejected forever by a phantom occupant), and a claim it released is restored (so a real duplicate is still refused).

  • Cypher CREATE no longer discards a node type’s cached id index on every insert. Incremental id-index maintenance was gated on the type having a declared primary key, so an undeclared type invalidated the whole index per created node and the next MATCH (n {id: ...}) or MERGE paid an O(n) rebuild. The gate protected nothing about uniqueness — a rebuild and an incremental insert collapse a duplicate id identically; it existed only because id had already been moved into the insert and no clone was available. Maintenance is now driven purely by whether the index is already cached (and therefore complete), replacing the per-CREATE O(n) rebuild with one id clone.

  • Deleting nodes now evicts them from the B-tree range index. detach_delete_nodes cleaned the type, id, property, composite, and secondary-label indexes but skipped range_indices, so WHERE n.prop > x on an indexed property kept returning tombstoned nodes as candidates.

  • Bulk loading no longer silently hides rows from an indexed MATCH. add_nodes (and the blueprint builder and edge stub vivification, which funnel into it) appends through the batch path, which skips the per-write index maintenance the Cypher executor runs — but the matcher trusts a property index unconditionally rather than falling back to a scan. So after create_index('Person', 'city'), a subsequent add_nodes left MATCH (n:Person {city: 'Oslo'}) returning only the pre-load nodes. The bulk path now rebuilds the equality, range, and composite indexes covering the loaded type once per call (no-op when the type carries none), the same order of work as the id index rebuild it already did.

  • Overwriting an indexed property through a fluent write no longer returns the node under its old value. add_properties(...) writes the property map directly and the store_as= paths (unique_values, collect_children, calculate) write through the batch path, so neither refreshed the secondary indexes — and the matcher trusts a property index unconditionally rather than falling back to a scan. After create_index('Child', 'tag'), an add_properties that changed tag left MATCH (c:Child {tag: <old value>}) returning the node whose tag was already the new value: a wrong answer rather than a missing row. Both paths now refresh the indexes covering every written node type, the same remedy as the bulk-append fix above. The two add_properties write loops (copy and aggregate) were duplicated tails, which is how one of them lost the maintenance; they now share one helper.

  • add_properties bumps the graph version, so version-keyed caches and freshness checks observe the write.

  • Declaring UNIQUE constraints no longer shifts the .kgl format for graphs that have none. The persisted unique_constraint_keys list emitted "unique_constraint_keys":[] into every file, changing the bytes of the overwhelming majority of graphs — which carry no constraints — and tripping the format-drift gate. It is now skipped when empty, so a constraint-free graph writes byte-identical output to one produced before constraints existed.

  • The persisted constraint list is sorted at save time. It was snapshotted straight from HashMap::keys(), so any graph carrying a constraint saved in nondeterministic byte order.

  • OCC commit conflicts over Bolt now report Neo.ClientError.Transaction.ConflictDetected, the code the Bolt server’s README and the migration guide have always documented. They previously reported Neo.ClientError.Transaction.TransactionStartFailed — wrong twice over, since the transaction started fine — so a ported client branching on the status code (the normal way to write a retry loop) was misled. Writing the Java/JS driver suites is what surfaced it: the Python tests matched on message text and could not see the code.

  • labels(n) now returns a node’s secondary labels in a stable order (sorted by name, primary label first). They were previously returned in hash-map iteration order, so two graphs holding identical data could report a node’s labels in different orders, and the order could change between processes. Any caller that compared, displayed, or serialized labels(n) could see irreproducible results.

  • Mutations other than cypher() are now crash-safe on a durable=True graph. Only Cypher statements were written to the write-ahead log. Every other way of changing a graph — add_nodes, add_connections, replace_connections, add_nodes_bulk, add_connections_bulk, add_connections_from_source, extend, add_label, remove_label, create_connections, add_properties, purge_provisional, and a committed transaction — applied the change and left it out of the log, so a crash lost it with no error. A committed transaction is logged as a single entry, and a rolled-back one still leaves nothing behind.

  • load_ntriples() no longer raises PanicException on a graph opened with durable=True. The RDF loader’s type-resolution pass treated a durable graph as impossible and aborted.

  • A Session now refuses write queries against a durable=True graph instead of applying them somewhere they can never be persisted. Session writes land on a working copy visible only through that session — never on the graph that owns the log or the save path — so they were unreachable by both. Reads are unaffected; run mutations with g.cypher(...) or with g.begin() as tx.

  • A vacuum() no longer switches off write-ahead logging. Compacting a graph rebuilt it into a plain in-memory backend, which discarded whatever the triggering statement had written and silently ended crash-safety for the rest of the session — every later mutation went unlogged, with no error. A durable graph with auto-vacuum enabled could therefore lose all work since its last save(). Vacuuming now keeps the graph durable, and the log is flushed before the rebuild so no write is left describing a stale position.

    The same rebuild also silently downgraded a storage="mapped" graph to plain in-memory storage, so a graph stopped using its file-backed columns after the first vacuum. Mapped graphs now stay mapped. vacuum() on a storage="disk" graph is now a documented no-op rather than converting it to an in-memory graph — disk reclaims space by writing a new generation, and rebuilding meant loading the whole graph into RAM.

  • Secondary labels are no longer lost on crash recovery in durable=True mode. A node’s :Labels live in an index above the storage layer, so the write-ahead log never captured them: after a crash, a recovered node kept every property but came back with only its primary label, and MATCH (n:Label) no longer found it. Labels are now logged as their own entry and restored in labels(n) order, including removals (REMOVE n:Label). Data written by an earlier version replays unchanged — the log’s older format is read exactly, not rejected. A log written by this version is refused by older builds with a clear message instead of being silently truncated.

  • graph.export(...) no longer writes an empty file when the current selection exists but matched nothing. It decided whether to use the selection from “does a selection level exist” rather than “does the selection hold nodes”, so a fluent call that matched nothing made export('out.graphml') silently emit an empty graph while export_string('graphml') was correct. All three export entry points now share one selection-resolution helper.

  • The Cypher script splitter behind the REPL’s .read (and now migrate) is quote-aware: a ; inside a string literal is data, so CREATE (:Note {body: 'a;b'}) is no longer torn into two invalid fragments.

  • n:A:B label chains, subscript chains, and long arithmetic/boolean operator chains past the nesting budget now report the documented “nesting exceeds 512 levels” syntax error instead of aborting the process (all frontends, including in-process Python).

Security

  • Fixed a denial of service in the Bolt and MCP servers: a single deeply nested query from any client could overflow a server worker thread’s stack, and because a Rust stack overflow aborts rather than unwinds, the whole server process died — disconnecting every other connected session. The query is now rejected with a Neo.ClientError.Statement.SyntaxError (“Expression nesting exceeds 512 levels; simplify the query”) and the server keeps serving.

    The parser’s 512-level nesting budget previously counted only recursively parsed nesting (parentheses, lists, NOT, unary minus). The left-associative operator chains — OR / XOR / AND, + / - / ||, * / / / %, subscripting, and n:A:B label chains — are parsed iteratively, so the parser stayed shallow while the tree it returned grew one level per term. The planner’s expression walkers, the executor’s predicate evaluator, and the AST’s drop glue all recurse per level, so an unbounded chain walked off the end of the stack. Those chains now charge the same budget, making it a bound on AST depth rather than on parser call depth.

[0.14.5] - 2026-07-22

Changed

  • Replaced the code-tree-specific MCP embedding hooks with a generic WorkspaceGraphHooks lifecycle. One request/result path now covers ordinary and revision-set builds, while downstream producers own file relevance and ingestion policy through ServerExtensions::with_workspace_graph.

Fixed

  • Made concurrent MCP workspace activations request-coherent: graph preparation now publishes only while its activation generation is current, and watcher identity comes from the graph committed by that same transaction.

  • Preserved revision-set scope across lazy MCP workspace rebuilds and discarded rebuild results prepared for a superseded active-graph generation.

  • Made .kgl topology serialization deterministic for relationships with multiple properties, independent of their internal insertion order.

  • Deeply nested Cypher expressions (hundreds of levels, within the documented 512-level budget) no longer overflow the stack in debug builds: the parser grows the stack on demand (via stacker), so the nesting budget — not the thread stack — is the effective limit in every build profile.

  • Closed disk-mode arena-guard gaps outside the Cypher read executor: Cypher mutations, bulk fluent mutations, graph algorithms, search(), to_networkx(), and other direct read paths now hold the disk materialization arena guard while borrowing node/edge weights (previously only enforced by debug assertions that release builds skipped).

[0.14.4] - 2026-07-20

Fixed

  • .kgl, .kgle, and disk-snapshot bytes are now deterministic across equivalent graph builds. Embedding stores (node_to_slot, text_hashes, and the store container) and timeseries payloads (channel maps and the store container) serialize with key-sorted entries, so rebuilding the same graph in a new process produces byte-identical files. Wire-compatible in both directions — no format bump, existing files load unchanged. (Reported by sonagram’s real-library byte-determinism gate.)

[0.14.3] - 2026-07-19

Removed

  • BREAKING: all bincode persistence support and the bincode dependency have been removed. Current .kgl, .kgle, WAL, disk-snapshot, property-log, column, edge-property, and index payloads use Postcard only. Pre-0.14 archival formats now fail with guidance to open and re-export them with KGLite 0.13.4; rebuildable pre-0.14 caches are skipped and regenerated.

  • BREAKING: removed the embed_texts(replace=...) Python alias. Use mode='all' to rebuild every vector, or the default mode='missing' for an incremental fill.

  • BREAKING (Rust): removed the unused ProgressValue::F64 and Str variants and the duplicate DirGraph::build_id_index_from_columns alias.

Changed

  • Updated every declared Cargo and Python dependency to its latest usable release. The MCP server now builds on mcp-methods 0.4 and rmcp 2.2.

Fixed

  • Persistence upgrade guidance now matches the Postcard-only reader. The README and ReadTheDocs migration paths cover .kgl, .kgle, disk graphs, WAL/transient logs, and rebuildable caches through the 0.13.4 bridge, and no longer promise that pre-0.14 bincode artifacts load directly.

[0.14.2] - 2026-07-18

Added

  • Atomic MCP domain graph context. Embedded domain tools can use DomainGraphState::with_context to borrow the active graph, persistence target, and source root from one coherent activation snapshot.

  • Inline records: from_records(..., on_missing_endpoint=...) can now "drop" edges with absent endpoints or reject the complete build atomically with "error"; the existing "vivify" behavior remains the default.

  • Write provenance: git_sha and modified_by now flow through sessions, transactions, DataFrame node/edge writes, replacements, and connector bulk helpers for schema types that opt into auto_timestamp.

  • Release verification: published main and standalone CLI wheels are now checked for exact MIT metadata and an unmodified embedded LICENSE.

Fixed

  • Documentation and onboarding accuracy. README/ReadTheDocs now lead with install, first-query, and high-level navigation paths; persistence, transaction, timeout, multi-label, MCP/Bolt, Rust, and C-ABI guidance has been reconciled with the shipped interfaces. Agent-facing Cypher introspection no longer lists supported FOREACH/CALL {} features as limitations or suggests that SET n.type retypes a node.

  • MCP manifest bundled-tool overrides now apply to the completed router. hidden: true removes a tool from discovery and rejects direct calls even when the route is registered by KGLite or a downstream extension; description and rename overrides are validated and applied at the same late boot stage.

  • Nested map properties are preserved by columnar overflow storage and borrowed subgraph streaming instead of being written as NULL or omitted.

  • Disk generation saves rebuild peer-count histograms from newly added edges, keeping grouped relationship counts complete after save and reload.

[0.14.1] - 2026-07-18

Added

  • Rust API: kglite::api::algorithms::leiden_communities is now exported alongside Louvain. The existing deterministic Leiden implementation and CommunityOptions/CommunityResult types are unchanged.

  • MCP server library: downstream binaries can use run_with_extensions and ServerExtensions::with_domain_tools to register typed or raw domain tools against the live, read-oriented DomainGraphState. Registration runs before skill finalisation, and DomainToolRegistry rejects collisions with KGLite or manifest-owned tools.

Fixed

  • Corrected the MCP guide’s stale claim that manifest tools[].python is supported; executable domain logic belongs in a composed Rust binary.

[0.14.0] - 2026-07-16

Migration guide for both removals: docs/python/migrations/0.13-to-0.14.md — pin-back escape (pip install "kglite<0.14"), per-surface table. Accessing kglite.code_tree / kglite.datasets / build_code_tree / repo_tree now raises a guided error naming the fix (tombstones, removed in 0.15).

Removed

  • BREAKING: the in-tree code-graph builder moved to the standalone codingest project. kglite.code_tree, kglite.build_code_tree, and kglite.repo_tree are gone from the wheel; kglite code-tree is gone from the CLI; kglite::api::code_tree is gone from the Rust API; and the bundled MCP server no longer builds code graphs itself (workspace modes report “code-graph building is not available” unless an embedding binary injects CodeTreeHooks — codingest-mcp does exactly that). Everything read-side survives unchanged and works on codingest-built graphs: graph.source()/find()/context(), read_code_source/explore MCP tools, and the rev_diff/affected_tests/dead_code procedures. The wheel drops all 15 bundled tree-sitter grammars. Migrate builds to codingest (same builder, verified graph-equivalent by its golden parity suite).

  • BREAKING: the pre-packaged domain dataset loaders moved to the standalone kglite-datasets project. kglite.datasets (the sec / sodir / wikidata Python wrappers) is gone from the wheel; the kglite::api::datasets Rust facade and the core sec / sodir / wikidata Cargo features are gone; and the 13 kglite_datasets_* C-ABI functions (plus their sec / sodir / wikidata features) are gone from kglite-c. With the loaders out, the default engine build links zero network code — the datasets’ ureq / rustls HTTP stack drops out, and zip + quick-xml leave the workspace dependency tree entirely (the engine crate’s normal dep count drops from 309 to 171; ureq/rustls remain only under the opt-in fastembed model transport and the separately-bundled MCP server). The RDF loader (load_rdf / load_ntriples), the OKF loader (kglite.okf), graphgen, and blueprints all stay — they are engine formats/features, not domain loaders, and the Wikidata-scale mmap/disk storage that serves billion-edge graphs is untouched. Migrate to the kglite-datasets project (same loaders, verified byte-identical to the former in-tree copy by a golden parity suite); kglite continues to serve and query the .kgl graphs they produce.

Added

  • kglite-mcp-server accepts an external code-tree builder. run_with_code_tree_hooks(args, Option<CodeTreeHooks>) mirrors the existing run_with_embedder_factory pattern: an embedding binary can inject {build, build_revs, is_code_file} closures for the workspace build and watch paths, while every other tool remains builder-agnostic. None (and the plain run) keep today’s in-tree builder — no behavior change for existing callers.

Changed

  • BREAKING (Rust API): kglite::api::algorithms now takes per-family options structs instead of long positional parameter lists. pagerank, betweenness_centrality, closeness_centrality, degree_centrality, louvain_communities, label_propagation, shortest_path, all_paths, shortest_path_weighted, shortest_path_cost_weighted, and vector_search drop their trailing tunable parameters (damping/tolerance/normalized/ sample_size/resolution/connection_types/scope/via_types/interrupt/metric/…) in favour of a single &…Options argument — PagerankOptions, CentralityOptions (betweenness + closeness), DegreeCentralityOptions, CommunityOptions (louvain + leiden), LabelPropagationOptions, PathOptions (all four shortest-path finders), AllPathsOptions, and VectorSearchOptions. Only the graph handle and genuinely primary inputs (path endpoints, weight/embedding property, query vector, selection) stay positional. Each struct is #[non_exhaustive] with an impl Default (defaults match the prior common-call values, e.g. pagerank damping 0.85) and with_* builders, so future tuning knobs can be added without breaking callers: pagerank(&g, &PagerankOptions::default().with_damping_factor(0.9)). The Python (graph.pagerank(...), graph.vector_search(...), …) and Cypher (CALL pagerank({...})) surfaces are unchanged — same kwargs, same defaults, same results; only direct Rust callers of kglite::api::algorithms need to migrate.

  • Rust API: code-entity read helpers moved to kglite::api::code_entities. resolve_code_entity, find_code_entities, source_location, code_entity_context, SourceLookup/SourceLocation, and the CodeEntity* types now live in their own facade module — they operate on any code-schema graph regardless of builder and are no longer coupled to the parser surface in kglite::api::code_tree (which keeps the build-side: build_code_tree, build_code_tree_revs, language_for_path, …). Rust consumers update their use paths; the Python API is unchanged.

Fixed

  • Deterministic code-tree edge counts on minified assets. Repos whose minified CSS/HTML repeats a selector or element name on one line (duplicate entity ids) produced run-to-run-varying total edge counts: duplicate (file, entity) DEFINES rows became parallel edges when their type-pair won the per-process-random iteration order into the initial-load fast path. DEFINES frames are now consolidated and iterated in a stable order; builds of the same tree are byte-for-byte repeatable across processes.

[0.13.4] - 2026-07-15

Added

  • Code review has a zero-configuration CLI and Agent Skill path. kglite code-tree build creates current-tree, single-revision, or merged multi-revision code graphs without Python; a metadata sidecar lets kglite code-tree status detect stale artifacts. kglite skill install installs the bundled kglite-code-review skill offline for Codex and Claude Code at user or project scope, while the existing MCP workspaces remain the persistent watch/cache/tool-schema path.

  • pip install kglite now includes the kglite CLI. The Python wheel exposes the same Rust CLI library as the standalone kglite-cli binary, so kglite skill install works immediately without a second package or a duplicated engine. The separate CLI-only PyPI/crates.io distribution remains available for users who do not need the Python extension.

Fixed

  • Saved revision code graphs retain their agent instructions. Single- and multi-revision save_to artifacts now persist the same revision provenance and scoping guidance returned by the in-memory graph.

  • Repeated web endpoints keep unique Route node IDs. Multiple declarations of the same framework/method/path share one Route node with distinct handler edges, and exact duplicate decorators no longer duplicate the handler edge.

Changed

  • Graph persistence uses explicitly versioned Postcard payloads. New .kgl v5 and .kgle v3 files select their codec in the header, while existing .kgl v4 and .kgle v1/v2 files remain readable through dedicated legacy readers. Rebuildable vector-index caches carry their own codec-aware format version. New disk snapshots also version their Serde-backed sidecars, mixed-value columns, edge-property slots, overflow records, and general ID indexes; existing bincode generations remain readable. Durable WALs are upgraded atomically before append, and new transient N-Triples property logs and nested list payloads use explicit Postcard format versions.

[0.13.3] - 2026-07-14

Fixed

  • Explicit graph copies have independent runtime identity and caches. copy(), copy.copy(), and copy.deepcopy() no longer share Cypher plan identity or mutable semantic-cache locks with the source graph, so equally versioned copies with divergent schemas cannot reuse one another’s plans. Disk-backed copies also write through a lazy private workspace—including N-Triples bulk builds—so mutating or saving a copy cannot alter the source generation or collide with its active writer lease.

  • Disk writers remain usable while frozen or fluent snapshots are held. Copy-on-write mutations and saves now retain the source graph’s writer lineage, while the held view continues reading its stable prior snapshot.

  • Native and XML dependencies include current upstream fixes. PyO3, quick-xml, AWS-LC, crossbeam, anyhow, and memmap2 were refreshed; SEC XML parsing continues to decode escaped and numeric entities after the parser API update.

  • Installation, exception, and MCP quickstart contracts match runtime behavior. DataFrame workflows have a named pandas extra, NetworkX guidance uses its complete extra, KgError is documented as the typed engine-error base, and the generated MCP quickstart names only supported install paths, flags, and trust-gated embedder configuration.

  • Manifest embedders require their documented trust opt-in. MCP servers reject extensions.embedder unless the manifest explicitly sets trust.allow_embedder: true, before any Python factory or Rust model is constructed.

  • Strong connected components retain their directed semantics on disk. connected_components(weak=False) now computes SCCs across memory, mapped, and disk storage instead of degrading to weak components on disk.

  • The published parallel-bz2 Cargo feature is self-contained. It now resolves entirely from crates.io while retaining bounded, CRC-verified block-parallel decoding for single-stream Wikidata dumps.

  • The networkx extra installs the complete bridge dependency set. A clean pip install 'kglite[networkx]' now includes pandas, which from_networkx() uses internally.

  • Package classifiers match the artifacts actually published. Metadata no longer claims PyPy or OS-independent installation for the native extension; macOS, Linux, Windows, and CPython are declared explicitly.

  • N-Triples cancellation reaches the column-build phase. Mapped and disk loads now stop at the next Phase 1b progress callback instead of continuing through edge creation and, for disk graphs, publishing completion metadata.

  • COUNT subqueries honor query budgets and preserve WHERE errors. Pattern expansion and joined rows count toward max_rows, while missing parameters and other predicate failures are returned instead of being treated as non-matching rows.

[0.13.2] - 2026-07-13

Fixed

  • Cypher predicate pushdown preserves colliding constraints. WHERE predicates that target an already-constrained inline pattern property, or a property already reserved by another pushed predicate, remain as residual filters instead of being silently discarded.

  • Relationship filters preserve NULL through boolean composition. Pushed relationship-property predicates now use Cypher three-valued logic, so negating a comparison against a missing or NULL property no longer admits that relationship.

Performance

  • Node text filters run before relationship expansion. Positive STARTS WITH, CONTAINS, and ENDS WITH predicates, including string parameters, now narrow node candidates before multi-hop traversal. Typed STARTS WITH queries also reach persistent prefix indexes correctly.

  • Relationship text and parameter filters run during expansion. String prefix, substring, and suffix predicates plus parameterized relationship comparisons now discard non-matching edges before downstream bindings and hops are materialized.

  • Fused node scans reuse property indexes. Single-node aggregate and top-K operators now share the normal candidate-discovery path instead of scanning an entire label bucket and rechecking indexed properties row by row.

  • IN planning distinguishes lookups from scans. Empty lists now become immediate empty candidate sets, indexed IN predicates use actual hit counts, and non-indexed IN predicates no longer tie constant-time ID anchors in join ordering.

  • Secondary labels no longer disable unrelated typed indexes. Candidate routing now checks the queried label, retaining primary index lookups and unioning only matching secondary-label carriers when needed.

  • Later ID anchors can drive consecutive MATCH clauses. Within a safe shared-variable span, clearly ID-anchored clauses are now stably promoted ahead of broad clauses before expansion.

[0.13.1] - 2026-07-13

Performance

  • Property-grouped Cypher counts avoid materializing one row per edge. Direct property keys are resolved once per endpoint and merged by value before ORDER BY ... LIMIT, preserving duplicate and null group semantics.

  • Unconstrained global edge counts are constant-time. MATCH ()-[r]->() RETURN count(r) reads the backend’s live edge cardinality directly while constrained and undirected patterns retain exact matching.

  • Typed fixed-length paths avoid unnecessary trail bookkeeping. Single-pattern traversals whose relationship types cannot overlap skip exact path cloning while assigned, untyped, overlapping, and multi-pattern paths retain relationship-identity checks.

Fixed

  • Fused grouped counts honor constraints on both endpoints and relationships. Histogram shortcuts now defer to exact counting when the opposite endpoint or a pushed relationship predicate filters the matched edges.

[0.13.0] - 2026-07-13

Added

  • Cypher behavior is now defined by an executable dialect contract. The contract distinguishes supported, partial, extended, and intentionally divergent behavior, and is reflected in the public documentation and agent introspection.

  • EXPLAIN reports optimizer activity. Plans include the optimizer passes that changed them, making rewrites observable and easier to diagnose.

  • Disk saves use immutable generations and cross-process writer leases. Readers remain on one consistent snapshot, interrupted saves leave the prior generation intact, and competing writers fail instead of overwriting one another.

Changed

  • traverse() uses only the canonical where and where_connection filters. The obsolete filter_target and filter_connection aliases have been removed.

  • Cypher names and compatibility reporting are more precise. KGLite functions and procedures accept canonical kglite.* names, while soft keywords used as property or result names retain their original case. Properties written by older releases with canonical-uppercase names remain accessible with backticks.

  • Execution limits cover the complete query. max_rows, timeout, and cancellation checks now apply across expansion, aggregation, subqueries, procedures, result conversion, and mutations; interrupted writes roll back.

  • Public Python, MCP, Bolt, CLI, C ABI, package metadata, and license surfaces are checked as release contracts.

Performance

  • Common in-memory operations avoid unnecessary graph copies and GIL stalls. Point lookup, cache rebuilding, bulk mutation, traversal, and MCP persistence now retain shared read behavior where possible.

  • The planner has fast paths for safe LIMIT, one- and two-hop count(*), and trivial CREATE/DELETE cases. Representative workloads improve from milliseconds or seconds to microseconds without weakening rollback guarantees on complex writes.

  • Lazy result consumption batches provenance checks. Materializing, slicing, representing, or converting a result validates each contiguous batch once while retaining per-row disk-arena guards; the release fixture is 4–9% faster than the pre-hardening implementation.

Fixed

  • Cypher matching and expression semantics are substantially more consistent. Relationship and path identity, trail uniqueness, multi-pattern and optional joins, scope validation, map updates, null writes, three-valued boolean/list expressions, stable top-K ties, and aggregate grouping now agree across optimized and materialized execution paths.

  • Parser and resource failures are bounded and typed. Invalid variable ranges, excessive nesting, unsafe numeric or temporal magnitudes, oversized regexes, missing parameters, and malformed expressions return normal Cypher errors rather than wrapping, exhausting memory, or panicking. Python syntax errors expose line and col.

  • Disk, mapped, and N-Triples storage fail atomically. Growth and write errors propagate without partial rows or published saves; corrupt or truncated files, indexes, WAL records, sidecars, and compressed input are rejected explicitly. Fixed-width storage is alignment-safe and uses portable little-endian encoding.

  • Concurrent and lazy operations retain valid ownership. Overlapping disk-backed reads no longer invalidate materialized values, Session and transaction writers preserve their lineage, and C ABI, Bolt, and MCP boundaries serialize or report conflicting work instead of losing updates.

  • Python data interchange preserves values and declared shapes. DataFrame ingestion promotes whole columns correctly, NumPy arrays become native list properties, connection properties are retained by default, vector conversion errors propagate, and NetworkX export keeps columnar attributes and parallel edges.

  • File-freshness stamping is precise and atomic. Files are read in bounded chunks from stable descriptors, duplicate paths are processed once, updates commit in one transaction, and timestamps use nanosecond UTC precision.

[0.12.14] - 2026-07-10

Changed

  • kglite-mcp-server now requires mcp-methods >= 0.3.50, picking up the upstream multi-rev activation hardening: revs=N selects the dominant stable release-tag family and skips prereleases (on multi-family repos like apache/arrow it previously grabbed whichever family version-sorted highest); update=True re-applies a previously requested rev-set instead of silently collapsing the graph to HEAD-only; resolved rev labels are deduplicated upstream as well.

Fixed

  • Multi-rev code-graph builds are now deterministic and idempotent. Inline <script> blocks in HTML files were sub-parsed through a temp file whose path ({pid}-{per-file-counter}) collided whenever two HTML files’ first scripts parsed concurrently (files parse in parallel), so the two threads clobbered each other’s block.js and the extracted function set — hence qualified_names/ids — varied between builds. Merging the identical tree twice (e.g. revs=[<HEAD-sha>, "HEAD"]) could therefore mint phantom entities and inject spurious added/removed noise into CALL rev_diff. Each inline script now sub-parses in its own unique temp dir, so a rev built twice yields a byte-identical entity set and re-folding an unchanged tree only appends the rev label to existing nodes.

  • Duplicate revs labels are collapsed (order-preserving, first occurrence wins) in build_code_tree_revs, so revs=["HEAD", "HEAD"] no longer folds the tree twice or leaves nodes carrying revs: ["HEAD", "HEAD"]. The MCP activation banner/header reflect the deduped set.

  • graph_overview / properties() no longer present sampled property stats as exhaustive. Types at or below ~200k nodes are now scanned in full so unique / vals are exact (previously 200-node sampling could report a unique count and value list that silently omitted values — acute for the revs property agents scope on). When sampling still applies (larger types) the output is marked honestly: unique="N+" plus an approx="true" attribute in the schema XML, and an approx key in the Python properties() dict.

  • Cypher errors from the MCP server no longer stutter their prefix. A failing query surfaced as Cypher error: Cypher execution error: Cypher execution error: (three wrappers); the redundant re-prefixing is removed so a self-identifying engine error reads once.

  • Single-rev graphs read as a point-in-time snapshot, not a degenerate multi-rev graph. Building with a one-element revs list (or duplicate labels that dedup to one) no longer prints “Multi-rev graph spanning 1”, the unscoped-over-count warning, or CALL rev_diff steering (none of which apply to a single rev) in either the graph-embedded provenance or the MCP activation summary.

[0.12.13] — 2026-07-09 — multi-rev code graphs + ingestion integrity

Added

  • Multi-rev code graphs from the MCP server — set_root_dir(revs=…) / repo_management(revs=…). The workspace activation tools gained a revs argument (an integer N = the last N release tags + HEAD, or an explicit list of git revspecs). When revs are requested the server builds ONE multi-rev graph via build_code_tree_revs (instead of the HEAD-only build), and the agent-facing identity surfaces name the loaded rev-set: the <active_graph revs="v1,v2,…"/> header attribute, the activation message (which also teaches WHERE '<rev>' IN n.revs scoping + CALL rev_diff), and the graph_overview provenance instructions. Unscoped queries span all revs (an over-count trap the steering warns about). Requires mcp-methods ≥ 0.3.49 (the revs activation arg + revs-aware post-activate hook).

  • CALL rev_diff({from, to}) — Cypher delta over a multi-rev code graph. Reports the code entities added, removed, or changed between two revs of a graph built by code_tree.build(revs=[…]), by anti-joining the per-node revs list and comparing the aligned rev_fp fingerprints — no source re-parse. Yields bucket, type, qualified_name, name, file, line; optional {node_type} scoping. Errors clearly on a non-multi-rev graph or an unknown rev (listing the available revs). E.g. CALL rev_diff({from: 'v1', to: 'HEAD'}) YIELD bucket, qualified_name RETURN *.

  • code_tree.build(revs=[…]) — multi-rev code graphs from Python. Pass a list of git revspecs (oldest → newest, mutually exclusive with rev=) to kglite.code_tree.build / kglite.build_code_tree to merge N revisions into one graph. Every node carries revs: [str] (revisions it appears in) + rev_fp: [int] (per-rev shape fingerprint) and every edge carries revs: [str]; ordinary properties report the newest rev (newest-wins). Because one graph holds all revs, an unscoped MATCH (n:Function) RETURN count(n) over-counts — scope with WHERE 'v2' IN n.revs. describe() lists the loaded revs and teaches the scoping idiom. Wraps the Rust build_code_tree_revs.

  • Multi-rev code graphs — kglite::api::code_tree::build_code_tree_revs (Rust api). Merge N git revisions of a codebase into one graph via shared identity + rev-sets: one node per entity (keyed by (node_type, id), aligned across revs by a fixed snapshot basename), carrying native list props revs: [str] (revisions it appears in) + rev_fp: [int] (per-rev fingerprint hash, so a signature/value change is detectable between any two revs), and one revs: [str] on every edge. Unchanged entities are stored once, so the graph is ≈ base + deltas; ordinary property columns report the newest rev an entity appears in, and unscoped queries span all revs (scope with WHERE '<rev>' IN n.revs). Each rev is archived-and-built independently (reusing archive_and_build) then folded oldest→newest through extend_graph, at ≈ two graphs’ peak memory. Rust-only for now — the Python code_tree.build(revs=[…]) surface and the CALL rev_diff procedure follow.

  • code_tree.build(rev=…) — build a code graph from a git revision. Pass a tag, branch, or SHA as rev to kglite.code_tree.build / kglite.build_code_tree to graph a codebase as it existed at that revision. The revision’s tracked files are materialized via git archive into a tempdir and built with the normal pipeline — HEAD and the working tree are never touched, uncommitted changes are excluded, and .gitignored/untracked files never appear. The git root is auto-resolved from the given path (override with repo_root=); a bad rev or non-git directory raises a clear error. The built graph’s describe() records which revision it represents. Composes into a “what changed between two revs” workflow. rev=None (default) is exactly the previous working-tree behavior.

  • code_tree.diff(graph_a, graph_b) — structural diff of two code graphs. Compares the code-entity nodes (Function, Class, Struct, Mixin, Enum, Trait, Protocol, Interface, Constant) of two graphs built by kglite.code_tree.build — typically two revisions of one repo, via build(rev=…) — and returns {"added", "removed", "moved", "changed", "summary"}, each entry carrying qualified_name, type, file, and line. Identity is qualified_name (build-root prefix stripped so it is stable across builds/revs, including tempdir rev= builds). moved is the honest same-simple-name-different-file signal only — a genuine rename shows as remove + add. changed fires on a cheap already-stored fingerprint (signature / visibility / constant value / enum variants / struct fields / line span) without reparsing source; a same-line-count body edit is not detected. Pure-Python (kglite/code_tree/_diff.py), one bulk query per type per graph — no per-node round trips. Raises a clear error on an empty or non-code_tree graph.

Fixed

  • Chained-dot access into a map property — n.m.k — now resolves. Reading a map-valued property with chained dots (RETURN n.m.k) returned null, while the equivalent bracket subscript (n.m['k']) worked. The Cypher executor’s expression-property-access path had no map arm; it now mirrors the bracket path (n.m.k == n.m['k'], a missing key is null). Works for both node and edge map properties, across storage modes.

  • add_connections warns when it drops columns absent from columns=. Unlike add_nodes, add_connections keeps only id/title columns unless an explicit columns= whitelist is given — so a plain add_connections(df, ...) with edge-property columns silently dropped them. It now emits a UserWarning naming the dropped columns (once per call), so the asymmetry is visible. The whitelist behaviour itself is unchanged: pass columns=[...] to keep the columns.

  • from_records keeps dict field values as maps. A JSON object in a from_records record (e.g. {"id": 1, "meta": {"k": 1}}) was silently dropped to None: json_to_value built a Value::Map, but the records→ DataFrame type inference (from_cypher_rows) had no Map column type and coerced it through String to null. It now infers ColumnType::Map, so the dict round-trips as a native map (n.meta['k'] reads it back). Nested lists inside the object are preserved too.

  • DataFrame ingestion preserves datetime time-of-day and dict values. add_nodes / add_connections previously truncated a pandas datetime64 column to date-only (dropping 03:04:05) and stringified a column of Python dicts ({'k': 1} → the text "{'k': 1}"). A datetime64 column carrying any nonzero time-of-day is now ingested as a full Timestamp (pure-midnight columns stay date-only for back-compat), and a column of dicts is ingested as a native Mapn.meta['k'] reads the value back instead of None. Nested lists/dicts inside the map keep their structure. Matches what the params/Cypher paths already did.

  • C# Constant nodes now carry a value_preview. C# const / static readonly fields emitted a Constant node with value_preview = null, so a constant’s value edit (e.g. const int Timeout = 3060) was invisible to code_tree.diff. tree-sitter-c-sharp flattens the initializer directly under variable_declarator (no equals_value_clause wrapper), and the extractor looked only for the wrapper; it now reads the initializer directly, mirroring the Java parser. C# constant value changes now surface in the diff’s changed bucket.

  • code_tree.diff now normalizes backslash-joined build-root prefixes. A PHP file without a namespace gets a synthetic <build-root-basename>\<rel-path>\<symbol> qualified_name (backslash-joined), but the diff’s root normalization stripped only dot-joined prefixes — so the throwaway-tempdir basename of a rev= build survived and every class, method, and constant was mis-reported as removed + added against a working-tree build. The build-root detection/stripping now treats \ as a namespace separator alongside .; Rust crate::/C++ :: leads (which never embed the basename) are untouched. Rev-vs-worktree parity now holds for unnamespaced PHP.

  • C/C++ #define constants are now captured, including ALL-CAPS names and defines inside preprocessor conditionals. The #define:Constant pipeline existed end to end but was dead for the common case. Two defects: (1) the shared macro-decorator filter — which correctly protects the function-name slot from export macros like KUZU_API — also dropped every SCREAMING_SNAKE_CASE #define name (e.g. MI_TLS_MODEL), so the constant never materialized; the preprocessor-definition name is now read verbatim off its name field, leaving function/class extraction filtering intact. (2) The extractor visited only direct translation-unit children, so #defines guarded by #if / #ifdef / #ifndef / #elif / #else were never reached; the extractor now recurses into those conditional blocks (also picking up functions/types declared inside them). A #define NAME value in a C/C++ file is now queryable as MATCH (c:Constant {name:'NAME'}) RETURN c.value_preview, c.line_number.

[0.12.12] — 2026-07-08 — pyarrow coexistence + dataset-fetch standardization

Changed

  • MCP server: GitHub API calls now retry transient failures. Bumped mcp-methods 0.3.47 → 0.3.48 — github_issues/github_api retry 429/5xx and transport errors with exponential backoff (3 retries, 500 ms × 2 capped at 30 s) instead of failing the tool call on the first blip.

Fixed

  • pyarrow-24 coexistence — pin the wheel’s bundled mimalloc to v2. Importing both pyarrow==24.0.0 and kglite in the same interpreter and letting it run to normal teardown could SIGSEGV at exit. Cause: two statically-linked mimalloc-v3 instances in one process — kglite’s #[global_allocator] and the copy CPython 3.14 vendors into libarrow — collide during thread-heap teardown (_mi_theap_collect_retired). Pinning kglite’s bundled mimalloc to the v2 series (which coexists cleanly with the v3 copy) fixes the crash. Cost is ~3-4% on parse-heavy loads; core query benchmarks are flat-to-better (the 11 tracked core benches stayed within +6.9% worst-case, most faster). No Python-visible change. A permanent coexistence canary (tests/test_pyarrow_coexistence.py, exercised on the newest-Python CI leg) now guards this: pyarrow and kglite are asserted to import and tear down cleanly in both orders, so the dual-allocator crash class cannot regress silently.

  • SODIR and Wikidata dataset fetches no longer hold the GIL. The _sodir_internal.refresh, _wikidata_internal.ensure_dump, and _wikidata_internal.remote_last_modified bindings now release the GIL for the duration of their network calls (via py.detach), so other Python threads — e.g. a Jupyter kernel’s IOPub thread — can run during a long download (a Wikidata dump is multi-GB). Matches the treatment SEC’s batch fetchers already had. No Python-visible signature change.

Changed

  • Rust-side dataset fetch is now synchronous. The SEC EDGAR (kglite::api::datasets::sec), SODIR FactMaps (kglite::api::datasets::sodir), and Wikidata dump (kglite::api::datasets::wikidata) loaders moved off async reqwest + tokio + governor onto a shared blocking DatasetClient (ureq + a process-global rate gate + retry), matching the “core is sync” doctrine. The fetch_* entry points, SecClient, SODIR’s ArcGISClient methods, and Wikidata’s ensure_dump / remote_last_modified are now plain fns — no runtime needed to drive them; SODIR’s concurrent refresh runs on a bounded scoped-thread worker pool (per-completion index save preserved for Ctrl-C resume), and the Wikidata resumable dump download streams straight to disk with no read timeout (10-20 GB stream) and its Range/206 resume path intact. The Python API is unchanged. With every loader ported, the old async plumbing is deleted: the datasets/blocking.rs tokio bridge (and the api::datasets::block_on re-export) is gone, and reqwest + governor are dropped from the kglite crate’s dependency tree entirely while tokio leaves the kglite and kglite-py loader paths (the bundled MCP/Bolt servers keep their own runtimes). This trims the compile graph and shrinks a loaders-only wheel; the default wheel’s size is dominated by the bundled server and is little changed.

[0.12.11] — 2026-07-08 — MCP root-swap correctness + active-graph identity

Fixed

  • Stale MCP graph after a root swap (code-review / open-source servers). set_root_dir(A)set_root_dir(B)set_root_dir(A) (and the equivalent repo_management A→B→A) could leave the previous graph active while the tool still reported “Graph ready”: the server keeps a single active-graph slot, and mcp-methods’ rebuild-skip gate treated any root ever built this process as still-live, so the intervening B swap left B loaded under A’s name. Fixed by flooring the mcp-methods dependency at 0.3.47, whose skip gate tracks the currently-active built root — re-binding a different root always rebuilds, while a same-root re-bind still cheap-skips.

Added

  • Active-graph identity in agent output. graph_overview prepends an <active_graph root="…" built_at="…" age="…"/> header; cypher_query results carry a one-line active graph: · built footer; and the set_root_dir activation message names the live root + build age. An agent can now see which root (and how fresh) it is querying and spot a stale graph immediately.

  • Lazy-discovery escape hatch in the activation message. The workspace activation/repo_management reply now tells a client that loads MCP tools lazily (Codex / code-mode / tool-search) to search its registry for cypher/graph_overview if they aren’t loaded — the graph tools are always registered, so a broad first-search miss shouldn’t read as “graph unavailable.” Complements the existing instructions-block steer by putting the hint in a tool-call result, which lazy clients read more reliably.

[0.12.10] — 2026-07-02 — Agent-oriented CLI automation

Added

  • kglite-cli one-shot agent commands. The standalone kglite binary now supports query, write, ready-set, describe, and session subcommands with --format table|csv|json; write also supports --save, --write-scope, --git-sha, and --modified-by so automation can use the same scoped-write and provenance controls as MCP/Python paths. session processes JSONL requests against one loaded graph, avoiding reload-per-query for agents.

Fixed

  • Agent-facing CLI protocol polish. kglite session --format json now emits typed rows for query/write responses and echoes request id values, avoiding double JSON parsing and positional-only response matching.

  • Focused describe() connection counts. Type-detail and connection-detail views now fall back to live edge scans when an older saved graph carries zero-count connectivity-cache triples, so they no longer report count="0" while samples show matching edges.

[0.12.9] — 2026-07-02 — --selftest wide-root fix + production-shape dogfood gate

Fixed

  • kglite-mcp-server --selftest no longer hangs on a wide workspace.kind: local root. The activation step used to set_root_dir(workspace.root), building a code_tree over the entire root — but that root is a wide sandbox agents narrow with set_root_dir and is never built as a unit, so for the documented code-review archetype (root = a whole dev tree) it was unbounded work → a silent hang. --selftest on local-workspace is now registration-only by default (verifies the server inits + graph tools + set_root_dir are registered, without building), mirroring how the github-workspace selftest already behaves. New --selftest-path <subdir> opts into a real build + cypher_query hydration against a small representative directory. Reported by the mcp-servers operator.

Changed

  • Dogfood --selftest in the test suite at production shape. The bundled- wheel test suite (which make test/CI runs) now exercises --selftest through the pip-wheel install (python -m kglite.mcp_server) against a wide local-workspace root and via --selftest-path, so the wheel-install and wide-root conditions that produced the last two --selftest bugs are a standing gate rather than caught after release.

[0.12.8] — 2026-07-02 — --selftest wheel-install fix

Fixed

  • kglite-mcp-server --selftest on the pip-wheel install. The self-test re-spawns the server to drive a live handshake; it used current_exe(), which on the wheel is the Python interpreter (the kglite-mcp-server command is a console-script shim), so the child launched as python <server-flags> and failed with “Unknown option” — the tool that catches silent misconfiguration was itself broken on the primary install path (0.12.7). The wheel entry (kglite.mcp_server.main) now exports KGLITE_MCP_RESPAWN so the server re-spawns via the module entry (python -m kglite.mcp_server); the cargo standalone binary is unaffected (falls back to current_exe()). Added a wheel-install regression test that exercises --selftest through the console shim, not just the cargo binary. Reported by the mcp-servers operator.

[0.12.7] — 2026-07-02 — MCP-server --selftest, default discovery banner, and workspace-archetype docs

Added

  • kglite-mcp-server --selftest. A positive “did I set it up right?” check: re-spawns the binary with the operator’s own flags, drives a real MCP handshake (initializetools/list → activate → cypher_query), and prints green/red per capability (server initializes, graph tools registered, github tools present when a token is reachable, workspace activation, graph hydrates). Exits non-zero if any check fails, so it doubles as a deployment / CI smoke gate. Server misconfigurations were previously silent (missing tools, hidden github tools, stale PATH-shadowing binary, “No active graph”) — this makes them loud.

  • kglite-mcp-server default lazy-tool-discovery steer. Workspace modes (--workspace / workspace.kind: local) now fold a one-line discovery steer into the initialize instructions by default — “graph_overview and cypher_query are ALWAYS registered; if a broad first tool-search surfaces only grep/read_source, search your registry for ‘cypher’ before falling back.” It’s the client-side complement to the 0.12.6 in-band steering, so code-mode / tool-search clients get the guidance without every deployment copy-pasting it into its manifest. Any manifest instructions: are preserved (appended), and the steer is skipped when the manifest already carries equivalent text.

Documentation

  • MCP-server docs for the two workspace archetypes (mcp-servers operator feedback). The Quick Start config example now leads with the absolute path to the binary + a one-line “why” (silent PATH-shadow version drift), a prominent restart-after-config-change note, and a new “Verify your setup” section built around --selftest. Ships a copy-pasteable examples/local_code_review_mcp.yaml (the local code-review counterpart to the existing github-clone example); README and operators/index.md now headline both archetypes.

[0.12.6] — 2026-07-02 — Runtime-write correctness (petekSuite) + MCP graph-over-grep steering

Added

  • kglite-mcp-server runtime graph-over-grep steering (mcp-methods 0.3.46). Two hooks now correct an agent’s course at the moment it acts, not just in the load-once tool descriptions:

    • A result footer on builtin tool output: a definition-shaped or zero-match grep gets steered to cypher_query/graph_overview, and a cypher_query result carrying qualified_name is pointed at read_code_source. Fires only when a code graph is active; otherwise the result is byte-for-byte unchanged.

    • An activation mini-map: workspace activation now appends “Graph ready: N Functions · M Classes · … · K edges. Start with graph_overview() → cypher_query; grep = literal text only”, so the first message an agent reads steers it graph-first.

Changed

  • Bumped mcp-methods 0.3.45 → 0.3.46 (adds the result-postprocess + activation-summary hooks above and a SKIP-first grep bundled skill).

  • Bundled MCP code-navigation skills lead with a consistent, truncation-proof 4-step workflow banner (graph_overviewcypher_querygrep for literals only → read_source/read_code_source). A field report showed an agent grepping for definitions/callers despite the doctrine living lower in a skimmed-once description; the banner puts the steer at the top of every code-nav skill body where it can’t be missed.

Fixed

  • Runtime-write correctness: title updates, edge integrity, and deleted-id cleanup across save/reload (reported by petekSuite). Four issues, all in the in-memory columnar save path:

    • Cypher \uXXXX string escapes were dropped — a title written as "A—B" was stored as the literal text Au2014B. The tokenizer now decodes 4-hex-digit unicode escapes (non-\uXXXX backslash-u stays literal).

    • Title updates reverted on save+reload. Every in-place title write (Cypher SET n.title, add_nodes(conflict_handling="update"/"replace")) sets the inline node.title but not the columnar __title__; the save re-consolidated the stale column value once the type had a column store (i.e. after the first save+load). enable_columnar now detects the inline override and consolidates the fresh title.

    • Edge endpoints scrambled after delete+create+save. The save wrote column rows in insertion order while load re-bound them in ascending node-index order; once a deletion made those orders diverge, every row (and thus every edge) rebound to the wrong node. Column rows are now built in ascending node-index order to match the load-side re-point.

    • Deleted ids resurrected on reload. After a DETACH DELETE, enable_columnar early-returned and serialized the stale store still containing the deleted row — the node stayed findable by id-lookup and re-bindable by MERGE, inconsistent with the live count. The early-return now rebuilds when store rows exceed the live node count.

[0.12.5] — 2026-07-01 — mcp-server activation fix + Codex/code_mode discovery on-ramp

Added

  • describe() / graph_overview now emit a schema-adapted example query per node type. Each <type> carries an <example> anchored on that type’s real identifier property (its id alias, else the builtin id) with a concrete sampled value — e.g. MATCH (n:File {id: 'src/foo.rs'}) RETURN n vs MATCH (n:Function {qualified_name: 'mod::bar'}) RETURN n. Prevents the wrong-property first query a lazy-tool-discovery client (Codex / code_mode) hit when guessing a type’s key from generic examples.

Changed

  • cypher_query / graph_overview tool descriptions now lead with code-exploration vocabulary (explore, understand, “how does”, call graph, “where defined”, structure, navigate) so tool-search / code-mode clients surface the graph tools on their first broad discovery pass instead of falling back to grep.

Fixed

  • kglite-mcp-server workspace/github mode: graph now hydrates when activating an already-built repo in a fresh process. Bumped the mcp-methods dependency 0.3.44 0.3.45, which fixes a post-activate hook skip: the build-skip gate keyed only on the persisted last_built_sha, so a fresh server process (empty in-memory graph, but a matching on-disk SHA) reported activation success while leaving no active graph — graph_overview / cypher_query returned “No active graph” until a force_rebuild. Affected both github mode (git SHA) and local mode (directory fingerprint). No kglite code change — clean dependency bump.

[0.12.4] — 2026-06-27 — Disciplined graph-as-document projection + file-freshness helpers

Added

  • stamp_file_freshness() / check_file_freshness() — drift detection for nodes that link to files. The binding-layer answer to SimulatoRS’s “auto-stamp file freshness” ask: the engine still never reads the filesystem (that no-fs line stays), but these Python helpers do. stamp_file_freshness(g) captures each node’s linked-file file_mtime/content_hash into properties; check_file_freshness(g) re-checks read-only and returns the drifted nodes (status: "missing" for a deleted file, "changed" for an edited one) — the “Artifact pointing at a deleted crate” case as a one-call gate.

  • Outline projection — CALL outline + kglite.outline(). Project a subgraph into the “open and skim” view a graph otherwise lacks: a BFS spanning tree from a root node along one edge type. The engine procedure CALL outline({root, edge, max_depth?}) YIELD node, depth, parent_id yields the tree structure (Cypher-composable, each node once at first-discovery depth); the binding-layer kglite.outline(g, root, edge) renders it as a nested markdown outline; pass body="<prop>" to indent each node’s prose property under its bullet (the markdown-body view — prose is a plain property, not a special field). Presentation stays in the binding; the engine stays a query engine.

  • to_text() / kglite export-text — a deterministic text projection of a graph, for human-readable .kgl git diffs. Nodes grouped by type + sorted by id, edges sorted by endpoints, so the output is stable across insert order AND across save/load (in-memory vs columnar) — git diff of two .kgl snapshots shows real content changes. Wire it up as a git textconv filter (git config diff.kglite.textconv "kglite export-text" + *.kgl diff=kglite in .gitattributes). Reserved provenance keys (updated_at/git_sha) are omitted so per-write churn doesn’t swamp the diff. kglite diff a.kgl b.kgl prints an explicit structural delta (-/+ lines; a changed node is a pair).

  • MCP write acks now stamp the engine version (OK: 1 node(s) created. [engine 0.12.4]). A long-running kglite-mcp-server pins its engine, so a venv upgrade that doesn’t restart the server silently keeps writing with the old engine (e.g. not honouring auto_timestamp until restart) — surfacing the version on every write makes that visible.

[0.12.3] — 2026-06-26 — Opt-in freshness provenance + SimulatoRS Cypher fixes

Added

  • Opt-in freshness provenance (auto_timestamp). Tag a node type with define_schema({"nodes": {"Task": {"auto_timestamp": True}}}) and the engine stamps a reserved updated_at timestamp on every write to that type — Cypher CREATE/MERGE/SET and add_nodes — so “when was this last touched” is a query (MATCH (n:Task) WHERE n.updated_at < $cutoff), not a guess. The stamp is engine-managed (a user-supplied updated_at is overwritten) and off by default, so writes stay deterministic unless a type opts in. A SET bumps it once per modified node. updated_at is metadata, not data: directly queryable (n.updated_at, n {.updated_at}) but hidden from property enumerations (keys(n), properties(n), RETURN n / n {.*}, describe()). Edges/connections opt in the same way (define_schema({"connections": {"LINKS": {"auto_timestamp": True}}})) and stamp updated_at on edge CREATE / add_connections / SET, queryable as r.updated_at and likewise hidden from edge data views.

  • Caller-supplied git_sha / modified_by provenance. Pass cypher(query, git_sha="<sha>", modified_by="<actor>") (or the MCP cypher_query git_sha/modified_by args) and the write stamps those reserved keys alongside updated_at on auto_timestamp types — so a node records “describes the world as of commit X, written by Y”. Queryable (n.git_sha, r.git_sha), hidden from data views, scoped to the one mutation (cleared after), and only on opted-in types.

  • CALL duplicate_id({type}) YIELD node structural validator. The identity-column sibling of duplicate_title: yields every node of type whose id is shared with another node of the same type. Handy after bulk writes — a CREATE fanned out over a multi-row MATCH (standard Cypher: one create per matched row) can mint several same-id nodes without complaint, and this surfaces them. Composes with WITH/aggregation like the other rule procedures.

Fixed

  • DETACH DELETE (and DELETE) inside FOREACH over a collected list now runs. MATCH (n) WITH collect(n) AS ns FOREACH (e IN ns[1..] | DETACH DELETE e) — the keep-first dedup idiom — was a silent no-op: the FOREACH loop variable binds a materialised node value (Value::Node in projected), but execute_delete only resolved a NodeRef, so the deletes were dropped. It now resolves a materialised node value the same way, so FOREACH-driven deletion over a collect()ed list works (incident edges detach as expected). A MATCH-bound DELETE t inside FOREACH was already correct and is unchanged.

  • Multi-pattern MATCH (a), (b) after a WITH/UNWIND now cross-joins. A comma-separated multi-pattern MATCH that followed a seeded pipeline matched each pattern independently and emitted half-rows ({a, null}, {null, b}) instead of the joined {a, b} — a silent wrong result. As a knock-on, the bulk pattern UNWIND $rows AS r MATCH (a {id:r.a}),(b {id:r.b}) CREATE/MERGE (a)-[:R]->(b) mis-bound the endpoints and created spurious unlabelled nodes (CREATE) or errored “must be bound by prior MATCH” (MERGE). The subsequent-MATCH branch now chains patterns into a proper cross-join (the single-pattern hot path is unchanged and keeps its LIMIT pushdown). Bulk edge creation via UNWIND now works.

[0.12.2] — 2026-06-25 — Write-enabled agent-graph MCP server + edge-persistence & Cypher fixes

Added

  • Write-enabled MCP server (“agent graph workbench”). Launch the MCP server with --graph foo.kgl --writable and cypher_query now accepts mutations (CREATE/SET/DELETE/MERGE) — so an agent can plan and work inside the graph over MCP, not just read it. Pass write_scope=["Plan","Task"] to restrict mutations to those node types (role-scoped writes). Mutations are in-memory; save_graph persists. Read-only stays the default (analysis / code-review servers are unaffected); writes route through the active graph’s write-lock so they serialize safely across concurrent MCP clients. Plus runtime graph-lifecycle tools (write-enabled only): load_graph(path), create_graph(path, storage), and save_graph_as(path) — so an agent can load or create a graph, work, persist, and swap to another within one session (the “graph workbench”). A mutation with no RETURN now returns a write acknowledgement (OK: 1 node(s) created, ) instead of the bare “No results.” — so an agent can tell a successful write from a no-op match.

Fixed

  • UNION with mismatched column names now errors instead of returning silent NULL rows. ... RETURN a UNION ... RETURN b previously kept the left arm’s column names and filled the right arm’s misaligned columns with NULL; it now rejects with “All sub queries in a UNION must have the same return column names” (matching Neo4j). Same for INTERSECT/EXCEPT.

  • Inline node-pattern property referencing an UNWIND map member now resolves. UNWIND $rows AS x MATCH (n {id: x.id}) (and the common bulk SET form) previously matched nothing — x.id (member access on the unwound map) wasn’t evaluated, so the pattern silently found no nodes. (Bare-variable, WHERE, and WITH-projected forms always worked.) Found via a clean-agent MCP stress test.

  • Critical: a relationship type introduced via Cypher CREATE/MERGE is no longer silently dropped on save(). Cypher edge creation registered the new type only in the lightweight connection_types cache, not in connection_type_metadata. The columnar save() consolidates edges by registered connection type, so on a loaded/columnar graph a brand-new edge type’s edges were lost on save (and queries warned “unknown relationship type”) — while the endpoint nodes survived. Edge CREATE now upserts the full connection-type metadata (matching add_connections), so the type and its edges persist. Covers CREATE and MERGE. Reported by SimulatoRS (it broke the agent-contract pattern of an agent linking runtime nodes to managed ones); verified end-to-end through a full research-managed_reload-over-agent-board round-trip.

[0.12.1] — 2026-06-25 — write_scope edge-scoping fix + Transaction.cypher write_scope + 0.12.0 docs

Fixed

  • write_scope no longer rejects creating an edge to a matched out-of-scope node. 0.12.0’s edge guard checked both endpoint node types, which broke the central pattern of linking a runtime node to an existing managed one (MATCH (t:Task),(s:AlgorithmSpec) CREATE (t)-[:IMPLEMENTS_SPEC]->(s) with a scope excluding AlgorithmSpec). Linking to a matched node doesn’t mutate it, so it’s now allowed; creating a new out-of-scope endpoint node is still rejected (via the node-CREATE guard). Reported by SimulatoRS.

Changed

  • write_scope now also available on Transaction.cypher(...) — previously only cypher / Session.execute carried it, so a scoped write reaching for a transaction silently lost its scope. MERGE is (and already was in 0.12.0) scoped — the 0.12.0 note saying otherwise was wrong; docs corrected.

[0.12.0] — 2026-06-25 — Agent-contract graph: from_records, write-scope, ready-set, native lists, ownership layers + instructions

Added

  • CALL ready_set(...) — a general dependency-frontier procedure: over a DAG on a chosen edge type, return the nodes whose dependencies (their outgoing-E neighbours) all satisfy a done predicate — the “ready set” of a build/scheduling/dataflow graph. CALL ready_set({relationship: 'DEPENDS_ON', done: 'n.status = "done"'}) YIELD node, dependency_count. Opt-in like pagerank/louvain, with the same {node_type, relationship} scoping; the done-predicate reuses the standard where-style syntax over n.

  • Role-scoped writescypher(..., write_scope=["Plan", "Task"]) (and Session.execute(..., write_scope=[...])) restrict Cypher CREATE/SET to a node-type whitelist (integrity, not secrecy: a coding role may write its own types but not research-owned Algorithm/Assessment nodes; an edge may not wire onto an out-of-scope endpoint either). Enforced at the executor’s CREATE/SET chokepoints, per-call and execution-scoped (never persisted, zero cost when unset). MERGE and the low-level Transaction.cypher are not yet scoped — use cypher/Session.execute for scoped writes.

  • kglite.from_records(spec) — build a graph from an inline JSON records spec (nodes + connections), no CSV files on disk. A JSON-native sibling to from_blueprint, the natural ingestion path for agent-authored graphs. Column types are inferred from the record values, so a JSON array becomes a native list property; missing edge endpoints are auto-vivified as provisional stub nodes (same as add_connections). Accepts a dict or JSON string; save=, lock_schema=, storage=/path= mirror from_blueprint.

  • Ownership layers + managed-reload guard for two-writer contract graphs. A node type can declare layer: 'managed' (rebuilt from source) or 'runtime' (owned/mutated live by another writer) in define_schema. add_nodes(..., managed_reload=True) then refuses to write a runtime type (skips it as a reported no-op), so a batch “research” rebuild can never clobber agent-owned nodes — turning a disjoint-ownership convention into an enforced guarantee. Opt-in; undeclared/managed types are unaffected. Persists in the .kgl (additive).

  • graph.set_instructions(text) — a graph-level instructions/briefing slot rendered verbatim and un-truncated at the top of describe() (as <instructions>), so an agent opening a .kgl cold reads how to use it first (unlike sample values, which truncate). Persists in the .kgl (additive — old files load without it); pass empty text to clear. A reserved channel= keyword leaves room for per-audience briefings later.

  • Native list properties on ingestion. A pandas column of Python lists/tuples now ingests as a real list-valued property (ColumnData::List) instead of a stringified "['x', 'y']". IN tests membership over the elements ('y' IN n.aliases), with no false-positive substring match, and UNWIND n.aliases yields the individual elements. List typing is also selectable explicitly via add_nodes(..., column_types={"col": "list"}). This is ingestion-side only — Value::List already round-trips through storage, so there is no .kgl format change.

Changed

  • Documented guarantee: add_nodes(conflict_handling="update") writes only the columns present in the call, leaving an existing node’s other properties untouched. This was already the behaviour; it is now a stated contract (regression-tested) so a batch reload can re-assert a subset of fields without clobbering fields another writer owns (e.g. an agent’s live status/notes).

Fixed

  • List (and timestamp) properties are no longer dropped through the overflow property bag. The mapped/disk overflow serializer encoded Value::List as NULL (and the mapped reader/borrowed streaming-disk path silently skipped lists), so a sparse list property could vanish on a streaming-disk subset save. Lists now round-trip via a new additive overflow wire tag (8 = length-prefixed bincode); the mapped overflow reader also gained the previously-missing Timestamp tag, restoring memory↔mapped parity for timestamp overflow values. The tag is additive — existing .kgl files never contain it, so this is read-compatible.

[0.11.16] — 2026-06-25 — Primary-key uniqueness + the kglite shell on pip (kglite-cli)

Added

  • pip install kglite-cli now provides the kglite interactive shell — a separate, lightweight wheel (built via maturin’s bin binding, one py3-none-<platform> wheel per platform) that installs the compiled binary on PATH. The core kglite wheel stays library-only (no shell-binary bloat). Mirrors the crates.io split (cargo install kglite-clipip install kglite-cli). (aarch64-linux wheels are best-effort; cargo install kglite-cli is the fallback where a cross-build isn’t available.)

  • kglite shell polish: multi-line statements (sqlite3-style — a Cypher statement now runs when terminated by ;; otherwise the prompt keeps reading, so a query can span lines), .timing on|off (prints query wall-time), and tab-completion of dot-commands and the live graph’s labels / relationship types.

  • kglite shell: .import <file.csv> <NodeType> [--id <col>] [--title <col>] loads a CSV as nodes, with per-cell type inference (int/float/bool/else string). Rows are passed as a Cypher $rows parameter (cell values are never interpolated into the query — no injection; only the node type + column names are identifiers, and those are validated), so it rides the normal CREATE path including primary-key enforcement.

  • Enforced uniqueness on a declared primary key. Once a node type declares primary_key (see below), a Cypher CREATE that would duplicate the key is rejected with a clear duplicate primary key error instead of silently making a second node — including within a single bulk statement (UNWIND CREATE). MERGE is unaffected (the explicit upsert path), and types without a declared primary key keep the permissive default, so the dense-int hot path is untouched. Enforcement rides the existing O(1), cross-mode id-index (identical in memory/mapped/disk) and maintains it incrementally for declared-PK types so sequential creates stay O(1)/node. add_nodes likewise rejects a within-batch duplicate id on a declared-PK type (it would otherwise become a hidden duplicate); duplicates vs the existing graph keep the established conflict-handling upsert behaviour.

  • define_schema(...) node entries accept an optional primary_key to declare a node type’s primary key, e.g. g.define_schema({"nodes": {"Person": {"primary_key": "id"}}}). The declaration round-trips through schema_definition() and persists in the .kgl (older files load with no PK). For now the key must be "id" (the identity field) — a non-id declaration is rejected — and this first step only records the declaration; write-path uniqueness enforcement follows.

[0.11.15] — 2026-06-25 — SQLite-conventions: kglite shell, db.* introspection, export escape hatch + div/mod overflow fix

Added

  • kglite interactive shell (new kglite-cli crate) — the sqlite3-style REPL for .kgl graphs: kglite app.kgl opens a Cypher prompt, no Python or server needed (cargo install kglite-cli ships the kglite binary). Runs any Cypher and prints results as an aligned table, CSV, or JSON (.mode), plus sqlite3-style dot-commands: .labels / .rels / .schema / .indexes (introspection), .dump <dir> (portable CSV+blueprint export), .read <file> (run a Cypher script), .save [path] (write a .kgl), .help, .quit. Ctrl-C cancels a running query; Ctrl-D exits. (.import awaits LOAD CSV — use .read or from_blueprint meanwhile.)

  • Cypher schema introspection now reaches property keys and the per-type schema, not just labels/relationship-types/indexes: CALL db.propertyKeys() YIELD propertyKey (every declared property name, sorted) and CALL db.schema() YIELD nodeType, properties (one row per node type with its sorted property-name list — the in-language counterpart of Python describe()). Both are Neo4j-named so Bolt drivers can call them, and reuse the same schema_overview helpers describe() does. Listed in list_procedures and CYPHER.md.

Changed

  • CYPHER.md: corrected the db.labels() / db.relationshipTypes() YIELD columns in the procedure reference (they yield label / relationshipType, not name — the docs predated the Neo4j-name alignment and the examples would have errored).

  • .kgl hard-break load errors (v3-in-v4, unrecognized-format file/byte-buffer) now point at the format-stable export escape hatch: if you no longer have the original source but can still run the old binary, g.export_csv('backup/') writes a portable CSV+blueprint.json copy that kglite.from_blueprint(...) rebuilds on any version. New guide section “Back up before upgrading” documents it as the recommended pre-upgrade step (SQLite .dump parity). No format change.

Fixed

  • Cypher integer division and modulo now wrap on overflow instead of panicking. arithmetic_div/_mod used the raw //% operators, so i64::MIN / -1 (e.g. RETURN (-9223372036854775807 - 1) / -1) and i64::MIN % -1 trapped in both debug and release builds (division overflow always traps in Rust). They now use wrapping_div/wrapping_rem, matching the wrapping_* treatment add/sub/mul/negate received in 0.11.14. Divide-by-zero is unaffected — still guarded upstream to return null.

[0.11.14] — 2026-06-24 — Cypher: relationship SET/REMOVE + integer-arithmetic overflow

Fixed

  • Cypher SET/REMOVE now works on a relationship variable, e.g. MATCH (a)-[r:KNOWS]->(b) SET r.weight = 0.9 and MERGE (a)-[r:KNOWS]->(b) ON CREATE SET r.since = 2020. Previously these errored Variable 'r' not bound to a node in SET/REMOVE — the handlers only resolved node bindings and ignored the bound edge. This unblocks edge-property upsert (MERGE SET r.prop), the canonical write path for primary-keyed relationship stores.

  • Cypher integer arithmetic now wraps on overflow instead of panicking under a debug build’s overflow-checks. arithmetic_add/_sub/_mul/_negate and the Duration component sums used raw operators, so e.g. RETURN 9223372036854775807 + 1 panicked in a debug build (release wrapped to -9223372036854775808 — the documented intent). They now use wrapping_* consistently, so the wrap semantics hold in every build.

[0.11.13] — 2026-06-24 — Cypher scalar-fn split, public-API gate, list/property fixes

Fixed

  • Cypher reverse() on a list now reverses its elements (e.g. reverse([1,2,3])[3,2,1]). It previously coerced the argument to a string first, so a list was JSON-stringified then character-reversed (reverse([1,2,3])']3 ,2 ,1['). A bracketed string is now treated as a list too (consistent with head/last/size); a plain string still reverses characters.

  • Cypher inline property access on a function-returned node/relationship now resolves (e.g. endNode(r).name, startNode(r).age). It previously returned null; the bound form (WITH endNode(r) AS s RETURN s.name) already worked.

  • Cypher split() now returns a native list (e.g. split('a,b,c', ',')['a','b','c']), consistent with range()/labels()/keys(). It previously returned a JSON-encoded string ('["a", "b", "c"]'); list operations (head/last/size/indexing) already accepted that form, so they are unaffected, but the value now renders as a real list.

[0.11.12] — 2026-06-24 — MCP server: screen_stargazers tool (mcp-methods 0.3.44)

Added

  • MCP server: screen_stargazers GitHub tool (via mcp-methods 0.3.42 → 0.3.44). A read-only, token-gated tool that screens a repo’s stargazers (or an explicit user list) to surface relevant/notable developers and actual adopters. Auto- registers alongside github_issues / github_api when a GitHub token is reachable, hidden otherwise. Opt out per-deployment with builtins.screen_stargazers: false in the MCP manifest. No kglite wiring — it’s a framework tool inherited from mcp-methods.

[0.11.11] — 2026-06-23 — Fix: mcp-server local-workspace code graph never built

Fixed

  • kglite-mcp-server local-workspace mode never built the code graph. In kind: local mode, the first set_root_dir activate was silently swallowed, so every graph tool (graph_overview, cypher_query, read_code_source, …) returned “No active graph”. The post-activate hook carried a stale initial_activate_seen deferral that assumed the old mcp-methods contract (a boot-time hook fire to skip); mcp-methods ≥ 0.3.x no longer fires the hook at open_local (only on activate()), so the deferral was instead eating the user’s first real activate. Removed the deferral — every hook fire is now a real activate and builds eagerly (no boot-hang risk, since mcp-methods doesn’t fire at open). Also surface a build failure via tracing::error! instead of a bare “No active graph”. Standing since 0.10.13.

[0.11.10] — 2026-06-23 — General RDF loader + graph topology functions

Added

  • kglite.load_rdf(path) — general RDF loader. Loads Turtle (.ttl), N-Triples (.nt), N-Quads (.nq), and TriG (.trig) into a fresh in-memory graph (parsed via the pure-Rust oxttl/oxrdf stack, behind a new optional rdf Cargo feature). The RDF→property-graph fold maps object literals to typed node properties (xsd:integer/double/boolean → int/float/bool, xsd:date → date, xsd:dateTime → datetime, GeoSPARQL POINT → point; a repeated predicate becomes a list), resource objects to edges, and rdf:type to the node label (first wins; extra types kept in an rdf_types property). Predicate and type IRIs are CURIE-compacted with a __ separator (foaf__knows, so they’re valid Cypher identifiers — MATCH (:foaf__Person)-[:foaf__knows]->()) from the document’s own @prefix declarations plus a well-known prefix table; each node keeps its full subject IRI in a uri property and n.id is a dense integer. In-memory backend only — for Wikidata-scale dumps use KnowledgeGraph.load_ntriples.

  • C ABI: kglite_load_rdf. The same loader is reachable from non-Rust bindings through a new rdf-gated C entry point (header guard KGLITE_FEATURE_RDF), with JSON-array config at the boundary and owned out-stats / error strings — mirroring the Python surface.

  • degree(n) / inDegree(n) / outDegree(n) Cypher functions. A node’s edge count, usable anywhere an expression is — e.g. find hubs with MATCH (n) WHERE degree(n) > 100 RETURN n, or a degree distribution with MATCH (n) WITH degree(n) AS d RETURN d, count(*). degree is both directions (a self-loop counts twice), inDegree/outDegree are incoming/outgoing. Resolves bound variables and nodes carried through WITH n AS x / collect(n) / UNWIND (consistent with id()/labels()). Previously there was no degree function and size((n)--()) isn’t supported.

  • CALL triangle_count() / CALL transitivity(). Global triangle count (number of 3-cliques) plus transitivity (global clustering coefficient = 3*triangles / connected_triples) as a single aggregate row, with optional {node_type, relationship} scoping. A native single-pass count (reusing the clustering_coefficient adjacency/intersection logic) — far faster than the equivalent Cypher pattern-join, which doesn’t scale.

  • CALL eccentricity() / CALL diameter(). Per-node eccentricity (the longest shortest path from a node to any node in its connected component) and graph diameter (the max eccentricity), with optional {node_type, relationship} scoping. Well-defined on disconnected graphs (distances ignore unreachable nodes). These are all-pairs O(V·(V+E)) computations, so they’re capped at 20k scoped nodes — narrow the scope for larger graphs.

[0.11.9] — 2026-06-21 — Timestamp + allShortestPaths + FOREACH; NDV planner selectivity

Performance

  • Faster to_list() / to_dicts() materialization. The row→dict loop now interns each column-name key once and reuses it across all rows, instead of re-creating the same Python strings per cell. ~22% faster result marshalling on a 30k×3 result (8.2 ms → 6.4 ms), more on wider/longer results. Results are byte-identical.

Changed

  • Planner: data-driven selectivity for non-indexed equality. The query planner’s start-node/pattern-reversal estimator now uses the real per-(type, property) distinct-value count (NDV) — type_count / ndv — instead of a flat ÷100 guess for equality on a non-indexed property. This fixes mis-rating low-cardinality fields (a boolean ≈ count/2, an enum ≈ count/k) as highly selective, which could pick a far worse start node. On a two-ended equality pattern ({active:true} vs {city:'Oslo'}) the planner now reverses to the rarer end — ~3.7× faster on the repro (0.67 ms → 0.18 ms). NDV is computed lazily and cached per graph version (auto-invalidated on mutation), gated to types ≤200k nodes (above which a property index — which already gives exact selectivity — is the right tool); the write hot path and indexed/id-anchored lookups are unaffected.

Added

  • FOREACH (var IN list | <update clauses>). Runs the body’s update clauses (CREATE / SET / DELETE / REMOVE / MERGE, and nested FOREACH) once per element of list, with var bound to the element — the standard Cypher mutation loop. Works standalone (FOREACH (x IN [1,2,3] | CREATE (:N {id: x}))) or per matched row (MATCH (a) FOREACH (i IN a.items | CREATE (:Item {v: i}))); list may be a literal, parameter, or property. A null list is a no-op (Neo4j semantics). The surrounding row set is unchanged (side-effect loop).

  • allShortestPaths(...) Cypher path function. Enumerates every minimal-length path between two anchored endpoints (one result row each), where shortestPath(...) returns a single path. Honours edge direction and :TYPE filters, undirected and directed; capped at 256 paths per endpoint pair to bound fan-out. Example: MATCH p = allShortestPaths((a {id:1})-[:R*..5]->(b {id:9})) RETURN nodes(p).

  • Timestamp value type — date + time-of-day at second precision. Complements the date-only DateTime. A Python datetime.datetime property now round-trips with its time component intact (a datetime.date still maps to the date-only DateTime); previously a Python date/datetime property was silently dropped to Null. The datetime() and localdatetime() Cypher constructors now return a real timestamp (a bare date parses to midnight) instead of truncating to a date. Timestamp values compare and sort chronologically (including mixed with date-only values), support + duration(...) / - duration(...) arithmetic with the seconds component applied, and duration.between(...) / date_diff(...) accept timestamp (and mixed date/timestamp) operands. Over Bolt, Timestamp maps to the wire LocalDateTime type. Persisted losslessly in .kgl (additive Value discriminant — existing files load unchanged, no format bump).

[0.11.8] — 2026-06-21 — code_tree resolution accuracy + dead-code analysis

Validated across 18 real repos (7 languages, zero crashes); the inheritance tier alone resolved 5,350 otherwise-misattributed calls on the neo4j source tree.

Added

  • code_tree cross-language HTTP edges. A client HTTP call (fetch/axios in JS/TS, requests/httpx in Python, reqwest in Rust, net/http in Go) is now linked to the server Route it targets, by normalized path: Function -[CALLS_SERVICE]-> Route -[HANDLES]-> Function. Impact analysis crosses the client/server (and language) boundary — a TS fetch("/api/users") reaches the Python FastAPI handler for /api/users. Concrete client paths match parameterized routes (/users/7/users/{id}). Detection is best-effort source matching, so edges are tagged confidence = "inferred"; the pass is a no-op on repos with no routes. See CYPHER.md → Code-graph analysis → Edge confidence.

  • code_tree Python REFERENCES_FN edges. The Python parser now records function-pointer / callback arguments (map(handler, xs), sorted(xs, key=keyfn), register(callback)) as references, matching the Rust parser. They surface as REFERENCES_FN edges, so a function only ever passed as a callback is no longer reported as dead and its real usage is reflected. Positional and keyword-argument callbacks are both captured; the builder keeps only references that resolve to a known project function.

  • code_tree inheritance-aware CALLS resolution. A self.method() call whose method is defined on an ancestor class/trait (via EXTENDS / IMPLEMENTS), not the caller’s own type, now resolves to the inherited definition — even when the same method name exists on unrelated types that the same-file / global fallbacks would otherwise pick. The resolver builds a transitive ancestor map from the parse’s type relationships and applies it as a high-precision tier (a unique inherited definition resolves immediately; diamonds narrow and defer). Conservative and build-time-flat: it only acts on implicit self-calls the direct-owner filter left unresolved. Surfaced as resolved_via_inheritance in the code_tree_stats harness.

  • CALL dead_code(...) Cypher procedure. Graph-native dead-code detection over a code_tree graph: reports Function nodes with no inbound use edge (CALLS / REFERENCES_FN / HANDLES / IMPLEMENTED_BY / DECORATES), which avoids the false positives a naive “no inbound CALLS” query hits (callbacks passed by value, route handlers, decorated entry points). Test functions, dunder methods and main are excluded as implicit entry points; include_tests keeps tests and exclude_public drops pub/exported visibility. See CYPHER.md → “Code-graph analysis” for this plus copy-paste recipe queries for complexity hotspots, blast radius, god functions and call-recursion cycles (all expressible directly over the metrics/edges already captured at parse time).

Fixed

  • code_tree — calls inside anonymous functions are now captured. Every parser that listed its anonymous-function node kind in the call-walk skip set silently dropped calls made inside lambdas / closures / arrow callbacks (e.g. Python sorted(xs, key=lambda x: helper(x)), JS/TS xs.map(x => helper(x)), Java/C# lambdas, Go func literals, C++ lambdas). An anonymous function has no graph node of its own, so its call sites now attribute to the enclosing function — matching how Rust closures were already handled. Complexity metrics are unchanged. Fixed across python / typescript / go / java / csharp / cpp.

[0.11.7] — 2026-06-21 — code_tree extraction robustness (C++ + TypeScript/TSX)

Fixed

  • code_tree C/C++ extraction. Three robustness fixes for C++ codebases:

    • Export-visibility macros in the class/struct keyword slot (class KUZU_API Foo { }) no longer desync the parse — the macro is neutralized before parsing so the class, its bases, and its members are all extracted. Previously the whole class (and often everything after it) was dropped.

    • .h headers in a C++ codebase are now parsed by the C++ parser. .h is C-by-default, but many engines (kuzu, LevelDB, …) use .h for C++ headers; the C grammar silently dropped every class / namespace / template in them. (.c files and pure-C repos are unaffected.)

    • Top-level struct Foo { }; / enum E { }; in pure-C files are now extracted (the C path previously only handled them inside a declaration wrapper, dropping bare top-level definitions).

    • C++ function names are now resolved for operator overloads (operator(), operator=, operator[], …), out-of-line definitions under nested namespaces (Ret a::b::Ctx::method()), and explicit template specializations (template<> bool fits<T>(…)) — all previously unknown. All-caps method names (MINUS, OK) are no longer mistaken for macro decorators in the name position.

    Measured on the kuzu source tree: extracted classes 349 → 2679, unknown function rate 88% → 1.2%, EXTENDS edges 206 → 1272. (duckdb: unknown 1.1% → 0.3%.)

  • code_tree TypeScript .tsx parsing. .tsx files are now parsed with the JSX-aware grammar. Previously they used the plain TypeScript grammar, whose inability to parse JSX desynced every component body into error nodes — so export default function App() { return <div/> } and similar lost their names (extracted as unknown). .ts files keep the TypeScript grammar (TSX would misread <T> type assertions/generics as JSX).

[0.11.6] — 2026-06-21 — interruptible Cypher (Ctrl-C) + free-threading readiness

Added

  • Cypher queries are interruptible with Ctrl-C. A long-running cypher() read — large scans / cross-products and CALL graph algorithms (pagerank, betweenness, louvain, …) — plus Session.execute mutations can now be stopped with Ctrl-C, which raises KeyboardInterrupt instead of blocking until the deadline. A scoped SIGINT handler (installed only while a query runs, then restored) flips a cooperative-cancel flag the engine polls at the same checkpoints as the query deadline. Session.execute mutations are atomic when cancelled (they run on a copy-on-write working copy that’s discarded on abort — the graph is fully mutated or unchanged, never partial). POSIX only; on other platforms the deadline still bounds queries. Targets the interactive single-query case (notebook / REPL). Live KnowledgeGraph in-place mutations and Transaction mutations remain deadline-bounded (not Ctrl-C-cancellable) — they mutate in place / don’t reliably roll back mid-run, so interrupting them could leave partial state; use Session.execute for cancellable + atomic mutations.

Internal

  • New kglite::api::session::ExecuteOptions.cancel (Option<&AtomicBool>) — the engine-agnostic cancellation primitive bindings flip from their own signal model; threaded through the executor and pattern matcher. Servers pass None (unchanged behaviour). New KgError::Cancelled / KgErrorCode::Cancelled (HTTP 499, Neo.ClientError.Transaction.Terminated). The graph algorithms now take an algorithms::Interrupt (deadline + cancel bundle) in place of a bare deadline: Option<Instant>, polled at their iteration/scan checkpoints so CALL procedures are interruptible too.

  • GIL-release + error-mapping + cancellation consolidated into one EnterKg::enter_kg helper in the Python wrapper (replaces scattered py.detach(...).map_err(kg_to_pyerr) call sites on the Cypher paths).

  • Free-threading (no-GIL / 3.13t) readiness. The kglite extension module now declares gil_used = false, and the shareable read pyclasses (Session, FrozenGraph, the Cypher ResultView) are #[pyclass(frozen)] — immutable + Sync, removing the runtime borrow-flag and matching how the concurrent Session path already shares state. No API change.

[0.11.5] — 2026-06-20 — kglite::api hard-seal + dataset surface curation + Cypher plan cache

Changed

  • kglite::graph is now pub(crate) — the engine is reachable only through the curated kglite::api facade (roadmap Piece 4 completed the 253→0 below-api-reach sweep; the api surface was also reorganized into one-home-per-concern clusters). The Python wheel, the bolt/mcp/C servers, and the Cypher / kglite::api surfaces are unaffected. Potentially breaking only for external Rust consumers of the kglite engine crate that reached kglite::graph::* directly — move those to kglite::api::*. A CI grep plus the pub(crate) compile boundary keep wrappers honest.

  • kglite::api::datasets slimmed ~65 → 38 items. The dataset module is now sealed behind api::datasets (single, gate-enforced path, the same treatment as graph); the per-function *_blocking twins collapsed to one kglite::api::datasets::block_on bridge; the dataset surface was curated to the items bindings actually consume; and ~530 lines of dead code the seal unmasked were removed. The surface every binding actually uses is unchanged.

  • Single mode-aware durable save dispatch (kglite::api::io::save_graph_with) now backs the wheel, the MCP server, and the C ABI, replacing three copies of the disk-vs-in-memory / columnar / fsync logic. Fixes the C kglite_save_graph_durable, which previously bypassed disk-mode dispatch and columnar consolidation (and whose fsync docs were inverted). Saves are byte-identical and remain durable (fsync) by default.

Added

  • Mapped / disk storage mode reaches every binding. Creating a graph in a specific backend (memory / mapped / disk) is now available across all wrappers through one shared core builder (kglite::api::storage::StorageMode + new_dir_graph_in_mode), so the mode vocabulary can’t drift:

    • C ABI: new kglite_graph_new_in_mode(mode, path, …) — non-Rust bindings can create mapped/disk graphs, not just in-memory ones.

    • bolt + mcp servers: new --storage memory|mapped|disk flag. An existing --graph (a .kgl file or disk-graph directory) is loaded in its saved mode (auto-detected); a --graph path that does not exist errors by default (typo guard) and is created fresh only when --storage is given (opt-in build-and-serve).

Performance

  • Cypher plan cache. A param-less, codec-free query re-run against an unchanged graph now reuses its fully-optimized plan, skipping parse + schema-validate + optimize (the parse cache already covered parse; the optimizer was the bigger uncached cost). Keyed on (graph_id, version) so it is invalidated by any mutation and never leaks across graphs; parameter binding still happens fresh at execute time. Biggest win for repeated queries against a stable/served graph (bolt/mcp). To make the key sound, DirGraph version now bumps on every mutation path (Cypher writes via execute_mut, bulk ingest, and make_dir_graph_mut), not only on handle acquisition.

[0.11.4] — 2026-06-19 — C ABI completeness + kglite::api soft-seal foundation

Added

  • C ABI surface completed (kglite-c): 32 → 45 extern "C" functions. The C ABI (the entry point for every non-Rust binding — Go/cgo, JS/napi, JVM/JNI, …) now covers the full lifecycle, not just query. New entry points:

    • kglite_graph_new — create an empty in-memory graph (previously the C ABI could only load a graph from a file).

    • kglite_session_execute_read_batch / kglite_session_execute_mut_batch — run a batch of queries against one snapshot / inside one transaction (the mut batch is atomic).

    • kglite_session_execute_read_opts — read with a timeout + max-rows guard (max-rows errors when exceeded, it does not truncate).

    • kglite_create_edges_batch — DataFrame-free bulk edge ingest by stable id + type (wraps the new core add_edges_from_specs).

    • kglite_graphgen_to_dir — synthetic-graph generator.

    • kglite_blueprint_build — declarative graph construction from a blueprint.

    • kglite_save_graph_durable (fsync) + kglite_graph_to_bytes / kglite_graph_from_bytes / kglite_free_bytes — durable save + in-memory bytes round-trip.

    • kglite_compute_schema_json — schema introspection at the ABI boundary.

    • kglite_memory_stats — backed by a tracking global allocator.

  • Core: add_edges_from_specs — DataFrame-free bulk edge ingest (exposed via kglite::api::mutation, reusing the same engine as the Python add_connections DataFrame path). The one genuine library gap that the C ABI needed; available to every Rust-side binding too.

  • kglite::api surface expanded (api-sealing roadmap Piece 1): GraphRead (the canonical read trait), OperationReport / OperationReports (structured mutation reports), resolve_code_entity + CODE_TYPES (code-tree graph helpers) are now reachable through the curated kglite::api namespace for downstream and future bindings. Zero-cost pub use re-exports — no behaviour or perf change.

Changed

  • C ABI result rows are now natural untagged JSON. A scalar comes back as {"n": 2} instead of the enum-tagged {"n": {"Int64": 2}}. The shared kglite_value_to_json converter was lifted into kglite::api::param so every binding (and the MCP server) emits the same shape.

  • kglite_abi_version now derives from the crate version (was hard-coded and stale at 0.10.5).

Fixed

  • JSON array / object query parameters were stringified instead of converted to Value::List / Value::Map. A live data-corruption bug: UNWIND $rows AS r CREATE {id: r.id} wrote null ids (an unmatchable graph), so subsequent SET / DELETE silently no-oped. Same class as the 0.11.2 PyO3 fix, but in the shared kglite::param converter (json_value_to_kglite_value) used by the C ABI, the MCP server, and every future binding — the PyO3 path was already fixed in 0.11.2; this fixes everyone else.

Packaging

  • aarch64 Linux (gnu) wheel now builds on manylinux_2_28 instead of the ancient manylinux2014 cross image. The 2014 cross gcc (4.8.5) could not cross-build the wheel’s C deps for aarch64 — it failed on ring’s .S asm (fixed in 0.11.1) and then on libmimalloc-sys’s -Wno-error=date-time (gcc <4.9 has no -Wdate-time). Building on gcc 12 clears the whole class. Trade-off: the gnu-aarch64 wheel’s glibc floor rises 2.17 → 2.28 (RHEL 8 / Ubuntu 18.10+); the musllinux aarch64 and x86_64 wheels are unchanged.

[0.11.3] — 2026-06-18 — thread-safe Session handle (shared reads + serialized writes)

Added

  • KnowledgeGraph.session()Session — thread-safe, shareable graph handle. A live KnowledgeGraph is single-owner: sharing one across a thread pool and mutating it concurrently trips a borrow guard (the failure mode that forces server consumers to wrap every call in a global lock). Session is the fix — it wraps the engine’s Mutex<Arc<DirGraph>> and exposes only &self methods, so it can be shared across threads: concurrent cypher() reads take a momentary snapshot and run lock-free, while execute() writes serialise behind a writer lock held across begin mutate commit (copy-on-write working copy + atomic swap). The writer lock makes concurrent writes compose — each execute() begins from the prior writer’s committed state, so increments and read-modify-write updates don’t clobber each other (the lost-update failure mode that forces naive shared-handle consumers to wrap every call in a global lock). snapshot() hands out a stable FrozenGraph for held multi-query views; version() exposes the monotonic commit counter. Build or load with a KnowledgeGraph, then .session() and serve every thread through the Session.

  • Session.cursor() — per-thread fluent query handle. Returns a KnowledgeGraph bound to a snapshot of the session’s current state with a fresh cursor. Where snapshot() gives a read-only FrozenGraph (just cypher()), cursor() gives the full fluent surface (select/where/sort/traverse/to_df/…) as an independent single-owner handle, so N threads can each take a cursor off one shared Session and run fluent chains in parallel, lock-free. Mutations on a cursor are copy-on-write isolated (they don’t write back to the session). Part of the KnowledgeGraph internal decomposition (storage / cursor / lifecycle now separated into CursorState + GraphLifecycle; see roadmap.md).

  • kglite.open_session(path) — one-call shared handle. Loads a saved graph directly as a thread-safe Session (equivalent to kglite.load(path).session()), so the concurrent-serving path is as easy to reach as the single-owner one. Paired with a clearer single-owner error: when a KnowledgeGraph is shared+mutated across threads, the RuntimeError now names the fix (session() / freeze() / cursor()) instead of only suggesting copy()/a lock.

Fixed

  • Core Session::commit TOCTOU race (concurrent committers could lose a commit or move the version backwards). The optimistic-concurrency version check read the graph version under one lock acquisition and then swapped the graph under a separate one, so two threads committing at once could both pass the check and both swap — losing one commit, and (because the new version was derived from the transaction’s possibly-stale base) leaving the monotonic version counter non-monotonic. The check and swap now happen under a single lock guard, and the version bumps from the current value, so commits are atomic and the version is monotonic even in last-writer-wins mode. Affects the bolt-server (which drives the core Session from many connection threads with no serializing lock); the Python Session was unaffected (its writer lock already serialized committers). Found by new true-parallel Rust concurrency tests + an opt-in Python stress harness (-m stress).

[0.11.2] — 2026-06-18 — bundled synthetic-graph generator + public benchmark

Added

  • kglite.graphgen() — bundled synthetic-graph generator. Generate a seed-deterministic org/social knowledge graph (Person/Company/Project/Skill/ City + 7 edge types) in one call — for demos, tests, and benchmarks, with no extra dependency or Rust toolchain (it’s compiled into the wheel, like code_tree). kglite.graphgen("medium") returns a ready-to-query KnowledgeGraph; kglite.graphgen("huge", out=DIR) streams one CSV per type

    • a manifest.json in bounded memory (millions of nodes at flat RAM), so any engine that reads the same bytes gets the same graph. Scales tinyxhuge (or an exact persons=), degree_dist='zipf' for realistic high-degree hubs. The generator moved from the standalone benchmarks/graphgen crate into crates/kglite/src/graphgen/ (core) and is re-exported from kglite::api for other bindings. Nodes now also carry geometry (City latitude/longitude) and a per-Person embedding vector (embedding_dim in the manifest), so the generated graph exercises geospatial and vector-search workloads out of the box.

Fixed

  • Cypher UNWIND $list AS i MATCH (n {id:i}) silently returned no rows once the list exceeded ~64 elements. A query-local equality index — meant for cross-MATCH joins on stored properties — was also being built over the id node-identity virtual (which resolves to identity, not a stored column), yielding an empty map so every probe missed and the bare point-MATCH dropped all rows above the index’s activation threshold. The index now skips the id/title virtuals (identity has its own fast seek path), so batched id-lookups via UNWIND are correct at any list size. Found via the new cross-engine benchmark parity check.

  • Subgraph-scoped community detection now works on mapped/disk graphs. CALL louvain/leiden/label_propagation({node_type, relationship}) previously errored on disk/mapped storage (“scoping is in-memory-only”), even though the scoped subgraph is bounded and connected_components scoping already worked there. Scoped runs now route through the materialised (storage-agnostic) adjacency path on every mode — identical results across memory/mapped/disk — while unscoped whole-graph runs keep the bounded-memory streaming path.

  • Python dict and list-of-dict Cypher params now marshal to native maps and lists instead of null. The PyO3 param converter had no dict branch (a dict param became Value::Null, so $m.prop and UNWIND $rows AS r r.key returned null) and flattened lists into a JSON string. The common batch shape UNWIND $rows AS r CREATE (:T {id: r.id, …}) therefore wrote nodes with null ids — unmatchable, so a following SET/DELETE silently no-oped and the in-memory/mapped graph diverged from disk (phantom rows, a duplicate-id warning). Params now convert recursively to Value::Map / Value::List; vector_score/UNWIND/IN over a list are unaffected (extract_float_list already accepts the native list). Found via the cross-storage-mode mutation-parity benchmark; storage modes are now byte-identical on the mutation suite.

Benchmarks / docs

  • A linkable, reproducible benchmark table — BENCHMARKS.md. Wall-to-wall time per category — 26 sub-benchmarks across 9 categories: scan & lookup / filter & aggregate / traversal / pathfinding / multi-type queries / graph algorithms / community detection / mutations, plus the KG-specialized vector search and geospatial tiers — for kglite vs Kùzu, Neo4j, NetworkX, rustworkx, igraph, and DuckDB on one shared synthetic graph every engine loads from identical bytes. A capability matrix (“can it do your workload?”) makes the breadth picture instant — kglite is the only engine covering all 10 categories (and the only one with vector search), while a shows a real gap (DuckDB has no pathfinding/WCC/community/vector; the algorithm libraries have no query language for the multi-type joins). A partial category is percentile-estimated so a within-category skip can’t flatter a total, and a sub-bench slower than 10 s is marked and excluded (so one ~11 s pure-Python Louvain can’t dominate a sum). Regenerate with python benchmarks/benchmark.py — it stages the dataset with the bundled kglite.graphgen (no Rust needed) and runs every installed backend; a cross-storage-mode result-parity check (tests/test_benchmark_parity.py) gates every kglite mode to identical results. The public graphsuite comparison is tracked in the repo; one-off dev scripts moved to tests/benchmarks/internal/ (the perf gates stay in tests/benchmarks/).

  • Opt-in server backends for the comparison. Heavy, externally-provisioned backends are requestable via --libs and skip cleanly when their prerequisite is absent: Neo4j in two deploy flavors — an auto-managed native server (neo4j-native, higher-performance) and a neo4j:5-community container (neo4j-docker) — plus kglite served over Bolt from a container (kglite-bolt-docker), backed by a new crates/kglite-bolt-server/Dockerfile so the Bolt server is one docker build away.

[0.11.1] — 2026-06-17 — HNSW in Cypher, faster index build, embedding-provenance papercuts

Follow-up to 0.11.0, driven by the mcp-servers operator’s independent validation (exact-search parity + 1.83× speedup, near-linear concurrent-read scaling, HNSW 0.997 recall @ 5.6× on a real 46k×1024 store — 15/16 reported painpoints verified resolved). This closes the remaining items.

Added

  • Cypher vector_score() / text_score() top-k now auto-uses the HNSW index. A whole-corpus RETURN vector_score(n, prop, q) AS s ORDER BY s DESC LIMIT k (and the text_score form) dispatches through a built index instead of scoring every row — so agent/MCP semantic search done via Cypher benefits too. Opt-in (only fires when build_vector_index was called), re-scores survivors with the exact Scorer (identical score scale), and falls back to the exact scan for any shape it can’t faithfully serve (ASC order, mixed/unbound types, duplicate node bindings, Poincaré, dimension mismatch, or a selective WHERE whose survivors underfill the limit). Independently validated at recall@10 0.994 with exact score parity on a real 46k×1024 store. The end-to-end Cypher speedup is more modest than the fluent API’s (~2.3× at 46k vs ~5.6×): Cypher’s fixed per-query cost (parse + plan + projection) is a larger share of the total at this corpus size, so the index saving shows through less — the gap widens on larger corpora where the scan dominates.

Changed

  • embedding_info() / list_embeddings() report the effective metric. A store created by embed_texts (which sets no explicit metric) used to report metric: None even though search applies cosine. Both methods now report the metric search actually uses — the explicit one if set, else 'cosine' — and never None for an existing store. Pure reporting; no stored-data or format change.

  • .kgle export/import carries embedding provenance (format v2). export_embeddings / import_embeddings now round-trip each store’s metric

    • embedder model_id + per-node text hashes, so a rebuild-from-.kgle pipeline keeps provenance and embed_texts(mode='changed') re-embeds only changed text instead of everything. Older v1 .kgle files still import (they carry no provenance — mode='changed' treats every node as new).

Performance

  • HNSW index build is ~5–6× faster (concurrent). Build was single-threaded (~43s on a 46k×1024 store — the new engine’s one rough edge). Inserts now run on rayon: the vectors are immutable during a build, so each insert reads the growing graph through per-node RwLock read locks and writes only its own + its neighbours’ link lists (one lock at a time → deadlock-free). Measured on 10 cores: 10k×128 1.9s→0.3s (6.3×), 50k×128 16s→2.8s (5.7×), 100k×256 77s→14s (5.4×). Recall and query latency are unchanged. The seeded level assignment stays deterministic; the link graph now differs run-to-run (recall is statistically equivalent — the index is a rebuildable cache).

Documentation

  • semantic-search guide: the Cypher index path, .kgle provenance, and a “benchmark HNSW on real embeddings, not random vectors” note (random high-dim vectors have no neighbourhood structure → any ANN looks bad; ~0.99 on real data). Concurrency guide: a freeze() fan-out scaling note (near-linear for CPU-bound queries, sub-linear for bandwidth-bound full scans).

CI

  • Wheel builds now run in parallel with CI (publish still gated on CI passing) — cuts the release pipeline wall-clock roughly in half — and the aarch64-unknown-linux-gnu (manylinux2014) wheel build is fixed (ring’s ARM asm needs __ARM_ARCH defined in that cross image).

[0.11.0] — 2026-06-17 — concurrency snapshots, durable save, embedding provenance, edge upsert, portable wheels

Cut as a minor (0.11.0), not a patch: this release adds new public API (freeze()/FrozenGraph, to_bytes()/from_bytes(), replace_connections(), embedding_info()/embedding_dim(), embed_texts(mode=…), copy_embeddings_from(), search_text/vector_search returning=, public build_code_tree), changes a default (save() is now atomic + fsync), and makes a scoped on-disk format break (embeddings section — core-data-version 3). See Migration below.

Folds three operator-feedback rounds (2026-06-17): graph-engine limitations & Cypher footguns (edge upsert, algorithm-config robustness, portable wheels); a concurrency/durability/embedding roadmap (freeze snapshot, durable save, embedding provenance + incremental re-embed); and the way-forward shortlist (search-hit projection, cross-graph vector carry, public code-tree API, typed load error).

Migration (0.10.x → 0.11.0)

  • save() is atomic + fsync by default. No code change needed; you get crash-safety for free. If you do high-frequency saves where durability isn’t required, pass save(path, fsync=False) (still atomic, just no flush).

  • .kgl with embeddings from an older binary won’t load (embeddings section format changed — core-data-version 3). The graph’s nodes/edges/columns are unaffected; only the (rebuildable) vector cache broke. Action: reload the graph, re-run embed_texts() / add_embeddings(), and save() again — or use new.copy_embeddings_from(old) once both are on 0.11.0. A .kgl without embeddings loads unchanged.

  • load() / from_bytes() raise kglite.FileFormatError (not IOError) on a corrupt file. Code with except IOError: around a load should catch kglite.FileError / kglite.FileFormatError (both subclass kglite.KgError).

  • Sharing one graph across threads raises a clear RuntimeError instead of panicking. Give each worker its own copy(), serialize access, or share a read-only freeze() snapshot for concurrent reads.

  • MCP/binary consumers: rebuild/republish kglite-mcp-server against 0.11.0 (the format bump means an old binary can’t read a 0.11.0 .kgl).

Concurrency / durability / embeddings, at a glance:

  • Concurrency — a clear cross-thread error (no more borrow panics) and freeze()FrozenGraph, an immutable O(1) snapshot with lock-free concurrent reads (the thread-safety item, addressed via the “build → freeze → share → swap” model rather than a global lock).

  • Durability — atomic + fsync save (no torn .kgl), to_bytes() / from_bytes(), typed FileFormatError on corrupt load.

  • Embeddings — model + text-hash provenance, embed_texts(mode='changed') incremental re-embedding, embedding_info(), copy_embeddings_from(), search_text/vector_search returning= projection.

Added

  • build_vector_index() — opt-in HNSW index for scalable vector search. Brute-force vector search is exact but O(n·d) per query; on large stores that doesn’t scale. g.build_vector_index(node_type, text_column, m=…, ef_construction=…, ef_search=…, metric=…) builds a hand-rolled HNSW approximate-nearest-neighbour index (cosine / dot-product / Euclidean — Poincaré stays exact). Opt-in like create_index: once built, vector_search / search_text auto-use it for whole-corpus queries on large stores (≥256 candidates); pass exact=True to force the exact scan. Heavily-filtered selections fall back to exact automatically (correctness over speed when a filter is selective). The index is dropped automatically whenever the store’s vectors change (add_embeddings / embed_texts) or slots are remapped (vacuum) — rebuild it afterward. Companion methods drop_vector_index() and has_vector_index(). The index persists in the .kgl (and to_bytes()): it rides in a dedicated, self-describing, skippable section (own magic + format version), so it’s restored on load with byte-identical search results, the on-disk index format can evolve without a core-data-version bump, and an unrecognised/corrupt index is silently dropped (it’s a rebuildable cache, never a correctness dependency). (The Cypher vector_score() / text_score() path still uses the exact scan — a follow-up.)

  • Public code_tree build API. Code-graph building now has a stable public entry point — top-level kglite.build_code_tree(path, …) and kglite.code_tree.build — and kglite._kglite_code_tree is documented as an internal implementation detail (consumers were importing from the underscore-prefixed module directly). The top-level from_bytes, build_code_tree, and FrozenGraph are now advertised in kglite.__all__ (operator note #3).

  • copy_embeddings_from(other) — one-call, id-keyed cross-graph vector carry. The dominant embedding workflow rebuilds a fresh graph from a source of truth on each load; embed_texts(mode='changed') can’t help an empty fresh graph, so vectors had to be hand-carried (embeddings() snapshot → add_embeddings()embed_texts). Now: build the new graph, then new.copy_embeddings_from(old) — vectors land on the nodes that share an id, carrying dimension, metric, model id, and per-node text hashes (so a following embed_texts(mode='changed') re-embeds only genuinely new/changed text). Implemented in core (DirGraph::copy_embeddings_from), so every binding reaches it (operator embedding note #2).

  • search_text / vector_search gain a returning=[...] field projection. By default a hit already carries id, title, type, score, and every node property (read live — identical before/after save/reload, so no follow-up MATCH WHERE id IN […] hydrate is needed). returning=['title'] trims a hit to id + score + the named fields, for ranking-heavy or wide-node workloads. Documents the default hit contract (operator note: search hits + harvest N1).

  • embed_texts(mode='changed') + per-node text-hash + model provenance. embed_texts now records, per node, a content hash of the embedded text and (when the embedder exposes a model_id/model_name) the model identity. mode='changed' re-embeds exactly the nodes whose text changed since the last pass (or are missing), instead of all (mode='all', = replace=True) or only the missing ones (mode='missing', the default, = replace=False). This subsumes the per-node text_hash machinery consumers were hand-rolling for the rebuild-from-source-cache workflow (operator embedding note #1). The new Embedder::model_id() trait method defaults to None, so any bring-your-own embedder works unchanged; a Python embedder can opt in with a model_id / model_name attribute. The result dict gains reembedded_changed.

  • embedding_info(node_type, text_column) — provenance for an embedding store: {dimension, count, model, metric, hashed}. Detect a model swap or a partially-hashed store without a sidecar (operator embedding note #2).

  • KnowledgeGraph.freeze()FrozenGraph — an immutable, concurrently- readable snapshot. Sharing a live KnowledgeGraph across threads is unsafe (single-owner; a mutation mid-read trips the borrow guard). freeze() returns a read-only view that shares the graph’s data via an O(1) Arc clone — no deep copy — and has no mutating method, so any number of threads can run FrozenGraph.cypher() against the same snapshot in parallel, lock-free (the GIL is released during execution). The snapshot is stable under copy-on-write: mutating the source graph afterwards leaves the frozen view on the original data. This is the “build → freeze → share → swap” model the operator’s concurrency note recommends (Tier 2). FrozenGraph.cypher is read-only — CREATE/SET/DELETE/REMOVE/MERGE raise; semantic search works via text_score()/vector_score() in the query.

  • KnowledgeGraph.to_bytes() + kglite.from_bytes(data) — serialise an in-memory graph to a .kgl byte buffer and load it back, without going through a filesystem path. Lets a caller own the write (object storage, a pipe, a checksum, a custom atomic-write) instead of being limited to save(path). from_bytes raises a classifiable error on a corrupt/truncated or non-.kgl buffer (operator durability note §4). Default/mapped modes only (a disk graph is a directory, not a byte stream). The Rust api surface gains the backing serializers — kglite::api::{write_kgl, write_kgl_with, write_kgl_to, load_kgl_bytes} — so non-Python bindings get the same atomic-write + byte round-trip.

  • replace_connections(...) — an atomic edge upsert. For every source node present in the input (data DataFrame or query result), its existing edges of that connection type are pruned, then the supplied edges are added — in one call. Edges from sources not in the input, and edges of other types from the same sources, are untouched. Use it to re-sync a derived edge set idempotently (“the current MENTIONS of exactly these documents is this list”) without the race-prone manual DELETE-then-re-add. Accepts every argument add_connections does (including query mode and extra_properties); validates the id columns before pruning, so a malformed input leaves the graph intact (operator B3). Implemented in core (maintain::replace_connections), so every binding reaches it.

  • embedding_dim(node_type, text_column) — returns the vector dimension of an embedding store (or None). A cheap, direct way to detect an embedder/ model change without iterating list_embeddings (operator B4).

Changed

  • .kgl embedding section format bumped (core-data-version 3). The embedding store now persists per-vector model_id + per-node text_hashes (positional bincode fields), so a .kgl with embeddings saved by an older version can’t be loaded by this binary — it’s rejected with a clear “reload, re-embed, save again” message. The graph’s nodes/edges/columns are unaffected and a .kgl without embeddings loads unchanged; only the rebuildable vector cache breaks. This is a deliberate, contained break (embeddings are a rebuildable cache), not a whole-graph format break.

  • save() is now atomic and durable by default. The .kgl is written to a sibling temp file and atomically renamed over the target, so a crash mid-save can never leave a torn/truncated file — a reader always sees either the old file or the complete new one. With the new fsync=True default, the file and its parent directory are flushed to physical storage before returning (durable against an OS/power crash); pass save(path, fsync=False) to skip the flush for speed (still atomic, just not guaranteed on-disk at return). The temp name is unique per process so two writers to one path can’t corrupt each other’s in-flight write. Removes the temp-file + os.replace + dir-fsync dance consumers were hand-rolling (operator durability note §4). Every existing caller (including to_subgraph().save() and code-graph builds) gets this for free. Cost: the atomic temp+rename adds a fixed ~one-file-create+rename per save (negligible on real graphs; the fsync flush is the larger, optional cost — fsync=False for the hot-loop case). Serialization throughput itself is unchanged.

  • embed_texts(replace=False) now rejects a model/store dimension mismatch instead of silently mixing dimensions (which corrupts similarity search). On a model swap, re-embed the whole column with replace=True (deterministic — rebuilds the store at the new dimension) or remove_embeddings first. (B4/B5; add_embeddings already rejected mismatches.)

  • Graph-algorithm procedures: relationship and connection_types are now interchangeable, and unknown config keys are rejected. The edge-scope key was inconsistent (centrality/community read connection_types; components/ k-core read relationship); either term now works on any procedure. A genuinely-unknown key (CALL pagerank({…, bogus:'x'})) now errors with a did-you-mean instead of silently no-op’ing (operator feedback A2/A2b). The where predicate-scope (added 0.10.25) was already working — it was the key name, not the feature, that tripped callers up.

Performance

  • Cosine vector search ~1.25× faster (≈21% on a 50k×128 top-10 scan). Each EmbeddingStore now caches a per-vector L2 norm alongside the vectors, so cosine scoring no longer recomputes the stored vector’s norm (plus a sqrt) on every query — the per-candidate work collapses from “dot + two norm sweeps

    • sqrt” to a single dot product and one divide, with the query norm computed once per query. Shared by both the fluent vector_search path and the Cypher vector_score() / text_score() scalar (so the fused top-K semantic-search path benefits too), and by the all-pairs link_similar traversal. Results are unchanged (exact within floating-point epsilon). The cache is derived from the vectors and not persisted — it’s rebuilt on load, so the .kgl format and on-disk bytes are identical. Dot-product / Euclidean / Poincaré are unaffected (they need the raw magnitudes and fall through to the existing kernels).

  • HNSW vector index scales whole-corpus search sub-linearly (see build_vector_index under Added). Stored-vector-query benchmark (cosine, top-10, exact vs indexed): 10k×128 → 4.1× faster (recall@10 0.99); 50k×128 → 5.6× (0.92); 100k×256 → 8.8× (0.72 at default ef_search, raise it for higher recall). The speedup widens with corpus size; the index is built once and persists in the .kgl. Repro: python tests/benchmarks/bench_vector_index.py.

Fixed

  • load() / from_bytes() raise a classifiable FileFormatError on a corrupt file/buffer, not a generic IOError. A caller can now reliably distinguish “this .kgl is corrupt → rebuild from source” (FileFormatError) from “it isn’t there” (FileError) or a genuine IO fault (FileIoError), instead of a broad except IOError. (Both already detected corruption; the type is now typed end-to-end — operator note #4.)

  • A cross-thread borrow conflict now raises a clear, actionable error instead of panicking. Sharing one KnowledgeGraph across threads while a thread mutates it (add_nodes / embed_texts / a CREATE query / save) trips PyO3’s RefCell guard; the hand-written read paths previously panicked (borrow()), and mutations surfaced a cryptic Already borrowed. The read paths now raise a RuntimeError explaining the single-owner contract and pointing to the fix (give each worker its own copy() — cheap — or serialize access; or share a read-only freeze() snapshot — see Added). This is the operator’s concurrency note Tier 1; the freeze() snapshot (Tier 2) ships in the same release.

  • create_index now reports created honestly. Re-creating an existing index is still idempotent (no error), but the returned dict now carries created=false when an index for (node_type, property) already existed and created=true only when this call made a new one — previously it was always true, so callers couldn’t tell “I made it” from “it was already there” (operator B6).

  • A WHERE predicate on a property absent from the matched label now warns (non-fatal, stderr — same channel as the unknown-label/relationship warnings) instead of silently filtering out every row. MATCH (f:Function) WHERE f.nonexistent = false previously returned No results indistinguishably from a genuine empty match (null = false → false); now it emits “WHERE references property ‘nonexistent’ which no Function node has …” with a did-you-mean. A warning, not an error, so legitimately-sparse (sometimes-null) properties — which are in the type’s metadata — never trip it (operator A1b).

  • Code-graph: is_external is now emitted on Function (= false), not just Class/File. Previously f.is_external was null on functions, so the documented library-only filter WHERE n.is_external = false silently matched zero rows on Function (null = false → false). Now uniform across labels.

Documentation

  • Identifier charset & escaping is now documented in CYPHER.md (operator B2). Hyphenated/dotted/spaced relationship types and labels (supports-claim, refines-idea, Legal Document) have always worked; they just need backtick-quoting inside a Cypher query ([r:supports-claim]). The string-typed Python APIs (add_connections/replace_connections/add_nodes/ create_index) accept arbitrary characters directly — no escaping. The bare- identifier rule ([A-Za-z_][A-Za-z0-9_]*, else backtick) is now spelled out.

[0.10.28] — 2026-06-16 — bring-your-own embedder (library + model); [embed] extra removed

Changed

  • MCP server embedder is now library-based and bring-your-own. Replaced the extensions.embedder.backend field with library (the engine you name): sentence-transformers / fastembed (Python, wheel-hosted) or fastembed-rs (Rust, cargo --features fastembed), plus a factory: module:attr escape for any custom embedder. The Rust server hands the whole config to the Python side (kglite._mcp_embed), so adding a library never touches Rust. This unlocks bge-m3 on the pip server via library: sentence-transformers — fastembed-py (the previous hardwired choice) doesn’t have bge-m3; fastembed-rs and sentence-transformers do. Unknown library / not-installed / host-mismatch all produce actionable boot errors. (Supersedes the backend: python shape added hours earlier in 0.10.27.)

Removed

  • The kglite[embed] extra. Embedding is bring-your-own: pip install kglite pins no embedding library; install whichever you name (pip install fastembed / sentence-transformers), matching the engine’s g.set_embedder(...) philosophy. pip install 'kglite[embed]' no longer resolves.

[0.10.27] — 2026-06-16 — value_codecs (safe literal conversions); cypher_preprocessor removed

Added

  • extensions.value_codecs — position-scoped, bidirectional literal codecs. An operator declares a transform (prefix / map / regex) bound to a stored property; the engine decodes query-side literals in that property’s position ({id:'Q42'}, WHERE n.id = 'Q42', n.id IN [...], CREATE/SET) and encodes direct result-column projections back (RETURN n.id'Q42'). Applied after parsing by a new cypher::value_codec pass, reached via ExecuteOptions::value_codecs and configured from the MCP manifest. Five safety invariants: position-scoped (a 'Q42' in CONTAINS / a different property / a RETURN alias is untouched), full-match (never query-text substitution), decode-is-total (any non-match leaves the literal as-is, so the 0.10.10 over-eager coercion stays dead), bidirectional, and typed (decode lands a real Value, hitting the same index path as a native literal). No trust gate — a Tier-1 codec is pure declarative data transformation. New kglite::api::cypher::{ValueCodec, CodecKind, StoredType}. See docs/python/examples/manifest_value_codecs.md.

Removed

  • extensions.cypher_preprocessor (both rules: and command:) — removed. Introduced in 0.10.26, it rewrote the raw query text before parsing — blind substitution that could mangle string literals, RETURN aliases, or anything that merely contained the pattern (re-creating the over-eager-match failure 0.10.10 deliberately killed). value_codecs does the conversion at a safe, post-parse, position-scoped site instead. No deprecation window (0.10.26 had no released consumers). trust.allow_query_preprocessor is now unused by kglite.

[0.10.26] — 2026-06-16 — MCP server bundled into the wheel + native query preprocessor

Added

  • pip install kglite now ships the kglite-mcp-server command. The pure-Rust MCP server moved into the wheel: its server body lives in the kglite-mcp-server library (run), is statically linked into the compiled extension (sharing the one kglite engine — no separate wheel, no duplicated engine, ~6 MB added to the extension), and is exposed to Python as kglite._run_mcp_server. A thin kglite/mcp_server.py console-script shim forwards argv into it, so pip install kglite && kglite-mcp-server runs the identical server as cargo install kglite-mcp-server. This restores the pip-native entry point retired in 0.10.25, now backed by the single Rust implementation rather than a parallel Python server. The standalone cargo binary is unchanged (it’s now a thin main.rs over the same library). (Semantic search via extensions.embedder still needs the fastembed cargo feature, which neither the default wheel nor the default cargo binary ships — build the standalone binary with --features fastembed.)

  • extensions.cypher_preprocessor — rewrite agent Cypher before execution. Trust-gated (trust.allow_query_preprocessor: true, mirroring trust.allow_embedder), in two shapes: declarative rules: (ordered regex substitutions with $1 backrefs) and a command: subprocess hook (query on stdin → rewritten query on stdout, run with the manifest dir as cwd). Applies to every cypher_query and manifest tools[].cypher invocation. The motivating case is Wikidata Q-number rewriting ('Q42'42, since the engine stopped auto-coercing prefixed ids in 0.10.10); it natively replaces the bespoke FastMCP rewriting server an operator would otherwise hand-roll. A boot error (not a silent no-op) when declared without the trust gate. See docs/python/examples/manifest_cypher_preprocessor.md.

  • extensions.embedder.backend: python — semantic search in the bundled server with no Rust toolchain. The pip-hosted server can now back text_score() with a fastembed-py model (pip install 'kglite[embed]') instead of the fastembed-rs cargo feature. kglite._run_mcp_server takes an embedder factory; when a manifest declares backend: python, the server builds the Python model and wraps it in the existing PyEmbedderAdapter, which re-acquires the GIL only for the (once-per-query) embed — the ranking over the graph still runs in Rust with the GIL released, so non-text_score queries are unaffected. This closes the one gap from the wheel-bundling work: embedder MCP servers (e.g. semantic-search corpora) no longer need cargo install --features fastembedpip install 'kglite[embed]' suffices. The standalone cargo binary has no Python, so it rejects backend: python with a clear message and keeps using backend: fastembed (fastembed-rs). See docs/python/examples/manifest_with_embedder.md.

[0.10.25] — 2026-06-16 — code-graph ergonomics, algorithm scoping, single Rust MCP server

Added

  • MCP server: bundled code_graph_analysis + code_graph_views skills. Cross-tool skills (attached via references_tools, gated graph_has_node_type: [Function, Class]) that teach graph-first analysis — map structure with graph_overview/cypher_query/explore, drop to grep/read only to confirm — and library-only views (the is_test / is_benchmark / is_external filters, {where:'…'} algorithm scoping, and parse_json for parameters/fields). This is the guidance operators previously hand-rolled into instructions:. Requires mcp-methods 0.3.42 (the serve_prompts pass that injects a skill’s description under ## When to use and honors references_tools); the pin was bumped this release.

  • MCP skill-authoring guide (docs/python/guides/mcp-skills.md): the frontmatter schema, the <basename>.skills/ project-layer convention, the skills: value shapes, applies_when gating, the three text channels (instructions: vs overview_prefix: vs skills), the injection size caps, and which frontmatter keys are load-bearing vs decorative.

  • Code-graph provenance flags is_benchmark and is_generated. is_benchmark (path-based — asv_bench/, benchmarks/, bench/) joins the existing is_test on File / Module / Function / Class, and is_test is now also emitted on Class (so test classes like PlotTestCase can be excluded from fan-out / centrality queries). File nodes carry is_generated (true for machine-produced files skipped as generated / minified). Lets analysis queries scope to library-only code: WHERE c.is_test = false AND c.is_benchmark = false.

  • parse_json(s) Cypher function (alias from_json). Recursively parses a JSON string into a structured map / list / scalar (null on invalid input), so Cypher can predicate over data stored as a JSON string — notably the code graph’s Function.parameters and Class.fields: WHERE any(p IN parse_json(f.parameters) WHERE p.type_annotation = 'Dataset').

  • Subgraph scoping for the centrality + community procedures. pagerank, degree, betweenness, closeness, louvain, leiden, and label_propagation now accept optional {node_type, where} parameters that restrict the algorithm to a property-filtered subgraph, so test / benchmark / external nodes no longer pollute centrality and community results: CALL pagerank({node_type:'Function', connection_types:'CALLS', where:'n.is_test = false'}). where is a predicate over the node variable n (full WHERE grammar); only edges with both endpoints in scope are traversed, and an explicit scope lifts the large-graph refusal guard. In-memory graphs only (disk/mapped reject scope — filter with a preceding MATCH).

Fixed

  • Code-graph: is_external is now false on internal nodes, not null. Internal Class / Struct / Trait / Interface nodes left is_external unset, so only external stubs carried the property and the intuitive filter WHERE c.is_external = false silently matched nothing. Internal definitions now emit is_external = false explicitly, sharing one boolean column with the external stubs (which stay true).

  • Code-graph: qualified_name / module no longer double the package name. In the common clone layout where the directory the graph is built from shares its name with the top-level package (<repo>/xarray/xarray/core/...), the module path was prepended twice (xarray.xarray.core). The package prefix is now skipped when the relative path already begins with it, so qualified_name round-trips with the obvious module path (xarray.core...) and read_code_source(qualified_name=...) takes the un-doubled form.

Changed

  • MCP server consolidated on a single pure-Rust binary. kglite-mcp-server is now exclusively the Rust binary (cargo install kglite-mcp-server). The parallel Python MCP server was retired — the two implementations had begun to drift (duplicate skill directories, tool descriptions, and applies_when logic), and the Rust binary was already the more complete one. pip install kglite is now the engine + code_tree only. (Anti-drift regression tests now fail the build if a second MCP server or a second skill source reappears.)

  • graph_overview / describe() no longer pads the schema with uniformly-false boolean columns. On a single-language code graph the other frontends’ flags (flutter_build, is_ffi, is_pymethod, is_pymodule, is_factory, …) were emitted false on every node and printed in both the <properties> and <samples> sections. They’re now suppressed from the overview (a boolean that is actually mixed still shows); the columns remain present in the graph and queryable via Cypher.

  • graph_overview / describe() never truncates identifier columns. The node id and its alias (e.g. qualified_name) are the join key copied into follow-up tool calls, so they’re now emitted in full regardless of the sample_truncate setting; other long string values still truncate.

Removed

  • The Python MCP server (kglite.mcp_server) and its pip-installed kglite-mcp-server console script. Install the server with cargo install kglite-mcp-server. Breaking for users who ran pip install kglite and relied on the bundled kglite-mcp-server command — switch to the cargo install (the agent-facing tool surface is unchanged).

  • The wheel’s MCP runtime default dependenciesmcp, pyyaml, aiohttp, watchdog — plus the internal kglite._mcp_internal mcp-methods bridge. pip install kglite no longer pulls any of these; the wheel is the engine + code_tree extension only. (The optional [embed] extra — fastembed for engine-level set_embedder/text_score — is unchanged and still available.)

[0.10.24] — 2026-06-16 — smaller .kgl files, faster CREATE

Performance

  • Bulk Cypher CREATE is ~30% faster — now beats the 0.10.15 baseline. Two per-node redundancies in the node-create path were removed:

    1. insert_node_routed registered node-type metadata for every created node (a HashMap<String,String> of property types), and create_node also ran ensure_type_metadata per node — duplicate, type-level work per row (the regression introduced in 0.10.17). The upsert is only needed on disk (where the node can’t be read back), so it’s gated to disk mode.

    2. ensure_type_metadata now skips its read-back + HashMap build + upsert when the type’s metadata already covers the node’s property keys — the common homogeneous case after the first node. Heterogeneous nodes (a new key) still fall through to the full upsert, so behaviour is unchanged.

    A 50k-node UNWIND CREATE drops from ~49 ms (0.10.23) to ~34 ms — below the ~42 ms 0.10.15 baseline. Metadata, MERGE, and save/load round-trip are all byte-identical.

Fixed

  • Smaller, faster-loading .kgl files for in-memory builds. enable_columnar() (run on every in-memory save()) moved id/title into the column store but left the inline copies in each node, so they were serialized twice — once in the topology section and once in the column section. A fresh build now nulls the inline copies (as a load already did), so a freshly-built graph is byte-for-byte identical to a load→save round-trip. Eliminates ~27 bytes/node of duplicated topology (e.g. a 557k-node graph sheds ~15 MB uncompressed / ~2.6 MB compressed topology), shrinking files ~1.5–2% and speeding load ~2–3%. Existing .kgl files are unaffected and still load correctly.

  • disable_columnar() no longer drops node ids/titles. Rebuilding per-node storage from the column store omitted the reserved __id__/__title__ columns, so calling disable_columnar() on a loaded graph (whose nodes hold the columnar null-sentinel) wiped every id and title. It now restores them from the store.

  • Deterministic .kgl output for Cypher-CREATE graphs. The schema slot order was derived from a properties HashMap whose iteration order is randomized per process, so saving the same CREATE-built graph could produce different column orderings — and, because zstd’s ratio is order-sensitive, different compressed bytes — run to run. The CREATE path now sorts schema keys, so identical input always yields identical output (save is reproducible).

[0.10.22] — 2026-06-15 — OKF: structured-only sweeps + memory-aware labels

Changed

  • okf.build now ingests only structured .md by default — files with a YAML frontmatter block (require_frontmatter=True). This is the discriminator between structured knowledge (OKF concepts, Claude memories) and plain markdown (READMEs, notes), so you can point at a parent of many projects and sweep out only the structured knowledge across all of them in one pass — each project’s tree becomes Folder nodes; concept ids stay path-relative. (Measured: ~2,000 nodes from a whole multi-project code tree in ~4 s.) Pass require_frontmatter=False for vault-style ingestion of every .md.

  • Memory-aware labels and titles. Node label falls back typemetadata.typeConcept, and title falls back titlename → file stem. Claude memory files (which carry metadata.type and name, not a top-level type/title) therefore land as :feedback / :project / :user / :reference nodes titled by their name, queryable as MATCH (m:feedback) .

Fixed

  • Dangling-reference stubs now carry concept_id (and _provisional), matching real concepts, so “references not yet written” are queryable uniformly via MATCH (n {_provisional: true}) RETURN n.concept_id regardless of whether the bundle has any bare Concept nodes.

[0.10.21] — 2026-06-15 — richer OKF graphs (folders, tags, sources)

Changed

  • OKF ingestion now extracts more meaning from the bundle, by default. okf.build synthesizes three structural node types beyond bare concepts, so the result is a dense, well-clustering graph instead of a sparse author-link one — all from data already in the bundle (no embeddings, no new dependency):

    • Folder nodes — the directory hierarchy, (:Folder)-[:CONTAINS]-> concepts and subfolders. Each directory’s index.md (previously discarded) now enriches its Folder’s title/description.

    • Tag nodes(:Concept)-[:TAGGED]->(:Tag) from frontmatter tags, so concepts sharing a tag are connected through a hub.

    • Source nodes — external http(s) links (previously dropped) become (:Concept)-[:CITES|REFERENCES]->(:Source {url}), enabling co-citation.

    • Forgiving link resolution[[wikilinks]] and paths resolve by exact id → file stem → normalized slug (case- and _/--insensitive) → title, so [[my-note]] / [[My Note]] / my_note.md all reach the same concept (cuts false-dangling references substantially on real memory dirs).

    On the reference Google bundles this densification roughly tripled node/edge counts and turned fragmented clustering into useful communities (stackoverflow CALL leiden: 19 communities with 12 singletons → 6 communities). Note: with structural CONTAINS/TAGGED edges, an “orphan” query should exclude those edge types. Each enrichment can be disabled via BuildOptions.

[0.10.20] — 2026-06-15 — Native OKF (Open Knowledge Format) ingestion

Added

  • Native OKF (Open Knowledge Format) ingestion — from kglite import okf. Loads a directory of markdown files with YAML frontmatter, cross-linked by markdown links — Google’s Open Knowledge Format, and equally Claude memory dirs, skills folders, and Obsidian vaults — into a KnowledgeGraph. Conceptually code_tree for prose knowledge: read-only and partial (each concept becomes a node carrying its frontmatter as properties plus a file_path pointer; the body is read on demand via okf.source(path), not stored unless with_body=True). Markdown links become typed edges via an inference ladder — explicit link title ([x](/y.md "JOINS_WITH")) → enclosing section header (# CitationsCITES) → LINKS_TO — plus structural CONTAINS edges; links to not-yet-written concepts become _provisional stub nodes (MATCH (n {_provisional:true})). A dialect="obsidian" mode also resolves [[wikilinks]] and tolerates frontmatter without a type. OKF ships no query engine of its own, so the result composes with everything KGLite already has — CALL leiden/pagerank to cluster/rank a knowledge corpus, the orphan_node rule to find unreferenced notes, temporal filters for staleness. Feature-gated behind the engine’s okf Cargo feature (pulls only yaml-rust2); enabled in the wheel, off in the bare crate so non-OKF builds pay nothing.

[0.10.19] — 2026-06-14 — Leiden + multilevel Louvain + bounded-memory algorithms

Added

  • Leiden community detection — CALL leiden(...). The algorithm the GraphRAG ecosystem standardised on: like Louvain but a refinement phase guarantees every returned community is well-connected (Louvain can return internally-disconnected communities). CALL leiden({resolution, weight_property, connection_types}) YIELD node, community [, level]. Deterministic variant — refinement splits communities into connected components (guaranteeing connectivity) without the reference implementation’s randomised modularity sub-refinement, so results are reproducible. Reaches every interface via cypher_query; documented in describe() / graph_overview(cypher=['leiden']) and CYPHER.md.

Changed

  • Louvain is now multilevel (hierarchical). CALL louvain(...) / louvain_communities() previously ran only the local-moving phase (single level) — no aggregation, no hierarchy, and lower modularity than the full algorithm. It now runs the complete multilevel loop (local-move → aggregate → repeat), finding higher-modularity partitions. The returned flat partition may differ from prior versions (coarser, higher modularity) — community detection results were never a stable contract; existing louvain_communities() dict shape is unchanged.

  • CALL louvain / CALL leiden expose the community hierarchy via an optional level column: YIELD node, community, level emits one row per (node, level), finest (0) → coarsest. Omitting level returns the flat best partition as before. Useful for GraphRAG-style tiered community summaries.

Performance

  • Bounded-memory graph algorithms on mapped/disk. Community detection (louvain / leiden / label_propagation) and k_core previously materialised the whole graph into an in-memory O(edges) adjacency before running — defeating the point of the mmap-backed mapped / disk modes, which keep the graph off-heap so you can explore a larger-than-RAM graph. On those modes they now stream neighbours on demand from the CSR, holding only O(nodes) resident state (edges stay page-cached on mmap). Louvain/Leiden stream level 0 (the bulk); the aggregated super-graph at levels ≥1 is tiny and stays materialised. The in-memory (Default) hot path is unchanged — the streaming path is gated on storage mode. This makes “cluster a graph too big for RAM” — the GraphRAG indexing bottleneck — a real capability. Streaming community detection is also exempt from the per-query deadline (bounded but slower than in-memory).

Fixed

  • Disk backend dropped the last node’s overflow edges in three fast paths. On a disk graph whose edges live in the overflow maps (a fresh in-memory disk graph, or nodes appended after the last CSR rebuild), iter_peers_filtered, count_edges_filtered, and neighbors_directed_iter returned early when the CSR offset table didn’t cover node + 1 — skipping the overflow scan entirely. The highest-index node therefore appeared to have no matching/incoming edges (e.g. MATCH-free count queries undercounted, and the streaming graph algorithms above saw it as isolated). Now mirrors the correct edges_directed_filtered_iter path: empty CSR range, then always scan overflow.

[0.10.18] — 2026-06-14 — cyclic pattern-match optimisation (matcher + planner)

Performance

  • Cycle-closing pattern segments no longer expand-then-filter. When a node variable reappears later in the same pattern (a cycle, e.g. (p)-[:WORKS_AT]->(c)-[:OWNS]->(pr)<-[:CONTRIBUTES_TO]-(p)) or is pre-bound by an UNWIND seed, the matching segment only needs to confirm the edge to that one already-bound node. The matcher previously expanded every neighbour of the source and discarded all but the matching one — O(degree) work, plus a per-result binding allocation and an intra-pattern bound-variable scan. It now passes the bound node as a target_hint and rejects non-matching peers before any of that — turning the closing segment into a targeted check. Measured on a hub-skewed graph: an anchored triangle count dropped 2.2× (41.3 ms → 18.9 ms); a 4-way cyclic join (pattern_match) ~10% on a uniform graph (the win scales with the cycle-close target’s degree). Variable-length segments are unaffected (they still expand). Results are identical — verified by new TestCyclicPatternCorrectness cases (exact cycle counts + no over-match) and a knows_triangle_cycle entry in the differential corpus.

  • Cyclic patterns are re-rooted at their most-selective node (new planner pass reorder_cyclic_pattern_edges). A cycle like (p:Person)-[:WORKS_AT]->(c:Company)-[:OWNS]->(pr:Project)<-[:CONTRIBUTES_TO]-(p) was evaluated from the written start (p — every Person), materialising a huge intermediate set before the cycle closed; optimize_pattern_start_node couldn’t help (a cycle’s two ends are the same variable, so its reverse is a no-op). The pass rotates the ring so the smallest-cardinality node starts the walk (here Company, ~25× fewer start rows) and — when the edge-type-count cache is warm — orients it so the cheaper incident edge drives first; the closing segment then lands on the bound start node (the O(1) check above). On the 25k-node embedded-app graph this took the 4-way cyclic pattern_match join from ~16.8 ms to ~6.2 ms (2.7×, matching kùzu). Strictly shape-gated — fires only on a simple ring of clean single-typed edges and only on a clear (≥4×) selectivity win, so every acyclic pattern is left byte-identical. The pass is disable-able via disabled_passes=["reorder_cyclic_pattern_edges"]; a TestCyclicPatternCorrectness case asserts optimised == naive when it fires.

[0.10.17] — 2026-06-13 — durable WAL writes (durable=True), disk Cypher CREATE/MERGE, embedded-app perf

Performance

  • Durable SET/property-update no longer scales with graph size. On a durable=True graph loaded from a checkpoint (columnar storage), a Cypher SET ran in O(N-of-type), not O(rows-updated): ~113 ms to set one property on one node in a 127k-node graph. Two coupled causes, both fixed: (1) the columnar SET fast path writes through the master ColumnStore, bypassing the WAL capture wrapper, so the actual mutation wasn’t recorded directly; (2) the per-node Arc<ColumnStore> handle-refresh sweep that follows touches every node of the type via node_weight_mut, which the wrapper captured as N spurious mutations — logging (and re-serialising) the whole type per SET. Now the fast path records the one mutated node explicitly (note_recorded_node_upsert), and the refresh sweep uses a new GraphWrite::node_weight_mut_silent that bypasses capture (it’s internal storage bookkeeping, not a logical mutation). Durable 1-node SET dropped from ~5/24/113 ms (2.5k/25k/127k nodes) to a flat ~3 ms; a 500-node SET on a 127k-node graph from ~120 ms to ~5 ms. Crash recovery still captures the SET (verified). Surfaced by the embedded-app benchmark + a kùzu source-level comparison (its per-column in-place update has no such sweep).

  • WAL crash recovery is no longer quadratic. Replaying recovered WAL frames routed each frame through add_nodes, which rebuilds the type’s id-index per call — so recovering N un-checkpointed single-row commits was O(N · graph). Replay now folds all frames into net per-entity state (last write wins per (node_type, id) / (conn, src, tgt)) and applies it in a handful of bulk calls, rebuilding each index once. Sound because the ops are identity-keyed and idempotent. Replaying 1,000 un-checkpointed commits onto a 21k-node checkpoint dropped from ~932 ms to ~42 ms (~22×); the win grows with the un-checkpointed frame count. (Only matters between checkpoints — save() truncates the WAL.)

  • WHERE n.id IN $ids RETURN count(n) now anchors on the id index instead of full-scanning. The fuse_node_scan_aggregate planner pass fused MATCH (n) WHERE RETURN count(n) into a streaming node sweep that applied the predicate per node — correct for a non-indexable filter like age > 30, but ~40× too slow when the filter is an id equality / id IN that should seed from the always-present id index. The pass now bails on an id-anchorable WHERE, leaving the MATCH+WHERE+RETURN for the eq/IN-anchoring passes to drive from the index, then counting the small anchored set. On a 21k-node graph, WHERE n.id IN $ids (500 ids) count(n) dropped from ~27 ms to ~0.6 ms; WHERE n.id = $x RETURN count(n) is now an O(1) index hit. Non-id filters keep fusing (the streaming scan is the right plan there). Trigger shapes added to the differential corpus (id_in_count_bails_fusion, id_eq_count_bails_fusion). Surfaced by the embedded-app benchmark, where batch point-lookup-by-id was the one phase kùzu won.

Added

  • kglite.open(path, durable=True) — crash-safe durable graphs (write-ahead log). A committed Cypher mutation is now appended to a <path>-wal sidecar and fsync’d before the call returns, so it survives a hard crash (kill -9 / power loss) — not just a clean close. On open, any WAL frames are replayed onto the loaded .kgl checkpoint to recover work committed since the last save(); a durable graph that was never saved recovers entirely from its WAL. save() writes a full checkpoint and truncates the WAL. The log is logical and identity-keyed ((node_type, id) / (conn_type, src, tgt)) with idempotent upsert/remove ops + per-frame CRC, so a torn trailing frame from a crash mid-append is discarded and replay is safe to repeat. This is the first half of contesting the embedded-Cypher-database use case: a committed mutation is durable without an explicit save(). In-memory graphs only in this release (storage="mapped"/"disk" raise ValueError); the columnar disk modes keep their explicit-save() checkpoint model. In-memory non-durable performance is unchanged — a non-durable graph never enters the capture path (verified: tracked mutation/read benchmarks flat vs the prior baseline).

  • kglite.open(path) — load-or-create embedded-database lifecycle. Opens a graph at path, loading it if the file/directory exists or creating a fresh one if it doesn’t. The returned graph remembers path, so:

    • save() takes no argument when the graph was opened (or previously saved) — it writes back to the origin file. Passing a path still works and updates the remembered target (“save as”). A graph built purely in memory with no path raises a clear ValueError rather than failing silently.

    • Context-manager auto-save-on-close: with kglite.open("app.kgl") as g: snapshots to the file on clean block exit. On an exception the save is skipped so the on-disk file keeps its last good state. A new close() method does the same explicitly.

    • kglite.load(path) now also remembers path for bare save().

    This is lifecycle ergonomics (“open, mutate, close → persisted”), the first step toward contesting the embedded-Cypher-DB use case. It is not crash safety: a hard crash mid-session writes nothing (durable-on-commit is a separate, upcoming capability). storage= applies only when creating a new graph; opening an existing file keeps its saved mode.

  • Cypher CREATE / MERGE now work on storage="disk" graphs. Previously rejected with a loud guard, because the disk add_node writes only a slot (node_type + row_id) and drops the node’s properties/title/id — a naive CREATE would silently lose data. Node insertion now routes through one mode-aware choke point (DirGraph::insert_node_routed): on disk it pushes id/title/ properties into the per-type ColumnStore (the same mechanism add_nodes uses) and registers the property types in the schema, so created nodes carry their properties and survive save/reload. MERGE (whose create branch reuses CREATE) works too. Reached by every interface (Python, Bolt, MCP) since they share the executor. memory/mapped behaviour is unchanged; SET/DELETE/ REMOVE already worked on disk. The cross-mode parity oracle (test_phase2_parity.py) and test_cypher_id_semantics.py now exercise disk CREATE/MERGE.

Documentation

  • New guide: “Durable embedded apps” (docs/python/guides/durable-apps.md). Covers the embedded-app lifecycle — open() load-or-create, the remembered-path + context-manager checkpoint-on-close ergonomics, and crash-safe durable=True write-ahead-log writes (fsync per commit, replay on reopen, checkpoint-and-truncate). Includes mode selection (in-memory durable vs non-durable vs storage="disk"), the fsync-bound cost model, and batching guidance. Fills the gap where durable=True existed only in the API-reference stub. Linked from the guides index + toctree.

[0.10.16] — 2026-06-13 — scoped graph algorithms, IN-param anchoring, disk lazy-edge, docs sweep

A capability-discoverability release: the in-place graph procedures (k_core / coreness / clustering_coefficient, scoped connected_components) shipped alongside an IN $param planner-anchoring fix and a disk-mode lazy-edge traversal rewrite (~4–20× on disk pattern match / shortest path), then a documentation sweep that surfaced a batch of previously CHANGELOG-only capabilities in the user guides with verified examples. Plus schema “did you mean?” warnings now readable on diagnostics["warnings"] for agent callers.

Added

  • “Did you mean?” warnings for MATCH typos. A MATCH against a node label or relationship type the graph has never seen now emits a non-fatal warning: to stderr with an edit-distance hint (e.g. unknown label 'Persn' → “Did you mean ‘Person’?”). The query still runs and returns zero rows (unknown types are legal Cypher — a valid existence check), so this is a warning, not an error. It catches the single most common “why is my query empty?” foot-gun. Emitted from the shared execute path, so every binding benefits. The same messages are now also exposed programmatically on result.diagnostics["warnings"] (a list[str]), so MCP/agent callers that never see stderr can read why a query came back empty.

  • CALL k_core / coreness and CALL clustering_coefficient. Two graph procedures, in-place over the knowledge graph (no export to an external graph-algorithm library): k-core decomposition (coreness per node, via O(V+E) Batagelj–Zaversnik) and local clustering coefficient per node. Both take the same optional {node_type, relationship} scoping as connected_components (analyse a single-relationship projection rather than the whole graph) and are reached by every binding through cypher_query. Filter WHERE coreness >= k for the k-core itself.

  • Scoped weakly-connected-components. CALL connected_components() now accepts an optional parameter map — {node_type, relationship}, each a string or a list of strings — to restrict the analysis to a subgraph instead of the whole graph. relationship limits which edge types union their endpoints; node_type sets the component universe (nodes of other types are excluded, even as singletons). With neither, behaviour is unchanged (every node, every edge type). This is the “components of a single-relationship projection” query — e.g. CALL connected_components({node_type: 'Person', relationship: 'KNOWS'}) for the social-graph components — that a graph-algorithm library computes on an edge-type-projected view. Backed by the new weakly_connected_components_scoped core function; reached by every binding through cypher_query.

  • k_core / clustering_coefficient now surface in describe(). The agent-facing schema introspection lists the new procedures (with the scoping note) so an LLM can discover them without reading the docs.

Documentation

  • Surfaced previously CHANGELOG-only capabilities in the guides. A docs-vs-capabilities audit found several shipped, tested features that were invisible in the user guides (so under-discovered, including by evaluating agents). Now documented with verified examples: scoped CALL algorithms (graph-algorithms.md + CYPHER.md), to_neo4j() export and extend() multi-source merge (import-export.md), query tuning & diagnostics — PROFILE/EXPLAIN/timeout_ms/max_rows/disabled_passes/ diagnostics["warnings"] (cypher.md), spatial constructive geometry (spatial.md), and temporal time-travel valid_at/valid_during (timeseries.md). Plus a hybrid RAG-over-a-graph retrieval recipe (CYPHER.md) locked with tests.

Performance

  • Disk-mode traversal no longer materialises an EdgeData per edge. On storage="disk", every edge crossed during pattern matching, variable-length / shortestPath traversal, or a relationship-scoped connected_components used to allocate a heap EdgeData (with a cloned property vector) and take a per-edge arena mutex — just to read the edge’s connection type, which is available for free from the CSR endpoint table. GraphEdgeRef now carries the connection type directly and materialises the full edge lazily, only when a query actually reads edge properties; the traversal hot paths read the cheap connection_type() accessor instead. On the 25k-node / 266k-edge comparative benchmark, disk-mode pattern_match dropped ~394 ms → ~19 ms, shortest_path (100 pairs) ~747 ms → ~120 ms, and scoped connected_components ~20 ms → ~2 ms — now on par with in-memory and mapped. In-memory and mapped are unaffected (they keep their borrowed &EdgeData path; the connection type is a field they already had).

  • WHERE x.prop IN $param now anchors on the index instead of a full scan. The planner’s predicate-pushdown only recognised an IN list written as a literal (IN [1, 2, 3]); the parameterised form (IN $ids, an InExpression) fell through to a full type scan + post-filter. It now resolves the parameter (and the JSON-array string form the Python binding uses for list params) at plan time, pushes an IN matcher into the MATCH pattern — anchoring on the id index when the property is id — and rewrites the surviving WHERE to the O(1) InLiteralSet form so the safety-net re-filter is cheap. On a 2k-node graph, MATCH (p:Person)-[:KNOWS]-(f) WHERE p.id IN $ids (200 seeds) dropped from ~89 ms to ~1.2 ms (1-hop) and ~266 ms to ~1.3 ms (2-hop), matching the hand-written UNWIND $ids MATCH (p {id:sid}) form. Trigger query added to the differential corpus as id_in_param_anchored.

[0.10.15] — 2026-06-10 — CALL { } subqueries, graph interop, hot-path perf sweep

A user-experience release driven by a roadmap audit: the most-requested missing Cypher construct (CALL { }), ecosystem bridges (NetworkX round-trip, in-place graph merge), a profile-gated performance sweep (-5 to -17 % on the measured hot shapes), a Neo4j migration guide, and a batch of papercut/correctness fixes — including an MCP-server boot blocker on clean installs and several stale-docs purges.

Performance

  • Whole-node materialization (RETURN n, collect(n)) skips the per-node schema walk on in-memory storage. Materializing a node walked every node-type metadata key per node, paying alias + spatial resolution per key, although for in-memory storage that pass can only ever recover the hoisted id/title field-alias columns — now fetched with two O(1) alias lookups instead (the full walk is kept for the columnar disk/mapped backends, which need it). collect(n) ~8% faster, wide RETURN n ~4%, and the tracked return_node_10k / return_node_rel_node_100 benchmarks ~4% (min). Python-visible output is unchanged, including alias-recovered properties.

  • DISTINCT dedup structures use FxHash. The RETURN DISTINCT / WITH DISTINCT row-dedup sets (plus the count(DISTINCT) / collect(DISTINCT) / mode() / streaming-aggregate DISTINCT sets and the distinct_node_hint pre-dedup) still ran on the default SipHasher — missed by the 0.10.7 FxHash sweep and the top symbol in a samply profile of the DISTINCT shape. Same exact-equality dedup, faster hasher: RETURN DISTINCT over 50k rows ~14-17% faster, collect(DISTINCT) ~15% (min, 50k-node hot-path suite).

  • Per-row property access skips alias resolution when the property can’t be an alias. Every in-memory n.prop in WHERE/RETURN paid two string-keyed HashMap lookups in resolve_alias per row, even for properties that are plainly not id/title aliases (the hottest symbol in a samply profile of five query shapes). A per-query lock-free OnceLock set of alias-name hashes now fast-rejects non-alias properties — semantics unchanged (id/title virtuals, stored-property-wins, spatial fallbacks all preserved; hash collisions can only route to the slower full path, never change results). Measured on the 50k-node hot-path suite (min): multi-property WHERE filters ~12-13% faster, count(DISTINCT) ~10-11%, collect(DISTINCT) ~10%, ORDER BY LIMIT ~9%. Tracked core benchmarks flat.

Added

  • KnowledgeGraph.extend(other, conflict_handling='update'). Merge another in-memory graph in place — multi-source ingest no longer round-trips through CSV. Nodes match on (node_type, id) and resolve per the same conflict_handling vocabulary as add_nodes (update / replace / skip / preserve / sum); property schemas extend automatically; secondary labels union; edges dedup on (connection_type, src, tgt) with property merge (mirroring add_connections); id/title field-aliases carry over for new types. Returns an add_nodes-style report dict. The source graph is never mutated; embedding stores are not merged (a warning points at set_embeddings/add_embeddings); v1 requires in-memory Default storage on both sides. 50k-into-50k with 50% overlap merges in ~21 ms.

  • Cypher: CALL { } subqueries. Both uncorrelated (body executes once; results combine with the outer rows as a cartesian product) and correlated via an importing WITH (body plans once, executes per outer row seeded with only the imported variables — node/edge/path bindings anchor body patterns, including variables left null by an OPTIONAL MATCH miss). Cardinality follows Neo4j: a non-aggregating body with zero rows drops the outer row; an aggregating body always returns one row, so per-row counts preserve 0 rows — MATCH (p:Person) CALL { WITH p MATCH (p)-[:KNOWS]->(f) RETURN count(f) AS c } RETURN p.name, c. Scoping is strict: the body sees no outer variables beyond the imports, only its RETURN columns escape, and column collisions with outer scope raise a clear error. Writes inside a body route through the mutation classifier and are rejected in this version (as are unit subqueries and UNION inside a body). Planner passes treat the clause as an optimization barrier (audited pass-by-pass, documented above the PASSES registry); body optimization respects disabled_passes / disable_optimizer. Covered by 22 differential-corpus entries, Bolt round-trip conformance (172 queries, 0 failures), and Neo4j-conformance queries; documented in CYPHER.md with the v1 limitation table and surfaced to agents via a CALL_SUBQUERY introspection topic. The work also fixed a pre-existing hole: a write inside a UNION arm was classified as a read.

  • NetworkX interop. KnowledgeGraph.to_networkx() exports the graph as a lossless nx.MultiDiGraph (node key = node id; node_type, title, and all properties as attributes; connection_type as the edge key, so parallel typed edges stay distinct), and kglite.from_networkx(nx_graph, *, default_node_type='Node', default_edge_type='RELATED') builds a graph from any Graph/DiGraph/MultiGraph/MultiDiGraph via the bulk DataFrame fast paths. Round-trips preserve ids, types, titles, node/edge properties, and parallel typed edges; undirected edges become one directed edge. networkx stays optional — pip install 'kglite[networkx]'; a clear ImportError points there when it’s missing. 10k nodes / 30k edges round-trip in ~0.15 s.

  • Cypher: trigonometry + UUID + local-temporal functions. sin / cos / tan / asin / acos / atan / atan2(y, x) / cot / haversin / degrees / radians (null/non-numeric → null, matching the existing math functions); randomUUID() (RFC 4122 v4, excluded from constant folding so it stays unique per row — no new dependency, generated from the existing PRNG); localdatetime() / localtime() / time() returning ISO-8601 strings (no-arg = local now; 1-arg parse form mirrors datetime(str); strings because KGLite’s DateTime value is date-only — documented in CYPHER.md). Reaches every binding through cypher() per the cypher-first policy.

  • ResultView.one() / .scalar() / .column(name). The three most common result shapes get first-class accessors: one() returns the first row as a dict (or None), scalar() the first cell by RETURN order (or None) — g.cypher("… RETURN count(n)").scalar() — and column(name) one named column as a plain list without a DataFrame round-trip (KeyError listing available columns on a miss). All three materialize only what they return; row indexing stays integer-only.

  • KnowledgeGraph.exists(node_type, unique_id) -> bool. O(1) existence check via the same id-index as node(), with identical id-coercion semantics — replaces the node(...) is not None idiom without materializing the node.

Fixed

  • properties(n), keys(n), and n {.*} now match RETURN n on graphs loaded with non-literal id/title columns. When add_nodes hoists e.g. npdid/name into the node’s id/title, RETURN n recovered those columns into the property map but properties(n), keys(n), and the n {.*} map projection each had their own enumeration that omitted them (keys(n) additionally dropped real columns on the disk/mapped backends). All three now delegate to the same materializer RETURN n uses, so the shapes stay in lockstep across every storage mode. The materializer also honours the KG-1 soft-alias rule for type: a stored property named type wins over the structural type string in all four shapes (matching n.type); id/title remain canonical virtuals.

  • Map subscript by string key works. {x: 1}['x'], properties(n)['title'], dynamic keys ({x: 1}[k]), nested access ({a: {b: 2}}['a']['b']), and dynamic property access on bound nodes and relationships (n['title'], r['since']) previously failed with Index must be an integer. Missing keys and null keys now resolve to null (Neo4j semantics), never an error. List indexing (including the integer fast path, negative indices, and out-of-range → null) is unchanged.

  • kglite-mcp-server no longer refuses to boot on a default install. The startup dependency check demanded fastembed, which belongs to the opt-in [embed] extra — so a plain pip install kglite (without [embed]) exited at launch with advice to install a [mcp] extra that no longer exists. The boot check now verifies only the deps that ship in the default install (mcp, pyyaml, aiohttp, watchdog), and the embedder paths (fastembed + bge-m3) raise an actionable pip install 'kglite[embed]' error at point of use instead.

Documentation

  • Removed instructions to install nonexistent extras. Getting-started, the MCP server guide, the code-tree guide, CONTRIBUTING, and the conformance doc all still told users to pip install 'kglite[mcp]' / 'kglite[code-tree]' — neither extra exists (the MCP server runtime is in the default install since 0.9.41; tree-sitter grammars are bundled). Docs now point at plain pip install kglite and surface the real [embed] / [neo4j] extras.

[0.10.14] — 2026-06-08 — Bolt conformance tooling + ResultView.to_dicts() + doc clarity

Finalizes the Bolt server (Phase D) — the conformance oracle, reference clients, and docs that let us call the feature done. Also folds in a small API addition and a documentation pass driven by a downstream field report: most flagged items were already shipped or never broken, so the bulk of that work is making existing behaviour discoverable.

Added

  • scripts/bolt_conformance.py + make bolt-conformance. On-demand oracle that runs the differential corpus through kglite-bolt-server over the wire and compares against direct in-process cypher() — catches PackStream round-trip bugs. No Neo4j / Docker needed; it spawns its own server. Documented in docs/concepts/cypher-conformance.md.

  • Reference examples. examples/bolt_client_neo4j_python.py (drive the server with the standard neo4j driver) and examples/bolt_neo4j_browser.md (point Neo4j Browser at it).

  • ResultView.to_dicts() — alias for to_list() (returns all rows as list[dict]). Matches the polars .to_dicts() name (pandas calls the equivalent .to_dict(orient="records")), so consumers coming from either library reach the right method without a coercion shim.

Fixed

  • scripts/cypher_conformance.py passed query parameters as cypher(query, **params) instead of cypher(query, params=...), so the Neo4j conformance run errored on every parameterized query. Now fixed (same convention the differential test harness uses).

Documentation

  • add_embeddings surfaced for incremental ingest. The semantic-search guide now has an “Incremental ingest” section: set_embeddings is a full replace; add_embeddings upserts into the existing store (no read-merge-write at the call site). set_embeddings’ docstring cross-refs it.

  • vector_search hit contract documented. Each hit carries id, title, type, score, and all node properties; score is always present (every metric); properties are read live, so a hit round-trips through save() + reload without a follow-up id-join.

  • ResultView indexing clarified. Indexing is row-wise; there is no result["col"] column accessor (use to_df()["col"] or a comprehension).

[0.10.13] — 2026-06-06 — mcp local-mode github repo auto-detect

Fixed

  • MCP server (local-workspace mode): github_issues / github_api now auto-detect the repo from the active root’s git remote. Previously, calls without an explicit repo_name defaulted to the local/<dir> inventory key and 404’d, even when the active root was a checkout of a real GitHub repo. Bumped mcp-methods to 0.3.41, which derives the default from the root’s origin remote (falling back to “ask for repo_name” when there’s no GitHub remote). github-mode behaviour is unchanged.

[0.10.12] — 2026-06-01 — fluent select/filter perf + count(DISTINCT) fusion

Performance

  • Fluent select(sort=…, limit=k) is now a bounded top-K, not a full sort. Previously it sorted the whole selection then truncated; now it partitions (select_nth) + sorts only k. Combined with a rewrite of the sort path from a per-comparison HashMap lookup to a precomputed key vector, top-10/top-100 over 100k nodes dropped ~25 ms → ~1.9 ms (~13×). The no-limit full sort is also faster (~61 → ~40 ms).

  • Fluent where() on a full single-type selection uses the property index directly instead of building an O(N) membership set, and stops allocating a String per node when deriving candidate types. Indexed-property lookups via the fluent API are ~2× faster.

  • count(DISTINCT <property>) now fuses into the node scan-aggregate. It previously materialized one result row per scanned node and de-duplicated afterward; it now tracks a per-group value set inline during the scan. count(DISTINCT n.prop) (and the grouped RETURN k, count(DISTINCT n.prop) form) over 100k nodes dropped ~12.7 ms → ~4.9 ms (~2.6×). Results unchanged.

[0.10.11] — 2026-06-01 — count(node) no longer materializes per row

Performance

  • count(node) no longer materializes the node per row. count(n) (or count(c), …) over a bound node/edge variable evaluated the variable each row, building a full node value (every property cloned into a map) just to test non-null — the dominant cost of scan-, group-, and traversal-counts. It is now treated as count(*). Measured on a 110k-node / 830k-edge graph (in-memory): WHERE RETURN count(n) filters ~4× faster, RETURN k, count(n) group-by ~5×, reverse rel-type counts ~3.5×, and deep fixed-length path counts (…->(n5) RETURN count(n5)) ~2× — shared across the default, mapped, and disk backends. Results, column names, and count(DISTINCT …) semantics are unchanged.

[0.10.10] — 2026-05-30 — reserved-name papercuts + cross-mode id parity

Reserved-name Cypher papercuts (KG-1/KG-2) plus a node-id correctness sweep that makes the query interface identical across storage modes.

Changed

  • Node id is the same integer in every storage mode. For prefixed-id datasets (Wikidata Q42, …) the loader previously stored id as the string "Q42" in memory/mapped but the compact integer 42 on disk, bridged by a too-eager string→int coercion. Now id is the integer (n.id == 42) in memory, mapped, and disk — identical results everywhere — and the string form lives in the nid property (n.nid == "Q42"). Breaking (pre-1.0): memory/mapped n.id for Wikidata changes "Q42"42; query the string form via {nid: 'Q42'} (or the integer via {id: 42}). {id: 'Q42'} no longer matches. nid/qid are no longer id-aliases — {nid: X} is a plain (indexed) string-property lookup. See CYPHER.md → “Naming”.

Fixed

  • A node property named label (also type/node_type/name) is readable (KG-1). Property-first resolution across every read path — RETURN, WHERE, inline-map, EXISTS, disk fast path, map projection. The count-by-type fusion is gated when such a property shadows the type, and n.type projects a scalar there (matching the un-fused path) while labels(n) stays a list.

  • {id: 'a1'} no longer returns the wrong node. The string→int id coercion ('a1'/'x1'/'Q1'UniqueId(1)) is removed; a String id matches only by exact value. Numeric (Int64↔UniqueId↔Float) coercions are retained.

  • Cypher CREATE (n {id: X}) honours X as the node identity (was auto-assigned), consistent with add_nodes(unique_id_field='id'); string / int / float ids round-trip and survive save → load.

  • Duplicate ids now emit a rate-limited warning (MATCH (n {id: X}) returns one node per id); detected at id-index build so bulk ingest stays O(n).

Added

  • Reserved keywords usable as relationship types / node labels / property keys / property access (KG-2). CREATE (s)-[:CONTAINS]->(c), MATCH (n:CONTAINS), {contains: 1}, n.contains parse. The safe set (operator/sort/set/mutation keywords) works in every name-position across MATCH / CREATE / MERGE / SET / REMOVE / WHERE and EXISTS subqueries; load-bearing + value keywords stay reserved with a clear error and the backtick escape hatch.

Internal

  • Split the fusion.rs optimiser god-file into a fusion/ module directory.

  • Conformance/golden test layer for id semantics (tests/test_id_parity.py, tests/test_cypher_id_semantics.py) + N1–N4 regression locks — the layer the differential corpus (optimised-vs-naive) and parity oracles (set-equality) structurally can’t provide.

[0.10.9] — 2026-05-29 — self-healing id-index (issue #20)

Fixed

  • id-equality lookups are O(1) regardless of how the graph was built (issue #20). MATCH (n {id: X}) and the MERGE match go through a read-only lookup path that never built the id-index; whenever the index was absent for a type — the state add_nodes, CREATE, and DELETE all leave it in — every id-equality lookup fell back to an O(node-position) linear scan (e.g. ~26 µs for a high-id node on a 30k graph vs ~1.1 µs once indexed, with the cost growing as the node’s position grew). The read path now self-heals: on a miss it builds and caches the id-index once, so that lookup and every subsequent one is O(1) — no matter whether the type was populated via add_nodes, CREATE, or had its index invalidated by DELETE. Lookups measure a uniform ~1.1–1.5 µs across low and high positions after each of those paths. This also explains the “repeated-MERGE 36× slower” report — the slow case was always a high-position id; varying-id benchmarks masked it via min (which caught the cheap low-position samples).

[0.10.8] — 2026-05-29 — openCypher dialect fixes

Fixed

openCypher dialect gaps reported by kglite-docs (2026-05-29), all with regression tests:

  • labels() / keys() / properties() / id() on a node VALUE. These returned NULL silently when given a node that arrived as a value (collect(a)[0], head(collect(a)), a WITH projection) rather than a bound variable — so the standard “latest per group” ( collect(x)[0] AS latest) idiom read wrong data with no error. They now resolve the node value. Relatedly, a materialised node (RETURN n, collected nodes) now carries its full label set (primary + secondary), not just the primary type.

  • DETACH DELETE ignores NULL variables. The idiomatic single- statement cascade MATCH (root) OPTIONAL MATCH (root)-->(child) DETACH DELETE root, child no longer errors when a branch is empty (openCypher treats NULL in DELETE as a no-op).

  • Parameter inside an EXISTS {} pattern. EXISTS { MATCH (a)-[:R]-> (:T {id:$id}) } now parses (was a syntax error; the literal form worked).

  • Node-property expression as an inline-map value. MATCH (b {id: other.id}) now parses and resolves other.id at match time (against a bound node or a projected node value), instead of a parse error.

[0.10.7] — 2026-05-29 — in-memory query perf (SipHash → FxHash)

A samply profile of the in-memory query hot path found ~23% of engine CPU in the std default SipHasher: maps keyed by InternedKey (an already-well-distributed FNV u64) re-hashed it through a cryptographic hash on every property access, and the GROUP BY path did the same to group keys per row. Swept those to FxHash (rustc-hash, already a dependency). Semantics-identical, .kgl-compatible, correctness verified by the differential corpus + parity oracles.

Changed

  • Faster property accessTypeSchema::key_to_slot and StringInterner.strings (both InternedKey-keyed, hit per property per row on the Compact read path) now use FxHash.

  • Faster alias resolution — the id/title field-alias maps (resolve_alias) now use FxHash.

  • Faster GROUP BY — the five group-key → group-index maps across the streaming, materialized, and fused aggregation paths now use FxHash.

Single-label query A/B vs 0.10.6 (50k nodes, release, min-over-rounds): multi_where −38%, group_by −23%, where_scan −21%, proj5 −17%; aggregate queries an additional few percent. Traversal-bound queries are roughly flat. No single-label regression (every change is a no-op when no secondary labels / aliases exist).

  • mcp-methods 0.3.40 — picks up the merged watch skip-patterns PR plus graph_overview/cypher_query fastmcp fixes. No API change.

[0.10.6] — 2026-05-29 — multi-label read-path correctness

0.10.5 shipped secondary labels but only taught the slow matcher path about them; the optimiser’s fused fast-paths and several other candidate-selection sites still assumed type_indices[T] was all nodes labelled :T. On a multi-label graph that silently over/under- counted. One root defect, many surfaces — swept and fixed end-to-end, with single-label performance provably unchanged (every change is a no-op when no node carries a secondary label; verified against 0.10.3).

Fixed

  • Multi-label read paths now consult secondary labels everywhere. 0.10.5 shipped secondary labels but only the slow matcher path was taught about them — the optimiser’s fused fast-paths and several candidate-selection sites still assumed type_indices[T] was all nodes labelled :T, so on a multi-label graph they over/under-counted. Reported by kglite-docs: MATCH (n:Item:Pending) RETURN count(n) over-reported after remove_label. (The secondary-label index itself was never stale — the bug was entirely read-side, so no data is corrupted and existing .kgl files read correctly once upgraded.) Fixed across:

    • count(n) of a typed/secondary label (FusedCountTypedNode), single-pass scan + top-K aggregation, and labels(n) grouping.

    • Edge-expansion endpoint filtering — MATCH (a:Person)-[:KNOWS]->(b:VIP) returned nothing when :VIP was a secondary label.

    • The n:Label / WHERE n:Label predicate, MERGE (n:Label {...}) (no longer creates a duplicate when matching a secondary-labelled node), and the transient equality index.

    • Aggregate and spatial-join fusions that can’t express the secondary-label union now bail to the correct general path when the graph has secondary labels (single-label graphs keep every fast-path).

  • Node deletion evicts secondary labels. DETACH DELETE (and provisional purge) now remove the deleted node from the secondary-label index instead of leaving a dangling entry that over-counted MATCH (n:SecLabel). Loading a graph saved by 0.10.5 after such a delete is self-healed (dangling indices are dropped on load).

Added

  • select(node_type, ..., include_secondary=True) (fluent) — select nodes carrying node_type as a primary or secondary label, the fluent equivalent of Cypher MATCH (n:node_type). Default False preserves primary-type-only selection.

[0.10.5] — 2026-05-28 — multi-label nodes (Track C)

A node can now wear multiple labels: a primary type (set at creation, immutable via label mutation) plus an optional list of secondary labels added through Cypher or the new add_label / remove_label pymethods. Triggered by kglite-docs’s 2026-05-28 feature request — agent role taxonomies ((:Agent:LLM:Reviewer)), lifecycle status as label ((:Chunk:NeedsOcr)), cross-type predicates (MATCH (n:Disputed)).

Non-breaking by construction. Single-label workloads pay zero overhead — a has_secondary_labels: bool graph-level flag short-circuits every label-keyed read when no node uses secondary labels. Sodir / Wikidata / code-tree benchmarks unchanged vs 0.10.4.

Added

  • Multi-label CREATE syntaxCREATE (n:Person:Director {name: 'Alice'}) stores Person as the primary type and Director as a secondary label.

  • SET n:Label and REMOVE n:Label — add or remove secondary labels on existing nodes. Multi-colon syntax (SET n:A:B) parses as multiple items. REMOVE n:Primary errors with a clear message (use SET n.type = 'NewType' to retype).

  • MATCH (n:A:B) — AND-intersect across labels. Returns nodes that wear every listed label.

  • labels(n) returns the full list[primary, ...secondaries] in insertion order. The single-element behavior since 0.9.52 was the forward-compat placeholder.

  • g.add_label(node_type, ids, label) and g.remove_label(node_type, ids, label) — direct pymethods for batch label mutation by id. Returns {labelled / removed, skipped}. Idempotent.

  • add_nodes(..., labels=['X']) — new kwarg applies a uniform set of secondary labels to every row in the batch.

  • GraphRead::node_labels_of(idx) -> Vec<InternedKey> — new trait method returning [primary, ...extras]. Default impl emits a 1-element vec; Memory + Mapped backends override to emit the full list.

Changed

  • DirGraph.secondary_label_index is the canonical store for secondary labels (it was already the runtime fast-path index; now it’s also the persistence source of truth). NodeData layout is unchanged from 0.10.4 — pre-0.10.5 .kgl files load cleanly. Secondary labels persist via a new optional section in the .kgl v4 envelope (in-memory backend) and via the secondary_labels.bin.zst sidecar in the disk-graph directory. Single-label graphs skip both — zero extra bytes.

  • DirGraph gains secondary_label_index + has_secondary_labels — both #[serde(skip)], rebuilt on load from NodeData.extra_labels.

  • graph_overview(cypher=True) updated — the “Multi-label nodes” limitation note now documents the new syntax instead of flagging it as unsupported.

Internal

  • Single-choke-point label-mutation API on DirGraph: add_node_label / remove_node_label / node_labels. Every mutation site (Cypher executor, pymethods) routes through these so extra_labels and secondary_label_index can never drift apart.

  • secondary_labels.bin.zst disk sidecar — the disk backend’s columnar layout has no slot for NodeData.extra_labels, so the inverted index is persisted as a separate zstd-compressed file in the disk graph directory. Single-label disk graphs skip the write entirely (zero bytes, zero cost). Older 0.10.4 disk binaries ignore the unknown file and load with single-label semantics; older disk graphs without the sidecar load fine into 0.10.5+ with an empty secondary index.

  • Linux CI perf baseline refreshed — the Linux runner baseline (tests/benchmarks/baselines/current.linux.json) had been captured at 0.9.52 (2026-05-23) and accumulated ~+16% of measurement drift on test_bench_add_nodes across 0.10.0 through 0.10.4, leaving no headroom for 0.10.5’s normal noise margin. The hot path for add_nodes (mutation/maintain.rs::add_nodes / apply_node_batch) is byte-identical between 0.10.4 and 0.10.5 — no code change in that path. Refreshed against the CI run for the 0.10.5 perf-fix commit; new 0_10_5.linux.json archived alongside current.linux.json.

[0.10.4] — 2026-05-28 — kglite-docs feedback round

A downstream library author (kglite-docs) sent a 280-line bug report identifying one silent data-loss bug, one storage panic, one reload regression, and a small cluster of API papercuts. This release fixes all of them, ships a new add_embeddings API to make incremental ingest first-class, and folds the audit-flagged add_nodes refactor in on the way through.

Fixed

  • set_embeddings silently dropped embeddings after add_nodes on a loaded graph. BatchProcessor wrote new ids into id_indices incrementally, creating a partial entry that subsequent build_id_index calls trusted as complete. The 50-LOC load() add_nodes(one row) set_embeddings(merged) repro from kglite-docs now reports skipped: 0 instead of skipped: N.

  • Updating a String property on a columnar-backed node panicked with slice index starts at N but ends at M (N > M). Mutating offsets[idx+1] in TypedColumn::Str::set corrupted the start of row idx+1. String updates now park in a relocated overlay; the canonical buffers are rebuilt on save.

  • vector_search dropped non-core properties after save() + load(). Switched to properties_cloned() (which handles PropertyStorage::Columnar) on both result-materialization paths.

Added

  • add_embeddings(node_type, text_column, dict) — upserts into an existing embedding store instead of replacing it. Sidesteps the read-merge-write workflow that triggered the silent-drop bug. Behaves like set_embeddings on first call; store_created in the return dict tells callers which mode ran.

  • embedding_diagnostics() rows carry a length_stats dictmean_length, max_length, distinct_count, distinct_ratio. Callers can filter out short-string and fully-unique columns themselves rather than getting every String property reported uniformly as embeddable.

Changed

  • set_embedder(None) now unbinds the currently-registered embedder instead of raising AttributeError. Symmetric with set_embedder(model).

  • describe() docstring explicitly notes there is no limit kwarg — sample_truncate is the modern name.

Documentation

  • Cypher guide expanded with a new “Why semantic search in Cypher matters” subsection covering vector ranking + structural filters + graph traversal in one query, with worked examples for the kglite-docs document-corpus use case.

  • Cypher guide new “Edge provenance via reified nodes” section explaining when to model relationships as reified Tagging nodes to recover per-application provenance (and when the at-most-one-edge constraint is the right shape).

Internal

  • add_nodes refactored into eight per-phase private helpers (parse_inline_config, extract_embedding_pairs, convert_dataframe, apply_node_batch, register_feature_configs, store_extracted_embeddings, apply_timeseries, build_node_report_dict). Addresses the 2026-05-27 codebase-health audit’s Hotspot 2; the set_embeddings fix above lands at the clean seam exposed by the refactor.

[0.10.3] — 2026-05-25 — kglite-c C ABI ships; Phase B api lifts

Single release theme: every shipped capability of kglite is now reachable from any language with FFI through a stable C ABI. The new crates/kglite-c/ workspace member exposes kglite::api::* through extern "C" functions plus a cbindgen-generated header. Future Go / JS / JVM / .NET bindings link against libkglite_c.{so,dylib,dll} and include the shipped kglite.h instead of re-implementing wrappers in their host language.

Companion to the boundary principle codified in 0.10.2: that release made kglite::api::* rich enough to support future bindings from Rust; this release adds the C ABI layer that makes those bindings reach kglite without compiling Rust at all.

Added — new crate kglite-c

New publishable workspace member at crates/kglite-c/. Exposes 30 extern "C" functions covering the full lifecycle / Cypher pipeline / dataset / embedder surface. Stable C ABI with kglite_ naming convention, opaque-handle types (KgliteGraph / KgliteSession / KgliteCypherResult / KgliteEmbedder / KgliteSecClient), errno-style errors mapping 1:1 to KgErrorCode, and feature gating via KGLITE_FEATURE_* preprocessor defines.

  • Lifecycle: kglite_load_file, kglite_save_graph, kglite_graph_free.

  • Session: kglite_session_new, kglite_session_execute_read, kglite_session_execute_mut, kglite_session_free, kglite_session_set_embedder.

  • Result accessors: kglite_cypher_result_columns_json, kglite_cypher_result_rows_json, kglite_cypher_result_row_count, kglite_cypher_result_free. JSON-at-boundary for nested Value shapes — callers parse with their language’s stdlib.

  • Error introspection: kglite_status_code_name, kglite_status_code_neo4j_status, kglite_status_code_http_status (wrap the api-level lifts).

  • Datasets (feature-gated): Sodir (kglite_datasets_sodir_fetch_all), SEC EDGAR (_sec_client_new, _fetch_quarterly_master_idx, _fetch_submissions_bulk, _fetch_company_tickers, _fetch_company_facts, _resolve_fetch_buckets, _parse_tickers_json, _run_all, _client_free), Wikidata (_ensure_dump, _remote_last_modified, _decide_cache).

  • Embedder (feature-gated): kglite_embedder_fastembed_new, kglite_session_set_embedder, kglite_embedder_free.

  • String teardown: single kglite_free_string for every owned out-string the library returns.

  • ABI version: kglite_abi_version() returns {major: 0, minor: 10, patch: 3} for binding startup checks.

crate-type = ["cdylib", "staticlib", "rlib"] — consumers can link statically (Go cgo’s #cgo LDFLAGS: -lkglite_c) or dynamically (libkglite_c.{so,dylib,dll}). cbindgen runs in build.rs and writes crates/kglite-c/include/kglite.h (952 lines, committed; CI verifies the committed copy matches a fresh cbindgen run).

Added — KgErrorCode::http_status_code() (Phase B)

Companion to KgErrorCode::neo4j_status_code() (added in 0.10.2). Maps each error variant to its canonical HTTP status code:

let kg_err: KgError = /* … */;
let http_status: u16 = kg_err.code().http_status_code();
// 400 for CypherSyntax/InvalidArgument/etc., 404 for NodeNotFound,
// 408 for CypherTimeout, 422 for Schema/Validation/Expr, 500 for
// CypherExecution/FileIo/Internal.

Future REST / gRPC bindings call this rather than re-deciding “is node-not-found a 404 or a 422?” per binding.

Added — kglite::api::param::json_value_to_kglite_value (Phase B)

New module crates/kglite/src/param/mod.rs with the canonical JSON-to-Value converter. Lifted from kglite-mcp-server::tools::json_to_value (a private helper the mcp server had been carrying since first ship); the mcp server now delegates to the core function in one line. Future REST / gRPC / OpenAPI bindings reach for the same canonical converter rather than each re-implementing the JSON-shaped boundary.

Added — design + binding docs

  • docs/rust/c-abi.md (581 lines) — the C ABI design conventions: naming, opaque-handle pattern, errno-style errors, JSON-at-boundary, sync-only ABI, versioning. Source of truth for the kglite-c surface and the reference for binding authors.

  • docs/rust/implementing-a-binding.md — Option 3 rewritten with real cgo / napi / JNI worked examples calling the shipped C ABI (was sketches against an unbuilt aspiration). The “Phase H aspiration” framing is gone; kglite-c is real.

Added — CI

  • Header drift gate in .github/workflows/ci.yml (new kglite-c job): clippy + tests with default features, clippy + tests with sec,sodir,wikidata features, plus a cbindgen regen-and-diff check that fails CI if the committed include/kglite.h doesn’t match what a fresh cbindgen run would produce. Catches both forgotten regens and hand-edits.

  • Publish workflow extended with a 4th publish step (kglite-c) following the same pattern as bolt-server / mcp-server. Version-check job reads kglite-c’s Cargo.toml + probes crates.io; publish runs after kglite has propagated.

Changed — internal

  • crates/kglite-c/src/datasets.rs (single-file) → crates/kglite-c/src/datasets/{mod,sodir,sec,wikidata}.rs (directory). Per-loader files keep each at ~250-650 LOC.

  • SessionState in kglite-c gained an embedder: Option<Arc<dyn Embedder>> slot; execute_read / execute_mut clone it into ExecuteOptions per call so text_score() works through the C ABI.

Test stats

cargo test -p kglite-c # 4 + 4 default cargo test -p kglite-c –features sec,sodir,wikidata –lib # 29 unit cargo test -p kglite-c –features sec,sodir,wikidata # 9 integration cargo test -p kglite-c –features sec,sodir,wikidata,fastembed # 38 total

Full workspace make lint and pytest tests/ remain green.

[0.10.2] — 2026-05-25 — Dataset-wrapper preparation for future-language bindings (boundary principle landed)

Single release theme: get kglite core ready to host future non-Python bindings (Go via cgo, JS via napi, JVM via JNI, etc.) without each one re-implementing the same orchestration glue the Python wheel had to write.

The work is anchored by a new explicit boundary principle in CLAUDE.md (Architecture section):

A wrapper only contains code that is specific to its environment and cannot be used by any other sibling wrapper. Anything two or more wrappers would write identically belongs in kglite::api.

Applied in both directions: lift wheel-side code to core when any binding would write it identically; demote core code back to a wrapper when only that wrapper can use it. Eight commits totalling ~2,300 lines of net change in service of one goal.

Added — kglite::api surface

  • kglite::api::blueprint — pure-Rust blueprint loader + builder is now part of the curated stable API. Re-exports build, load_blueprint_file, Blueprint, Settings, NodeSpec, Connections, FkEdge, JunctionEdge, TimeKey, TimeseriesSpec, ComputeOp, CalendarLink, AggregateEdge, BuildReport, FlatSpec. The Python wheel’s from_blueprint has always been a thin wrapper around these — now any binding can call them directly without going through PyO3.

  • kglite::api::datasets::{sec, sodir, wikidata} — dataset fetch + extract building blocks now reachable through the curated stable API. Each submodule is feature-gated (matches the existing sec / sodir / wikidata Cargo features) and re-exports the same surface the Python wheel uses via _sec_internal / _sodir_internal / _wikidata_internal: workdir + storage-mode types, error + Result aliases, the HTTP client, the async fetch_* entry points, and (for SEC) the extract pipeline + size-prediction helpers. Lifecycle orchestration (cache short-circuit, mode selection, retry budgets) stays in each binding’s wrapper — the Python wheel’s wrappers at kglite/datasets/*/wrapper.py are the reference implementation.

  • Sync wrappers for every async fetch_* entry pointkglite::api::datasets::*::*_blocking for Wikidata, Sodir, SEC (13 functions total). Each spins up a single-thread tokio runtime via the new kglite::datasets::blocking::run helper. Bindings with their own async runtime drive the async variants; bindings without one use the blocking variants and let core manage the runtime per call.

  • kglite::api::datasets::sec — generic helpers lifted from the wheel:

    • SecFormBucket, ALL_BUCKETS, LEAN_FETCH_BUCKETS, resolve_fetch_buckets, all_buckets — the SEC form-type → per-filing-fetcher bucket mapping is now canonical in core. The Python wheel’s _FORM_BUCKETS table is sourced from Rust at import time.

    • parse_tickers_json — parses SEC’s company_tickers.json into a TICKER CIK HashMap. Lifted from the wheel’s _resolve_companies helper.

    • prepare_dispatch_plan + DispatchScope + DispatchPlan

      • FilingTask — read processed/filing_index.csv, apply company / year / form filters, group by bucket. The planning half of the wheel’s _dispatch_per_filing_fetches is now in core; execution half stays in the wrapper for now (see docs/internal/consider-for-future.md).

  • kglite::api::datasets::wikidata::{decide, CacheDecision, FreshnessInputs} + 3 helpers — the disk-cache freshness decision tree (force-rebuild flag → graph age → remote HEAD probe → cooldown comparison). 5 outcomes (Build, Load, Rebuild) with human-readable reason strings so bindings don’t re-derive the comparisons for verbose prints. Lifted from kglite/datasets/wikidata.py::open.

Changed — kglite::api discipline

  • infer_selection_node_type demoted from kglite::api re-exports. Identified by the reverse audit (see Docs below) as taking &CowSelection — a type only the wheel uses externally, so no other binding could meaningfully call this. Stays pub in crates/kglite/src/graph/handle.rs so the wheel reaches it via kglite_core::graph::handle:: infer_selection_node_type. When Selection gets lifted to a stable api type, both should move together.

  • discover_property_keys_from_data doc comment rewritten to remove Python-flavored language (“DataFrame-style exporters” → “any row-oriented exporter (CSV, Parquet, DataFrame, JSON-lines)”). No code change; the function signature is generic so the doc shouldn’t have claimed otherwise.

Docs

  • CLAUDE.md — boundary principle now formalised under Architecture (see “The boundary principle (north star for wrappers vs core)”). Applies in both directions; concrete examples; cross-references the binding-implementer guide.

  • docs/rust/implementing-a-binding.md — deep-dive companion to embedding.md and session.md for anyone publishing a new- language binding. Covers the bridge-layer choice (Rust direct vs language FFI vs the Phase H C ABI aspiration), the full KgErrorCode mapping table with recommended idioms per language family, an Embedder trait implementation walkthrough with an OpenAI-API-backed example, blueprint / .kgl / code_tree / dataset loading patterns, the binding-side cookbook (process cache, lazy materialization, value conversion, progress callbacks), and a cross-binding portability checklist. References the three existing reference implementations (kglite-py, kglite-bolt-server, kglite-mcp-server) as the canonical worked examples. New “Wrapping a dataset for your binding” chapter opens with the boundary rule and walks the six-step lifecycle common to all three datasets.

  • docs/internal/api-audit-2026-05-25.md — Phase 1 audit of the kglite::api surface ahead of the binding-implementer guide. Inventories the 29 + 2-submod current surface, classifies every #[pymethods] gap (Class A/B/C/D), and ranks a top-10 punchlist.

  • docs/internal/mcp-server-parity-2026-05-25.md — Phase 4 feature-parity audit between the Python MCP server (kglite/mcp_server/, 3548 LOC) and the Rust MCP server (crates/kglite-mcp-server/, 1974 LOC). Both ship; neither is being retired. 12 of 13 tools at full parity; the one tool gap is explore (Rust-native, Python lacks). Two “should converge” items deferred to consider-for-future.md; the rest are acceptable design intent. Includes an audience map for which server to pick for which deployment.

  • docs/internal/reverse-audit-2026-05-25.md — applies the boundary principle in reverse: which kglite::api::* items actually only one wrapper can use? Method, findings, decision rule for future api additions. One demotion (infer_selection_node_type), one doc-only cleanup (discover_property_keys_from_data); the other 9 audited items had generic signatures and stayed.

  • docs/internal/consider-for-future.md — parking-lot pattern for work that’s been deliberately deferred. Covers the full dataset-lifecycle lift, retiring the Python MCP server, from_blueprint lift, SEC ticker resolution lift, Wikidata process cache, Selection fluent-API lift, graph algorithms, result streaming, the Phase 1 audit’s items 2-10, the SEC dispatch execution loop lift, porting explore to Python MCP, lazy-load embedder in Rust MCP. Each entry has what / why-deferred / when-to-revisit / effort.

Internal

  • New Rust module crates/kglite/src/datasets/blocking.rs (shared tokio::runtime::block_on helper for sync bindings).

  • New Rust modules crates/kglite/src/datasets/sec/{blocking, buckets, tickers, dispatch}.rs housing the lifted SEC helpers + 27 new unit tests covering all variant cases.

  • New Rust module crates/kglite/src/datasets/wikidata/ freshness.rs with 6 unit tests covering the decision tree.

  • Python wrappers (kglite/datasets/{sec/wrapper.py, wikidata.py}) updated to delegate to the lifted core helpers instead of carrying their own copies. Net ~80 LOC of Python deleted; net ~700 LOC of core code added (more than a 1:1 trade because the core versions carry doc + tests).

Removed — none.

No public API removals. Items demoted from kglite::api stay reachable through deeper paths (kglite_core::graph::handle::*, etc.) — the demotion is a stability claim adjustment, not a code removal.

[0.10.1] — 2026-05-25 — Polars-style crate split (Phase G), Bolt Phase F driver-compat fixes, two-track docs, crates.io publish (kglite + kglite-bolt-server + kglite-mcp-server)

The headline of 0.10.1 is the polars-style core split: kglite is now a pure-Rust crate (crates/kglite/, zero pyo3 in the dep tree) publishable to crates.io as a standalone library. The Python wheel (pip install kglite) is unchanged — same install line, same Python API, same kglite-mcp-server console script. The wheel is now built by a sibling PyO3 wrapper crate (crates/kglite-py/).

Three landings in one release:

  • Phase G — crate split + workspace reorganization. cargo install kglite-bolt-server (no Python) now works; embedders depend on kglite = "0.10" with no PyO3 inherited.

  • Phase F — three Bolt driver-compatibility fixes: TLS via --tls-cert/--tls-key (so bolt+s:// and neo4j+s:// work), neo4j:// routing URIs via single-server routing table (--advertise-addr for reverse-proxy deploys), and Neo4j- conventional db.labels() / db.relationshipTypes() yield column names (label, relationshipType).

  • Two-track docs reorganizationdocs/python/ and docs/rust/ now live alongside docs/operators/, docs/concepts/, and the existing docs/reference/. URL breakage from the old /explanation/X paths is acknowledged; ReadTheDocs per-path redirects will be configured post-deploy.

Plus everything tracked in [Unreleased] below (validate_schema exposure, the kglite::api::session standardization landed in 0.10.0 Phase E, the Bolt protocol C.1–C.6 implementation, polars-style core split itself, two-track docs + cleanup of stale kglite-core references, crates.io publish prep with parallel-bz2 decoupling).

pip install kglite users see no behavior change. Rust embedders get a new option: cargo add kglite. Bolt-server operators get production-grade TLS + Neo4j-driver compatibility. Wheel + Bolt- server CHANGELOG details are below; the Rust crate’s docs.rs page will go live with the crates.io publish.

Added — Bolt server Phase F (TLS, neo4j:// routing, db.* yield naming)

Three driver-compatibility fixes captured during the C.5 robustness pass that needed real work to close. All land in kglite-bolt-server.

--tls-cert / --tls-key — Bolt over TLS (Phase F #6)

bolt+s:// and neo4j+s:// URIs now work. Drivers that require TLS (production Neo4j deployments + most cloud setups) connect unchanged. PEM-encoded cert chain and private key files via two new CLI flags:

kglite-bolt-server --graph my.kgl \
    --tls-cert ./cert.pem --tls-key ./key.pem \
    --bind 0.0.0.0 --port 7687

Implementation: boltr 0.2 with the tls feature, rustls = 0.23 with the ring crypto provider explicitly installed at startup (rustls 0.23+ requires the consumer to choose the provider; we install ring::default_provider()), boltr::server::TlsConfig::from_pem(...) wired into the existing BoltServer::builder(...).

neo4j:// routing URIs (Phase F #5)

Drivers that prefer the neo4j:// scheme (most cluster-aware clients default to it; Neo4j Browser uses it; some LangChain flows hardcode it) make a ROUTE call on connect to discover the cluster topology. We were returning Neo.ClientError.Routing.RoutingTableNotFound, which made neo4j:// URIs fail unless the user explicitly switched to bolt://.

Now BoltBackend::route() returns a single-server routing table with the configured address in WRITE / READ / ROUTE roles. New --advertise-addr HOST:PORT flag for reverse-proxy deployments (the address advertised in the routing table may differ from --bind). When omitted, falls back to the bind address.

# Behind a reverse proxy at public.example.com:
kglite-bolt-server --graph my.kgl \
    --bind 0.0.0.0 --port 7687 \
    --advertise-addr public.example.com:7687

db.labels() / db.relationshipTypes() column names (Phase F #7)

These procedures previously yielded a column named name for both, while Neo4j convention (which all drivers + dashboards expect) is label for db.labels() and relationshipType for db.relationshipTypes(). Tools that pre-fill schema panels from those columns silently broke against kglite-bolt-server. Now matches Neo4j’s naming exactly. The valid_yields table in the CALL clause executor was split per-procedure so the planner can validate the right column names per call site.

Fixed — create_index / create_range_index / create_composite_index returned 0 entries on reloaded .kgl graphs

A user-facing perf foot-gun surfaced during competitive benchmarking: calling create_index("Person", "ssn") on a graph loaded from a .kgl file silently returned 0 entries, even when the graph had 500k Person nodes. The auto-rebuild on load (rebuild_indices_from_keys, called from crates/kglite/src/graph/io/file.rs:1818) suffered the same bug, so users who created indexes pre-save lost them on reload without any error or warning. The result: MATCH patterns that should hit the index fell back to full scans, with no signal.

Two root causes, both addressed:

  1. NodeData::get_property() only reads the in-memory snapshot. For mapped/disk graphs loaded from .kgl, property values live in a backend-managed column store; NodeData.properties is the stripped-and-restored shell. The matcher’s hot path (core/pattern_matching/matcher.rs::node_matches_properties_columnar) uses GraphBackend::get_node_property() instead, which dispatches per-backend to the right storage. The three create_*_index methods on DirGraph (dir_graph.rs:815, 940, 985) now mirror that path.

  2. id / title are special-cased — not in properties at all. Their values live on dedicated NodeData fields and on the per-type id_index. Indexes on title-aliases (e.g. name) or id-aliases (e.g. starId) need alias resolution + the get_node_id / get_node_title accessors to populate correctly. The fix adds resolve_alias() + special-cased reads in each create_*_index (mirroring the matcher pattern at matcher.rs:1177-1182).

The property_indices HashMap is still keyed by the user-facing property name (e.g. name, not title) because the matcher’s try_index_lookup (matcher.rs:850) looks up by the unresolved key — keeping storage / lookup / auto-maintenance keys in lockstep.

A small follow-on fix in crates/kglite/src/graph/languages/cypher/executor/write.rs:558 makes the index-maintenance old-value capture also alias-aware for the name / title cases, so SET-on-title-alias correctly updates an index that was built from get_node_title. Without this, the auto-maintenance silently drifts.

Regression coverage

Six new tests in tests/test_indexes.py::TestIndexRebuildAfterReload exercise the round-trip (build → save → reload → create_index/ create_range_index/create_composite_index) for: id-alias names, the canonical id key, a plain property, range index, composite index, and a composite mixing an id-alias with a plain property. The 13 existing index-auto-maintenance tests continue to pass (one regressed during the fix iteration; root-caused to the SET path’s old-value capture).

Bench infrastructure

A shortestPath competitive bench landed under the new /benchmarks/competitive/ area (untracked; gitignored for competitive library comparisons). It exercises both surfaces — kglite-py (wheel) and kglite (Rust core) — against the same .kgl graph on all three storage modes. The wheel side reuses the existing comparison builder via a thin driver; the Rust side is a small standalone Cargo project calling kglite::api::session::execute_read.

Initial runs surfaced an unexplained measurement inversion (the wheel measuring faster than the Rust core it wraps), which is physically impossible given the architecture. Tracked as a post-0.10.1 investigation — likely a bench harness issue (LTO scope / first-call lazy init / per-iteration overhead) rather than a shipped-code defect. The headline “shortestPath ~0.07-0.5ms on a 500k-node Wikidata-scale graph” numbers from 0.10.0’s release notes were against the freshly-built graph (warm caches + live index data); reload-state perf for shortestPath specifically is ~10× slower because BFS traversal cost is distinct from endpoint-lookup cost — endpoint lookups for MATCH (n:T {alias: $x}) remain fast (~1ms) on reloaded graphs via the id_indices auto-rebuild path.

Internal — Batch 2 kglite-py → kglite lifts (LIFT-low + HYBRID extracts)

Continuing the polars-style cleanup, lifted every remaining non-Python-specific item from kglite-py into kglite (core). With Batch 1 (the earlier section below) this brings the wheel to as thin as it can be: everything left is genuinely PyO3-specific (#[pyclass]/#[pymethods], PyDict/PyList extraction, GIL handling, Python embedder bridging).

LIFT-low — pure-Rust items moved to core with the wheel delegating in 1-line wrappers:

Item

New home

Notes

field_contains_ci, field_starts_with_ci

Methods on NodeData (kglite::graph::schema)

Now natural API: node.field_contains_ci("name", &needle_lower). Call sites in pyapi/kg_fluent.rs updated. Wheel’s static-method wrappers deleted entirely.

discover_property_keys_from_data

kglite::api (in handle module)

Generic property-key discovery for DataFrame/Arrow exporters.

infer_selection_node_type

kglite::api (free function)

Takes (&CowSelection, &Arc<DirGraph>) — no longer wired to the wheel’s KnowledgeGraph struct.

build_slice (SEC)

SliceSpec::from_optional_filters

Constructor on the existing public type.

disk_graph_age_days (Sodir)

Workdir::disk_graph_age_days method

Natural API: wd.disk_graph_age_days().

HYBRID — pure-Rust cores extracted; wheel keeps only the PyDict/PyAny → typed-args extraction layer:

Item

Core API

Wheel becomes

parse_inline_timeseries

InlineTimeseriesConfig::from_components(time_col, time_components, channels, resolution, units)

PyDict extraction + 1-line constructor call

parse_spatial_column_types

kglite::api::parse_spatial_column_types_from_pairs(pairs)

PyDict → Vec<(String, String)> + delegation

parse_temporal_column_types

kglite::api::parse_temporal_column_types_from_pairs(pairs)

Same shape

parse_method_param

MethodConfig::from_components(...)

Per-field extract() + constructor call

Two items deliberately stay in kglite-py even though they look like LIFT candidates:

  • preprocess_values_owned — single-variant enum wrapper scaffolding for the Cypher → PyAny conversion phase. No core to lift; the wrapper does nothing useful outside the Python conversion pipeline.

  • json_value_to_py (in mcp_tools.rs) — the function’s job IS serde_json::ValuePyAny conversion. The “pure-Rust core” would be a no-op since serde_json::Value already exists in core; only the PyO3 conversion is wheel-specific.

Internal — Batch 1 kglite-py → kglite lifts

Architectural cleanup continuing the polars-style split. Per the “kglite-py holds ONLY Python-specific code” principle, five pure-Rust items that had been trapped in the wheel crate moved to core. The wheel keeps 1-line pub(crate) use ... as ... re-exports so the existing call sites in graph/pyapi/*.rs compile unchanged.

Item

New home

Why

resolve_noderefs(graph, rows)

kglite::api::session::resolve_noderefs

Post-execute cleanup — replaces Value::NodeRef with node titles. Every Cypher-emitting binding needs this; previously trapped in the wheel.

TimeSpec enum

kglite::api::TimeSpec (re-export of graph::features::timeseries::TimeSpec)

Pure-Rust data shape for inline timeseries config.

InlineTimeseriesConfig struct

kglite::api::InlineTimeseriesConfig

Same. Includes all_columns() helper.

get_graph_mut(arc)make_dir_graph_mut(arc)

kglite::api::make_dir_graph_mut (defined in graph::dir_graph)

Arc::make_mut + version increment. Generic Arc mutation helper for any binding. Renamed during the lift to match Rust naming conventions; wheel keeps the old name via use ... as get_graph_mut.

merge_blueprint(base, complement, overrides)

kglite::datasets::sodir::merge_blueprint_json

JSON-string deep-merge wrapper for Sodir blueprints. CLI tools and other Rust consumers that work with on-disk JSON now have a single entry point.

All five are 100% pyo3-free; verified by cargo tree -p kglite | grep pyo3 (empty) and confirmed by the audit Explore agent’s transitive-dep trace. The wheel’s call sites stay unchanged; the engine logic moves once and stays in one place.

Saved as memory ([[feedback-kglite-py-python-only]]): the principle is “kglite-py contains ONLY PyO3 bindings + Python type conversions”; engine logic belongs in kglite (core). Future work batches the remaining HYBRID candidates (parse_spatial_column_types, parse_temporal_column_types, parse_inline_timeseries, parse_method_param, json_value_to_py) into 0.11.0 — each needs a real refactor to split the pure-Rust core from the PyDict-extraction wrapper.

Fixed — kglite-mcp-server can now publish to crates.io (pyo3 lifted out of its dep tree)

kglite-mcp-server’s Cargo.toml previously depended on kglite-py (the wheel crate, which has pyo3 in its dep tree) because it used KnowledgeGraph::source_location, KnowledgeGraph::set_embedder_native, and other methods that lived on the wheel’s #[pyclass]-decorated KnowledgeGraph struct. The binary’s README claimed “No libpython link” — that claim was false (verified via cargo tree -p kglite-mcp-server | grep pyo3 → showed pyo3 v0.28.3 and friends).

The lift moves the heavy logic — source_location (50 lines) + resolve_code_entity (78-line helper) + CODE_TYPES const — into a new pure-Rust module at crates/kglite/src/graph/handle.rs as free functions. A new thin kglite::api::KnowledgeGraph struct (2 fields: Arc<DirGraph> + Option<Arc<dyn Embedder>>) bundles the binding-side convenience surface (from_arc, dir, set_embedder_native, embedder, source_location) without the wheel’s full state (selection / reports / mutation stats / temporal context / default timeout / max-rows).

Two types now share the KnowledgeGraph name across the workspace, in different crates with different audiences — mirrors the polars pattern (polars::DataFrame vs polars.DataFrame):

Crate

Type

Audience

kglite (core)

kglite::api::KnowledgeGraph

Rust embedders. Pure-Rust. 2 fields. No pyo3.

kglite-py (wheel)

kglite_py::KnowledgeGraph

Python users via pip install kglite. PyO3-decorated. 8 fields. The wheel’s heavy methods now delegate to the core’s free functions for single-source-of-truth.

mcp-server’s Cargo.toml switches its kglite dep from { package = "kglite-py", path = "../kglite-py" } to { version = "0.10", path = "../kglite" }. Mcp-server’s source needed zero changes — every kglite::api::* import resolves to the same item under the new dep, just routed through core’s api mod instead of the wheel’s re-exports. Verified:

cargo tree -p kglite-mcp-server | grep pyo3     # → empty

publish = false removed from crates/kglite-mcp-server/Cargo.toml. The crate publishes to crates.io alongside kglite and kglite-bolt-server in this release — three crates in the same 0.10.1 publish cycle, orchestrated by .github/workflows/publish_crates.yml.

Net effect: cargo install kglite-mcp-server works without a Python runtime, matching the binary’s README claim.

The wheel’s KnowledgeGraph::source_location / KnowledgeGraph::resolve_code_entity methods stay on the PyO3-decorated struct for back-compat with Python callers via #[pymethods], now implemented as 1-line delegates to the core’s free functions. kg_fluent::find_one’s Self::CODE_TYPES reference now points at kglite_core::graph::handle::CODE_TYPES. No behavior change.

Added — crates.io publish prep for the Rust crates

The pure-Rust kglite core crate (and the standalone kglite-bolt-server binary) are now metadata-complete for publishing to crates.io.

  • crates/kglite/ — added readme = "README.md", repository, homepage, documentation = "https://docs.rs/kglite", keywords = ["graph", "knowledge-graph", "cypher", "petgraph", "database"], categories = ["database", "data-structures"]. New crates/kglite/README.md (~140 lines) tailored for the crates.io audience.

  • crates/kglite-bolt-server/ — same metadata pattern, with Bolt/Neo4j-flavored keywords. Version bumped 0.0.1 0.10.1 to align with the wheel’s 0.10.x line. New crates/kglite-bolt-server/README.md.

  • crates/kglite-mcp-server/ — metadata + README written but marked publish = false for now. The crate still depends on kglite-py (for KnowledgeGraph::set_embedder_native / source_location methods that live on the PyO3 wrapper type); publishing must wait until those methods get lifted into the core. Version bumped to 0.10.1 anyway for local consistency.

  • crates/kglite-py/ — explicitly marked publish = false with a comment explaining: the wheel is the right artifact for Python users (pip install kglite), and Rust users want the no-pyo3 path via the sibling kglite crate.

cargo publish -p kglite --dry-run passes: 267 files, 5.1 MiB (1.1 MiB compressed). cargo publish -p kglite-bolt-server --dry-run succeeds once kglite is actually published (chicken- and-egg; the bolt server depends on the core crate that has to land first).

parallel-bz2 decoupling

In the process, the single-stream bz2 parallel-decode path was restructured so cargo publish -p kglite resolves cleanly against crates.io.

Background: the path uses the paolobarbolini bzip2-rs git fork (adds ParallelDecoderReader + ThreadPool trait + a RayonThreadPool helper gated on the fork’s rayon Cargo feature). The crates.io bzip2-rs = 0.1.x release has none of these — only the sequential DecoderReader. Cargo’s publish-time manifest resolver requires every declared dep feature to be satisfiable against crates.io versions, which previously broke cargo publish.

Fix:

  • New optional Cargo feature kglite/parallel-bz2. Default-off; enables the optional bzip2-rs dep.

  • Implemented bzip2_rs::ThreadPool ourselves (graph::io::ntriples::parallel_bz2::KglRayonPool) on top of kglite’s existing rayon dep instead of using bzip2_rs::RayonThreadPool. Removes the requirement for the fork’s rayon cargo feature from the published manifest.

  • Workspace [patch.crates-io] pulls the fork during local development. The patch is stripped on cargo publish; crates.io consumers who enable kglite/parallel-bz2 need their own matching patch until upstream bzip2-rs publishes a 0.2.x with these APIs.

  • Single-stream fallback when parallel-bz2 is off is sequential bzip2::read::MultiBzDecoder. Multi-stream pbzip2 parallelism is unaffected.

  • The wikidata Cargo feature implies parallel-bz2 (Wikidata ingest is the workload that needs it).

All 11 parallel_bz2 unit tests pass on both feature-on and feature-off configurations. The bz2_bench binary requires --features parallel-bz2.

Changed — Docs reorganized into two-track Python / Rust layout

kglite.readthedocs.io now groups content by audience instead of by document type. The existing docs/explanation/ is gone; everything moved to one of five top-level tracks:

Track

What’s there

Audience

docs/python/

getting-started, core-concepts, transactions, error-handling, value-projection, all of guides/, examples/, migrations/

Wheel users (pip install kglite)

docs/rust/

index (Rust quickstart), embedding, session, api-reference

Rust embedders depending on the kglite crate directly

docs/operators/

bolt-server

Operators deploying kglite-bolt-server or kglite-mcp-server

docs/concepts/

architecture, design-decisions, concurrency, cypher-conformance, multi-label-rationale, adding-a-storage-backend, adding-a-query-language

Contributors, curious users wondering “why is it built this way”

docs/reference/

cypher-reference, fluent-api, auto-generated Python API (unchanged location)

Cross-binding reference

docs/index.md rewrites as a track selector (one entry per track) and the per-track index.md files act as navigators into their own contents.

URL breakage warning. Bookmarks of the form kglite.readthedocs.io/en/latest/explanation/<X>.html no longer resolve. The new paths are:

Old path

New path

/explanation/transactions

/python/transactions

/explanation/error-handling

/python/error-handling

/explanation/value-projection

/python/value-projection

/explanation/embedding-kglite

/rust/embedding

/explanation/session

/rust/session

/explanation/bolt-server

/operators/bolt-server

/explanation/architecture

/concepts/architecture

/explanation/design-decisions

/concepts/design-decisions

/explanation/concurrency

/concepts/concurrency

/explanation/cypher-conformance

/concepts/cypher-conformance

/explanation/multi-label-rationale

/concepts/multi-label-rationale

/getting-started

/python/getting-started

/core-concepts

/python/core-concepts

/guides/<X>

/python/guides/<X>

/examples/<X>

/python/examples/<X>

/migrations/<X>

/python/migrations/<X>

/adding-a-storage-backend

/concepts/adding-a-storage-backend

/adding-a-query-language

/concepts/adding-a-query-language

ReadTheDocs per-path redirects will be configured via the RTD admin UI post-deploy to keep stale bookmarks working.

All file moves used git mv for blame/history preservation. The Sphinx build (with -W warnings-as-errors at warning gates that already cleared in CI) is green; pytest 3013+1, bolt 236+3, and make lint are unaffected.

Fixed — Stale kglite-core / kglite_core references after the G.4 rename

G.4 renamed the core crate from kglite-core to kglite but the in-flight G.5 work (examples + embedder doc) was authored before that rename and never updated. This swept up the leftovers:

  • crates/kglite/examples/embedded_{basic,session,blueprint}.rs and crates/kglite/tests/datasets_{sec_idx_parser,sec_fetch_live,sodir_fetch_live}.rskglite_core::* imports and cargo run -p kglite-core doc-comment invocations updated to kglite::* / -p kglite. Examples now compile (cargo build -p kglite --release --examples) and run; the embedded_session OCC sequence correctly rejects Transaction B with ConflictDetected.

  • In-code module doc comments in crates/kglite/src/{lib,datatypes/mod,graph/io/file,graph/languages/cypher/mod,code_tree/mod,code_tree/builder/mod,code_tree/builder/load}.rs no longer describe the crate as “kglite-core” or “Currently named kglite-core to avoid a workspace conflict” (the conflict was resolved by the rename — that paragraph was historical noise).

  • crates/kglite-py/src/** — clarifying comments on the kglite_core = { package = "kglite", ... } dep alias (the alias dodges the extern-crate collision with kglite-py’s own [lib] name = "kglite_py"; the engine itself is the kglite crate).

  • ROADMAP.md + bolt_implementation.md updated to reference kglite::api::* (the post-G.4 surface) rather than the historical kglite_core::*.

CHANGELOG entries for the Phase G journey itself are preserved unchanged with an upstream “Note on naming” caveat — the references to kglite-core in those entries describe the journey faithfully.

Internal — Polars-style core split (Phase G of bolt_implementation.md)

Note on naming. The first commits of Phase G used the temporary package name kglite-core to avoid a workspace conflict with the then-existing root kglite crate. The G.4 commit (5eecf51) renamed it to kglite and relocated the pyo3 wrapper to crates/kglite-py/. The references to kglite-core below describe the journey faithfully; the end-state crate is named kglite everywhere now.

The Rust core moves out of the wheel crate into a pure-Rust sibling crate at crates/kglite/ (initially package-named kglite-core, renamed to kglite in G.4). The PyO3 wrapper sits at crates/kglite-py/ and depends on the core via kglite = { path = "../kglite", ... }.

Why — Polars precedent: kglite’s engine has always been pure Rust, but pyo3 was an unconditional dep of the only crate that held it. Rust embedders (anyone wanting kglite as a graph library without the Python wheel) inherited pyo3’s build complexity. The split fixes that.

End-state verified by cargo tree:

  • cargo tree -p kglite-core | grep pyo3empty

  • cargo tree -p kglite-bolt-server | grep pyo3empty ✓ (switched to kglite = { package = "kglite-core" } direct dep)

  • cargo tree -p kglite-mcp-server | grep pyo3 → still present (uses KnowledgeGraph::source_location etc. that live in the pyo3 wrapper; cleanup deferred)

  • cargo tree -p kglite | grep pyo3 → present (this is the wheel — expected)

Highlights:

  • Dataset crates mergedcrates/kglite-{sec,sodir,wikidata}/ folded into crates/kglite/src/datasets/{sec,sodir,wikidata}/ behind features (sec, sodir, wikidata). Workspace down from 7 → 4 members. Polars-io pattern: opt in to dataset loaders only when you use them.

  • 117 KgError→PyErr sites converted to use a kg_to_pyerr() helper, fixing the orphan-rule violation that would otherwise block the move (impl From<KgError> for PyErr becomes invalid once KgError lives outside the wrapper crate).

  • Visibility bumps on DirGraph — ~23 pub(crate) fields

    • 6 helpers (resolve_node_property, MethodConfig, etc.) promoted to pub for cross-crate access. Pragmatic “wide public” choice over a ~25-method accessor refactor; tracked as a follow-up.

  • Embedder examples + binding-implementer guidecrates/kglite/examples/embedded_{basic,session,blueprint}.rs run cleanly with cargo run -p kglite-core --example . New docs/explanation/embedding-kglite.md walks through the surface, the .kgl portability story, and sketches cgo / napi / JNI wrappers for future bindings.

  • pip install kglite unchanged for Python users. Same wheel, same Python API, same kglite-mcp-server console script. The split is invisible from PyPI’s side.

Verification (~12 minutes wall-clock):

  • cargo build --workspace --release green (~3min)

  • cargo run -p kglite-core --example embedded_session works

  • cargo run -p kglite-core --example embedded_blueprint works

  • pytest tests/ → 3013 + 1 skipped (unchanged)

  • pytest tests/ -m bolt → 233 + 3 skipped (unchanged)

  • pytest tests/ -m bolt_stress → 9 (unchanged)

  • make lint clean

Internal — kglite::api::session standardization (Phase E of bolt_implementation.md)

Single source of truth for the canonical Cypher pipeline and the snapshot/working CoW transaction model. The same orchestration previously lived in three places (pyapi cypher(), mcp-server run_cypher_inner, bolt-server KgliteBackend) and the CoW machinery in two — drift had already cost correctness twice in this sprint (validate_schema missing from mcp/bolt; bolt’s lazy- RETURN bug from accidentally including mark_lazy_eligibility). Phase E extracts both into kglite::api::session::*, then rewrites all three consumers as thin wrappers.

  • New module src/graph/session/Session, Transaction, CommitOutcome, ExecuteOptions, ExecuteOutcome, execute_read, execute_mut. Pure Rust, no PyO3, no async. 13 unit tests pin the contract (snapshot isolation, working CoW materialization, OCC conflict detection, read-only enforcement, no-writes commit fast path).

  • OCC now enforced in bolt-server (closes limitation #1 of the 7 captured during the C.5 robustness pass). Concurrent writers whose snapshots become stale see ClientError("Transaction conflict: graph was modified by another committer ... Retry the transaction.") on commit. The bolt-stress concurrency tests now use a retry-on-conflict pattern that mirrors what real clients should do.

  • pyapi, mcp-server, bolt-server consumers rewritten to wrap the session module. kg_core::cypher body shrank from ~280 to ~80 lines; Transaction.cypher from ~150 to ~40; mcp’s run_cypher_inner from ~80 to ~30; bolt-server’s backend pipeline from ~325 to ~150. Net ~440 lines new (session + docs), ~735 lines deleted across consumers.

  • Foundation for future bindings. A Go binding (cgo) or TypeScript (napi) or JVM (JNI) is now a marshalling layer over session::execute_* + Session / Transaction handles — the pipeline + CoW + OCC are solved once.

  • docs/explanation/session.md — binding-implementer guide covering the API surface, snapshot isolation guarantees, the per-binding concurrency model, and a sketch of how to wrap from cgo. Read this if you’re integrating kglite into a new language runtime or transport.

No user-visible behavior change for the Python cypher() / Transaction surface (3013 tests pass unchanged). The bolt contract change — OCC-enforced commits returning ClientError on conflict — is intentional (the pre-E behavior was last-writer-wins with no warning, captured as limitation #1).

Internal — Bolt protocol scaffolding (Phase B of bolt_implementation.md)

Pre-implementation scaffolding for the Bolt v5.x wire-protocol server. No user-visible surface yet; this lands the crate skeleton, the failing-by-design test contract, and the perf baseline that Phase C sub-phases will retire.

  • New crate crates/kglite-bolt-server/ with clap CLI (--graph, --bind, --port, --readonly, --auth, --idle-timeout, --max-sessions) and a BoltBackend impl whose 11 trait methods all panic with unimplemented!("phase C.X ..."). The binary boots, loads a .kgl graph, binds a TCP port via boltr::BoltServer::serve, and panics the per-connection task on the first real Bolt message. Compiles green; the trait signatures pin the boltr v0.2.0 API surface against kglite’s kglite::api::* types.

  • New tests/test_bolt_server_smoke.py — 8 xfail(strict=True) tests using the neo4j Python driver. Each is tagged with the Phase C sub-phase that retires it (C.1 handshake, C.2 scalars, C.3 parameters, C.4 Node/Rel, C.5 transactions/readonly, C.6 auth

    • FAILURE mapping). Strict mode means accidentally fixing a test out-of-phase turns XFAIL into XPASS → FAIL — the alert that the decorator should be removed.

  • New pyproject.toml marker bolt — Bolt-protocol smoke tests excluded from the default pytest tests/ run via addopts; opt-in via pytest -m bolt. Mirrors the existing binary_size / parity pattern.

  • New benchmarks test_bench_return_node_10k + test_bench_return_node_rel_node_100 in tests/benchmarks/test_bench_core.py. Cover the Value::Node projection paths that Phase A.1 added and Phase C.4 will route over Bolt PackStream. Baseline capture deferred to the next release commit (Phase D, or whichever 0.10.x ships first) via make refresh-release-constants.

  • CI: cargo build --release now also builds -p kglite-bolt-server, the Python install line gains the [neo4j] extra, and a dedicated pytest -m bolt step runs the failing contract on every push.

This is prep work, not a feature release. No Cargo.toml version bump.

Added — validate_schema extended to CREATE/MERGE patterns + exposed in api

Two changes folded into one user-visible improvement:

Fortification. src/graph/languages/cypher/planner/schema_check.rs previously skipped CREATE and MERGE clauses (explicit => {} arm), even though those clauses’ pattern-literal property names are exactly the same “unambiguously a property name” shape that MATCH validates. Now extended:

  • CREATE (:Person {ttle: 'Alice'}) — catches the typo with “Unknown property ‘ttle’ on Person.<did_you_mean>”.

  • CREATE (a:Person {age: 30})-[:KNOWS]->(b:Person {agee: 25}) — walks multi-element paths.

  • MERGE (:Person {agee: 30}) — same path via MergeClause.pattern.

  • ON CREATE SET / ON MATCH SET still skip (use SetItem, deferred).

Zero false positives preserved — same gate as the MATCH path: validate_property skips when the type has no declared metadata. Tests grew from 13 to 21.

Exposure. User flagged a real gap: the Python boundary (src/graph/pyapi/kg_core.rs) has called validate_schema between parse and optimize since 0.9.x to catch property typos in pattern literals — but the pure-Rust kglite-mcp-server and (newly added) kglite-bolt-server were both missing the pass. The function was internal-only.

  • kglite::api::cypher::validate_schema: new pub use in src/lib.rs.

  • crates/kglite-mcp-server/src/tools.rs: adds the call right after parse_cypher. Error mapped to String (matches the existing mcp-server error pipeline).

  • crates/kglite-bolt-server/src/backend.rs::execute: adds the call as pipeline step 2 (renumbered the rest). Error mapped to BoltError::Protocol (genuine client error — bad property name → Neo.ClientError.Request.Invalid on the wire). Distinct from the BoltError::Backend-mapped “feature pending” errors C.2/C.3 use for slices we haven’t shipped yet.

All four downstream Cypher consumers (Python cypher(), MCP cypher_query, Bolt execute, the tests/test_schema.py agent helper via KnowledgeGraph.validate_schema()) now share the same hardened pre-flight check.

Added — kglite::api::cypher::validate_schema exposed; both pure-Rust servers now run it (superseded)

(See the section above; this entry kept as a pointer for git log archaeology — superseded by the fortification work that landed in the same commit.)

User flagged a real gap: the Python boundary (src/graph/pyapi/kg_core.rs) has called validate_schema between parse and optimize since 0.9.x to catch property typos in pattern literals ({ttle: 'Alice'} when only title exists on Person) — so users see “Unknown property ttle on type Person — did you mean title?” instead of silently getting zero rows. The pure-Rust kglite-mcp-server and (newly added) kglite-bolt-server were both missing this pass.

  • kglite::api::cypher::validate_schema: new pub use in src/lib.rs (was only reachable via the internal crate::graph:: languages::cypher::* path, which the api docs explicitly warn downstream consumers away from).

  • crates/kglite-mcp-server/src/tools.rs: adds the call right after parse_cypher. Error mapped to String (matches the existing mcp-server error pipeline).

  • crates/kglite-bolt-server/src/backend.rs::execute: adds the call as pipeline step 2 (renumbered the rest). Error mapped to BoltError::Protocol (genuine client error — bad property name → Neo.ClientError.Request.Invalid on the wire). Distinct from the BoltError::Backend-mapped “feature pending” errors C.2/C.3 use for slices we haven’t shipped yet.

All three downstream Cypher consumers (Python cypher(), MCP cypher_query, Bolt execute) now behave identically wrt schema validation. Bolt smoke contract still reports 3 passed, 5 xfailed; MCP smoke still reports 32 passed.

Internal — Bolt protocol C.6 (typed FAILURE codes + --auth basic + db.* verified)

Final sub-phase of Phase C. All 8 smoke tests now pass; the bolt- server can stand in for a Neo4j instance for the broad happy-path contract (handshake, auth, scalar reads, parameterized queries, graph-structure returns, explicit transactions, –readonly, typed FAILURE codes, schema-introspection procs).

  • New module crates/kglite-bolt-server/src/error_map.rs: kg_to_bolt(KgError) -> BoltError::Query { code, message } with a 16-arm mapping table from KgErrorCode to Neo.{Class}.{Category}.{Title} status codes:

    • CypherSyntaxNeo.ClientError.Statement.SyntaxError

    • CypherTimeoutNeo.ClientError.Transaction.TransactionTimedOut

    • CypherTypeMismatchNeo.ClientError.Statement.TypeError

    • CypherExecutionNeo.DatabaseError.Statement.ExecutionFailed

    • SchemaNeo.ClientError.Schema.ConstraintValidationFailed

    • Validation / Expr / InvalidArgumentNeo.ClientError.Statement.ArgumentError

    • NodeNotFound / ConnectionNotFound / PropertyNotFoundNeo.ClientError.Statement.EntityNotFound

    • MissingArgumentNeo.ClientError.Statement.ParameterMissing

    • FileNotFound / FileFormat / FileIo / InternalNeo.DatabaseError.General.UnknownError

    • 2 unit tests pin the table shape (every code maps to a 4-segment Neo.* string; the SyntaxError case is asserted verbatim).

  • crates/kglite-bolt-server/src/backend.rs::execute: parse_cypher errors now route through kg_to_bolt instead of the BoltError::Backend(e.to_string()) fallback. Other error sources (rewrite_text_score, CypherExecutor::execute, execute_mutable) still return String from kglite — when those paths gain typed errors in a future refactor, they’ll auto-flow through the same mapper.

  • New module crates/kglite-bolt-server/src/auth.rs: BasicAuthValidator implements the boltr AuthValidator trait. Checks scheme + principal + credentials against the CLI’s --auth-user / --auth-pass; rejects with BoltError::Authentication (maps to Neo.ClientError.Security.Unauthorized).

  • crates/kglite-bolt-server/src/main.rs: wires BasicAuthValidator into BoltServer::builder().auth(...) when --auth basic is selected. --auth none (default) leaves no validator wired — boltr handles LOGON SUCCESS itself, accepting any credentials.

  • kglite::api::{KgError, KgErrorCode} newly exposed (src/lib.rs). The Python boundary already used them; bolt- server now does too.

  • db.* schema-introspection procs (db.labels, db.relationshipTypes, db.indexes): verified to work via the standard Cypher CALL pipeline. No bolt-server changes needed — Phase A.3 added the procs to kglite’s executor, they’re routed through the existing parse-plan-execute path, and they return scalar rows that the C.2 to_bolt arms handle directly.

  • Test contract: xfail removed from test_bolt_returns_failure_on_parse_error. pytest -m bolt -v now reports 8 passed, 0 xfailed (exit code 0). Strict-mode contract retired cleanly — every test turned green on exactly the sub-phase it was tagged for.

Internal — Bolt protocol C.5 (BEGIN/COMMIT/ROLLBACK + –readonly)

Fifth sub-phase of Phase C. Explicit transactions work end-to-end: session.begin_transaction()tx.run("CREATE ...")tx.commit() (or tx.rollback()). The --readonly CLI flag rejects mutations at both the auto-commit boundary and the begin_transaction boundary.

  • crates/kglite-bolt-server/src/backend.rs: significant restructure of KgliteBackend:

    • Storage changed from Arc<KnowledgeGraph> to Arc<Mutex<Arc<DirGraph>>> — outer mutex allows commits to swap the inner Arc; inner Arc allows readers to cheaply snapshot via Arc::clone.

    • New transactions: Arc<Mutex<HashMap<String, TxState>>> map tracks per-transaction state.

    • TxState mirrors src/graph/pyapi/transaction.rs’s snapshot/working CoW shape: snapshot: Option<Arc<DirGraph>>

      • working: Option<DirGraph>. First mutation materializes working via Arc::try_unwrap (free when this tx holds the only ref) or deep clone.

    • begin_transaction snapshots the current Arc, mints a tx-{N} handle, stores TxState. Rejects under --readonly.

    • commit swaps the working Arc into the backend’s shared graph (no-op if no mutations occurred — read-only-then-commit transactions are cheap).

    • rollback drops TxState (working copy discarded).

    • close_session / reset_session roll back all in-flight transactions for the session.

  • Auto-commit mutations remain rejected with a BoltError::Backend error pointing at “wrap in BEGIN/COMMIT”. Drivers always wrap writes in explicit transactions in practice; adding auto-commit mutations would broaden the surface for no real win.

  • --readonly enforcement: begin_transaction returns BoltError::Forbidden (“server is read-only — explicit transactions rejected”), which maps to Neo.ClientError.Security.Forbidden on the wire → driver raises ClientError. Auto-commit mutations also return Forbidden when --readonly is on (vs Backend when it isn’t).

  • execute pipeline refactored into plan + execute_auto_commit

    • execute_in_tx helpers on KgliteBackend. Per-query mutex hold is bounded; reads outside tx are wait-free apart from a single Arc::clone.

  • SUCCESS metadata: now includes stats dict (nodes-created, relationships-created, properties-set, etc.) when the result carries MutationStats. Reads still emit type: "r"; mutations emit type: "w".

  • OCC version checking deferred. DirGraph.version is pub(crate) and not exposed via kglite::api. The Python Transaction class uses it; bolt-server gets it when the accessor is added. For C.5 the test scenarios are sequential so no conflict is possible; concurrent-writer stress is a Phase D consideration.

  • kglite::api::cypher::CypherQuery newly exposed (src/lib.rs) — bolt-server needs the type to write its pipeline helper. The mcp-server and Python boundary already use it via the internal path.

  • crates/kglite-bolt-server/src/main.rs: backend constructor takes DirGraph (not Arc<KnowledgeGraph>); Arc::try_unwrap on the loaded KG’s inner Arc is free in the typical boot path.

  • Test contract: xfail removed from test_bolt_transaction_commit_and_rollback and test_bolt_rejects_writes_when_readonly. The latter test gains a dedicated bolt_server_readonly fixture that spawns its own --readonly server instance. pytest -m bolt -v now reports 7 passed, 1 xfailed (exit code 0). Only test #8 (parse-error → ClientError mapping) remains — scoped to C.6.

Internal — Bolt protocol C.4 (Node / Relationship / Path RETURN)

Fourth sub-phase of Phase C. Cypher queries returning graph structures now round-trip over Bolt — RETURN n materializes as a neo4j.graph.Node in the Python driver, RETURN r as neo4j.graph.Relationship, and (via the Path encoding scheme) RETURN p = (...)-[*]-(...) returns a neo4j.graph.Path.

  • crates/kglite-bolt-server/src/value_adapter.rs::to_bolt: graph-structure arms become real, replacing the Phase C.2 Err(BoltError::Backend("phase C.4 ...")) stubs:

    • Value::Node(node)BoltNode { id: i64, labels, properties, element_id: id.to_string() }. element_id is the stringified integer id (stable within one server lifetime — the contract drivers care about; drivers shouldn’t persist element_id long-term).

    • Value::Relationship(rel)BoltRelationship { id, start_node_id, end_node_id, rel_type, properties, element_id, start_element_id, end_element_id }. All *element_id fields stringify the numeric ids.

    • Value::Path(p)BoltPath { nodes, rels: Vec<BoltUnboundRelationship>, indices }. The indices field encodes the Neo4j path-traversal scheme: pairs of (signed-1-based-rel-index, 0-based-next-node-index) where sign = direction (+ outgoing, - incoming relative to path traversal). Direction inferred by comparing rel.start_id / rel.end_id against the surrounding node ids.

  • New helpers in value_adapter.rs: props_to_bolt_dict (recursive via to_bolt); path_to_bolt_path (handles the indices arithmetic + tracing-logs corrupt paths where a rel doesn’t connect its surrounding nodes).

  • kglite::api: now exposes NodeValue, RelValue, PathValue alongside Value (src/lib.rs). Downstream Rust consumers (the bolt-server’s path encoder, future Arrow/Polars exporters) can pattern-match the carriers without re-deriving accessors.

  • Test contract: xfail removed from test_bolt_return_node_yields_node_struct and test_bolt_return_relationship_yields_rel_struct. pytest -m bolt -v now reports 5 passed, 3 xfailed (exit code 0). Only tests #6 (BEGIN/COMMIT), #7 (--readonly enforcement), and #8 (parse-error → ClientError mapping) remain — all scoped to C.5 and C.6.

Value::NodeRef(_) still returns an error (it’s an internal executor placeholder that should never reach the boundary; leaking it indicates a kglite bug).

Internal — Bolt protocol C.3 (parameter PackStream decoding)

Third sub-phase of Phase C. The Bolt server now accepts parameterized queries — session.run("MATCH (n:Person {city: $c}) RETURN n.title", c="Oslo") works against a bolt:// driver.

  • crates/kglite-bolt-server/src/value_adapter.rs::from_bolt: replaces the unimplemented!() stub. Scalar arms (Null/Bool/Integer/Float/String) + recursive List/Dict + temporal (Date → Value::DateTime via epoch arithmetic) + Duration + Point2D (SRID 4326 only). Non-representable inbound types surface as BoltError::Protocol (which maps to Neo.ClientError.Request.Invalid on the wire — these are genuine client errors, distinct from the BoltError::Backend / Neo.DatabaseError.* “feature pending” pattern that C.2 established).

  • crates/kglite-bolt-server/src/backend.rs::execute: the empty-params gate is gone; parameters now flow through value_adapter::from_bolt into the executor’s &kg_params map.

  • Rejected inbound types (each with a structured error message): Bytes (no kglite Value variant), Time/LocalTime/DateTime/ DateTimeZoneId/LocalDateTime (kglite has date-only precision — Phase A.1 deferred time precision), Point3D (kglite is 2D only), Node/Relationship/Path/UnboundRelationship (drivers shouldn’t pass these as params anyway).

  • Test contract: xfail removed from test_bolt_run_supports_parameters; pytest -m bolt -v now reports 3 passed, 5 xfailed (exit code 0).

Internal — Bolt protocol C.2 (read-only RUN/PULL with scalar values)

Second sub-phase of Phase C. The Bolt server now runs real Cypher queries end-to-end for scalar-returning reads. verify_connectivity

  • session.run("MATCH (n:Person) RETURN n.title AS name") works against a bolt:// driver; mutations, parameters, and Node/Rel returns still fail by design.

  • crates/kglite-bolt-server/src/backend.rs::execute: replaces unimplemented!() with the canonical kglite Cypher pipeline (mirrors kg_core.rs::cypher / kglite-mcp-server/src/tools.rs): parse → rewrite_text_score → optimize_with_disabled → mark_lazy_eligibility → mutation gate → CypherExecutor::with_params (dir, &params, None).with_streaming(false).execute(&parsed).

  • crates/kglite-bolt-server/src/value_adapter.rs::to_bolt: signature changed from BoltValue to Result<BoltValue, BoltError> (graph-structure arms must not panic mid-connection — they orphan tokio tasks). All 10 scalar variants now real: Null/Bool/Int64/UniqueId/Float64/String + recursive List/Map + Date/Duration/Point. Node/Relationship/Path return a structured Err(BoltError::Backend("phase C.4 ...")).

  • CLI surface: gates non-empty parameters (Phase C.3), explicit transactions (Phase C.5), Cypher mutations (Phase C.5), and text_score queries (Phase D) with clean BoltError::Backend messages — each maps to Neo.DatabaseError.General.UnknownError on the wire, so tests #3-#8’s pytest.raises(ClientError) checks don’t catch them and the strict-xfail contract holds.

  • crates/kglite-bolt-server/Cargo.toml: adds chrono as a direct dep (was transitive via kglite); needed for Value::DateTimeBoltDate arithmetic (days-since-Unix-epoch).

  • Test contract: xfail removed from test_bolt_run_returns_scalar_rows; pytest -m bolt -v now reports 2 passed, 6 xfailed (exit code 0).

  • bolt_implementation.md: Phase C summary row updated to C.1, C.2 Shipped · C.3–C.6 pending; C.2 sub-section heading flipped + body rewritten to reflect what shipped.

The server is now a usable thin Bolt frontend for scalar-only read-only Cypher. SUCCESS metadata includes type: "r" + t_last (elapsed ms). The lazy-result-descriptor streaming path is forced off (.with_streaming(false)) for simplicity; revisit in Phase D if profiling demands it.

Internal — Bolt protocol C.1 (handshake + session lifecycle)

First sub-phase of Phase C. The Bolt server is now connectable — the neo4j Python driver’s verify_connectivity() runs end-to-end against kglite-bolt-server. Queries still panic per the strict- xfail contract; that’s C.2 onward.

  • crates/kglite-bolt-server/src/backend.rs: replace 6 of the 11 unimplemented!() stubs:

    • create_session — generates bolt-{N} handles via an AtomicU64 counter (no UUID dep needed; SessionManager only needs uniqueness within one server process).

    • get_server_info — returns honest server: "kglite-bolt-server/{version}"

      • bolt_agent dict; boltr auto-injects connection_id + hints.

    • set_session_auth — no-op (only called once C.6 wires an AuthValidator; right now boltr handles LOGON SUCCESS itself).

    • close_session / reset_session / configure_session — no-op

      • debug log. No per-session state until C.5 brings transactions.

    • route — tightened from unimplemented!() to a structured BoltError::Protocol (“connect with bolt:// not neo4j://”) so accidental routed-client connections fail cleanly instead of panicking the connection task.

  • Test contract: the xfail(strict=True) decorator on test_bolt_handshake_and_verify_connectivity is removed; the test now PASSES. The other 7 stay XFAIL — they exercise RUN / BEGIN which still trigger panicked execute / begin_transaction. pytest -m bolt -v now reports 1 passed, 7 xfailed (exit code 0).

  • bolt_implementation.md: Phase C row gains a C.1 ✅ sub-status.

Server identity is honest, not Neo4j-mimicking. The server field reads kglite-bolt-server/0.0.1; if any Phase D ecosystem tool turns out to require a Neo4j/<x.y> prefix, we’ll add a --neo4j-compat CLI flag then — pre-emptive lying isn’t on the menu.

[0.10.0] — Phase A (Bolt prep): Value variants + KgError + db.* procedures + audit

The foundation for the Bolt protocol server (ROADMAP.md §1). Three sub-phases shipped as bisectable commits; user-visible surface area is Cypher type-system completeness, a typed Python exception hierarchy, and the Neo4j-canonical schema-introspection procedures. Plus the post-A.3 “are we ready?” audit: fixture rebuild for the v3→v4 hard break, transaction + concurrency hardening tests, and binding-implementer docs for Phase B/C.

Added — Value::{Node, Relationship, Path, List, Map} variants (A.1)

The Cypher Value enum now carries the full openCypher type lattice natively, replacing the prior JSON-string round-trip for compound values. Lists no longer serialise through String shapes on the way out; UNWIND fast-paths a native Value::List; the columnar exporter gets per-row typing without re-parsing.

Per-row execution shaves a few µs on list-heavy queries (test_bench_cypher_where -16%, columnar -17.5%). The Box<NodeValue>-tax on common-case matches shows up as test_bench_cypher_match +14.4%; within ±20% policy budget.

Added — kglite.KgError taxonomy + typed Python exceptions (A.2)

Pre-0.10.0 every error from kglite arrived as a built-in Python exception with a format! string body. Now:

  • New kglite.KgError base class + 17 typed subclasses (CypherSyntaxError, CypherTimeoutError, CypherExecutionError, SchemaError, ValidationError, FileError, ArgumentError, etc.). Hierarchy descends from kglite.KgError Exception; the Cypher subtree extends kglite.CypherError for narrower catches.

  • Cypher syntax errors carry line and col as struct fields (preserved through the parser → boundary route rather than embedded only in the message).

  • ~80 PyErr sites across the Python surface migrated to the typed exceptions (kg_core, kg_mutation, kg_introspection, algorithms, kg_fluent, py_in, mcp-server).

  • See docs/explanation/error-handling.md for the full reference.

Breaking. PyO3’s create_exception! is single-inheritance, so the typed exceptions extend KgError, NOT also the built-in equivalents. except ValueError: / except RuntimeError: no longer catches kglite errors — migrate to except kglite.CypherSyntaxError: (or the universal except kglite.KgError:). See the migration table in docs/explanation/error-handling.md.

Added — CALL db.labels() / db.relationshipTypes() / db.indexes() (A.3)

The Neo4j-canonical schema introspection procedures, callable from any Bolt-compatible client (cypher-shell, Neo4j Browser, Python neo4j driver):

  • CALL db.labels() YIELD name — every node-type name, sorted.

  • CALL db.relationshipTypes() YIELD name — every connection-type name, sorted.

  • CALL db.indexes() YIELD name, type, entityType, labelsOrTypes, properties, state — every installed index with structured columns.

The parser now accepts namespaced procedure names (db.labels, apoc.coll.sum); previously only single-identifier names parsed. Procedure names are case-insensitive on dispatch (Neo4j convention).

A KGLite-specific extension: db.indexes() returns type='RANGE' for B-tree range indexes (Neo4j collapses them under 'PROPERTY'). The planner uses the distinction — an equality index can’t serve a range query — so the procedure surfaces it for index advisors.

Behind the scenes, the new procedures share pub(crate) Rust helpers (collect_labels, collect_relationship_types, collect_indexes_structured) with describe() and schema() so all introspection surfaces report identical data.

Performance — Pre-Bolt audit + targeted fixes

The pre-Bolt audit identified five performance issues. Four shipped fixes; the fifth was investigated and documented (root cause is the A.1 Value enum expansion — a known trade-off).

Issue #1 — Deferred clone on begin(). The biggest Bolt win in this release. begin() used to deep-clone the entire DirGraph up front (O(graph_size)); now it takes an Arc snapshot (O(1)) and defers the clone until the first mutation lands. Read-only-then- commit transactions pay no clone cost regardless of graph size.

Graph

begin() + commit() (no writes)

Before

After

1k nodes

40 µs

166 ns (~240× faster)

10k nodes

391 µs

166 ns (~2,400× faster)

100k nodes

4.16 ms

166 ns (~25,000× faster)

A mutating transaction still pays the clone cost on first tx.cypher("CREATE ...") (~30 µs / k nodes), unchanged.

Issue #2 — Query parse cache. cypher() used to re-parse every input string from scratch. Added an LRU cache (256-entry, FIFO eviction, RwLock-protected) at src/graph/languages/cypher/parse_cache.rs. Cache HIT is ~700 ns end-to-end vs ~1.4 µs uncached — a 50% reduction for the Bolt-typical “agent re-issues the same parameterized query in a hot loop” pattern.

Issue #3 — Concurrent-read scaling. Audit found the read path plateaus at ~5.8× speedup beyond 8 threads. On Apple M4 this is hardware-bound (4 perf + 6 efficiency cores). Targeted fix: moved resolve_noderefs (pure-Rust post-execution step) into the py.detach block so it runs GIL-free, improving per-thread efficiency by 2-3 percentage points. The remaining inefficiency is heap allocator contention + minor GIL re-acquisition on PyObject construction, both system-wide rather than kglite-specific. On homogeneous x86 server CPUs, scaling extends further than M4 permits.

Issue #4 — columnar_enable regression investigated. The A.3 release reported a +27% capture variance; rigorous re-measurement shows the actual regression vs 0.9.52 is +2.7% — within noise. The minor slowdown is consistent with the documented A.1 Value enum expansion (cypher_match +1.9%). enable_columnar is a one-time setup operation, not in the hot Bolt path.

Issue #5 — Documentation. New “Performance reference” sections in docs/explanation/transactions.md and docs/explanation/concurrency.md document the post-fix numbers, the M-series CPU plateau, and the Bolt-server design implications. scripts/perf_audit.py is the re-runnable audit harness for these numbers.

Performance — shortestPath benchmark: kglite is now 10–250× faster

Replicated a published 500K-node shortestPath benchmark. Pre-fix run on the 500K Star / 4M HYPERLANE fixture exposed two compounded bottlenecks in the hot Cypher path: a quiet 35 ms floor on every shortestPath call + a 500 K × 500 K cartesian product when prior bindings weren’t propagated to the shortestPath executor. Combined with the trivial-but-disastrous “did you forget to call create_index()?” trap when the indexed field is the id-alias, the unfixed pipeline timed out at 10 s per call past depth 3.

Three targeted fixes shipped:

  1. Pre-bind propagation in execute_shortest_path_matchMATCH (a {id: X}), (b {id: Y}) MATCH p = shortestPath((a)-[*]-(b)) used to re-resolve (a) and (b) as bare patterns inside the shortestPath executor, which (correctly) returned all 500K nodes for each, then ran a 250-billion-pair cartesian-product BFS. Now the executor reads bindings from the input ResultSet on the fast path; only bare-pattern shortestPath callers fall through to the pre-fix cartesian behaviour. Extracted into a new executor/shortest_path.rs submodule.

  2. Id-alias routing in try_index_lookup — when the user calls add_nodes(df, "Star", "starId", "title"), starId becomes the ID-field alias for the canonical id. Pre-fix, parameterized MATCH (s:Star {starId: $a}) queries fell through to a full 500K-node type scan because the matcher only special-cased the literal property names id / nid / qid. The matcher now consults the type’s declared id-alias and routes through lookup_by_id_readonly — O(1) lookup on the auto-maintained per-type id_index. No create_index call needed.

  3. HashMap-backed BFS state in reconstruct_path_bfs — the BFS used to allocate Vec<bool> (500 KB) + Vec<u32> (2 MB) + VecDeque (~1 MB) per call sized to node_bound, regardless of actual traversal scope. For a 1-hop visit (~16 nodes) the alloc/init cost (~30 ms) dominated the operation. Now uses a HashMap<usize, u32> for parent tracking (presence ⇔ visited); shallow paths pay µs of alloc, deep paths pay O(visited_nodes). Tradeoff: ~50% slower per-node visit cost for very deep BFS (HashMap hash vs Vec index); on this benchmark at d=60 it still beats the pre-fix code by 7× and beats the baseline by

    100×.

A VecDeque + HashMap (no overhead at small N) is a saner default than Vec[node_bound] for the realistic mix of shallow + medium BFS that real Bolt clients issue. Kept the Vec<bool> path on shortest_path_directed (less exercised; reverting the directed version’s HashMap change had no measurable benefit).

Reverted (no impact / regression): an earlier id-alias fix to create_index itself was reverted after breaking test_set_name_updates_index — the matcher-side routing covers the agent-typical case without needing the index-builder change.

Final baseline vs kglite (Apple M4 vs M3 Max, 500K nodes / 4M edges):

Depth

kglite med

baseline med

Speedup

1

3.9 µs

94.8 µs

24×

5

122.9 µs

1.23 ms

10×

10

418.7 µs

~15.5 ms

37×

20

1.27 ms

~31.1 ms

24×

40

2.97 ms

612.7 ms

206×

60

5.70 ms

>612.7 ms

>100×

Graph build is 49× faster on top of the per-query wins (4.25 s vs the baseline’s 3 m 32 s, storage-flush-dominated).

Refactor — Code-rot cleanup (pre-Bolt Tier 1 audit)

Post-Bolt-audit code-rot review found light rot concentrated in duplicated helpers + one stale dead-code marker + an inbox folder holding 41 processed interproject messages. All cleaned in this release; god-file refactors (Tier 2) deferred — the existing files are working code, just large.

  • Duplicated helpers consolidated. Five copies of file_to_module_path (dart/html/php/swift/css) and three copies of make_qualified (dart/php/swift) — all byte-identical except for the separator character — moved to code_tree/parsers/shared.rs with a separator: char parameter. Three byte-identical copies of sanitize_filename in blueprint/compute/{derive,chain,filter}.rs consolidated into the shared blueprint/compute/mod.rs. The yield_alias helper in cypher/executor/{affected_tests,refresh_stats}.rs moved to the shared executor/helpers.rs. Three slightly-different copies of value_to_string (graph/mod.rs, graph/explore.rs, graph/io/export.rs) consolidated into a single canonical crate::datatypes::values::raw_string using the most complete of the three impls (rich variant coverage for DateTime, Point, Duration, A.1 collection variants). Two copies of default_auto_vacuum_threshold consolidated as pub(crate) in dir_graph.rs. Net: ~80 LoC deleted, no behavior change.

  • Stale dead-code marker removed. subgraph_streaming.rs’s #[allow(dead_code)] // Phase 4 consumes every method. marker is no longer needed — Phase 4 ships and pyapi/algorithms.rs consumes RankIndex::from_bitset + kept_count. Removed the marker and the one method that genuinely was unused (Bitset::bitset accessor). (unified_columns::WriteResult’s marker is legitimately still needed; its fields are reserved for a future caller and the marker stays with a refreshed comment.)

  • Inbox cleanup. inbox/read/ (41 messages, 476 KB of processed MCP-methods / MCP-servers interproject correspondence) deleted — git history preserves them. tests/fixtures/build_fixtures.py’s origin-attribution comment updated to drop the dead URL reference.

  • God-file size gate. New tests/test_god_files.py rejects any src/**/*.rs file over 3000 LoC unless it has an entry in an explicit ALLOWLIST with a pinned ceiling and justification. Current state: one allowlisted file (cypher/planner/fusion.rs at 3028 LoC, pinned at 3050) — the optimizer-fusion pass registry, on the deferred Tier 2 split list. Companion test_allowlist_is_not_stale ensures the allowlist gets pruned when files shrink below the default. The gate exists to catch the NEXT file that drifts past 3000 — making CLAUDE.md’s “each pass should leave it more compartmentalised” guidance mechanical.

Net Phase A regression matrix (0.9.52 → 0.10.0 with audit fixes)

Benchmark

0.9.52

0.10.0

Δ

shortest_path

2.6 µs

1.3 µs

−49.7%

cypher_where

251 µs

185 µs

−26.3%

columnar_cypher_where

261 µs

200 µs

−23.1%

columnar_cypher_match

4.9 µs

4.6 µs

−5.6%

save_v3

401 µs

394 µs

−1.7%

traversal

348 ns

339 ns

−2.5%

cypher_match

4.5 µs

4.6 µs

+1.9%

columnar_enable

196 µs

201 µs

+2.7%

add_connections

489 µs

500 µs

+2.3%

add_nodes

243 µs

253 µs

+4.2%

Phase A net: 6 benchmarks meaningfully faster, 4 marginally slower (all within noise / known A.1 trade-offs). No tracked benchmark regresses past +5%.

Added — Pre-Bolt audit: fixtures + tests + docs

The “are we really done preparing kglite core for Bolt?” audit surfaced three working-but-unverified concerns; the audit work itself ships here.

  • Test fixtures rebuilt for v4 format. Phase A.1’s .kgl v3→v4 hard break invalidated 4 committed binary fixtures (spatial_graph.kgl, timeseries_graph.kgl, graph_with_orphans.kgl, graph_with_duplicates.kgl). New tests/fixtures/build_fixtures.py regenerates them deterministically (random.seed(42)); 8 previously-xfailed MCP tests in tests/test_mcp_server_python_entry.py (j1/j2/j3/k1/k2/k3/l1/l2) now pass. 1 spurious “empty parametrize” SKIPPED in tests/test_cypher_differential.py is now an intentional @pytest.mark.skipif with a self-documenting reason.

  • Transaction class typed-exception sweep. A.2 missed src/graph/pyapi/transaction.rs; this release migrated 15 PyErr sites to typed kglite.KgError subclasses. Bolt server bindings now see uniform error types from transaction operations (timeout → kglite.CypherTimeoutError; OCC conflict + read-only mutation

    • double-commit → typed kglite.KgError).

  • Bolt-shaped tests pinned. New tests/test_transaction_bolt_patterns.py (18 tests) pins the BEGIN → cypher × N → COMMIT/ROLLBACK flow, snapshot isolation, OCC conflict semantics, context-manager auto-commit/rollback, read-only enforcement, and timeout behavior.

  • Bolt-scale concurrency stress. New tests in tests/test_concurrency.py (TestBoltScaleConcurrency, TestDocumentedQuirks) cover 16-thread parallel readers and 32-thread reader+mutator contention without panic, plus pinned contracts for the WKT cache write-lock and Arc::make_mut CoW isolation quirks.

  • Binding-implementer documentation. New docs/explanation/transactions.md and docs/explanation/concurrency.md document the surface Bolt’s Phase C will consume — error → FAILURE-code mapping table, per-session Arc recipe, the two documented contention quirks with rationale.

Verification

  • Test suite: 3010 passed (+61 from 0.9.52’s 2949), 1 skipped, 8 warnings. The 0.9.52 baseline had 1 skipped + 8 xfailed; this release flips all 8 xfailed → pass and converts the 1 skipped to an intentional self-documenting skipif.

  • Cross-mode parity (memory/mapped/disk) preserved for all 3 phases.

  • make lint green across the release (fmt + clippy + ruff + stubtest).

  • Benchmark gate: 11 tracked benchmarks within the ±20% policy budget.

What this unlocks

Phase B of bolt_implementation.md (Bolt server skeleton + failing test contract) can now start. Phase C.6 (Bolt FAILURE-code mapping

  • db.* pass-through) gates on this release’s typed exceptions and procedures, both now shipped. The deferred streaming wrapper for Bolt PULL will land in Phase B/C alongside the protocol code itself.

[0.9.52] — Cypher NULL semantics, batch dedup, shortestPath dedup

Fixed — Three-valued NULL logic in WHERE predicates

KGLite’s WHERE evaluator collapsed Cypher three-valued NULL logic to boolean at the predicate boundary, producing silent wrong rows in two patterns. Both are openCypher violations.

  • WHERE x <> 'literal' now correctly excludes rows where x is missing. Before this fix, NotEquals(NULL, 'literal') returned true, so missing-property rows were kept.

  • WHERE NOT (x CONTAINS 'lit') (and the STARTS WITH / ENDS WITH variants) now correctly excludes rows where x is missing. Before this fix, NULL CONTAINS x was false and NOT false was true, keeping the rows.

  • Kleene AND / OR / XOR composition is correct: NULL only propagates when no absorbing element is present.

  • Predicate::Not(None) is None, not flipped to true.

External evaluate_predicate callers (HAVING, OPTIONAL MATCH filter, list comprehensions, spatial joins) keep their Result<bool, _> contract — the internal evaluate_predicate_tristate does the NULL-aware composition and the wrapper collapses None to false (which every external caller already treated as “drop the row”).

Fixed — labels(n) JSON escape

The hand-rolled escape in scalar_functions::"labels" covered only \\ and ". Labels containing control characters or non-ASCII escapes could produce invalid JSON, breaking the Python deserializer. Switched to serde_json::to_string for the encoding side; the consumer (parse_list_value) was already symmetric. No user-visible change for ASCII-only labels — this hardens the edge cases.

The two call sites (scalar_functions.rs::"labels" and helpers.rs::parse_list_value) carry a Track-C swap-point note: when Value::List lands they’re the first sites to migrate. See docs/explanation/multi-label-rationale.md.

Fixed — Undirected shortestPath neighbour dedup

filtered_neighbors_undirected concatenated outgoing and incoming neighbours into one Vec without deduplicating. Bidirectional edge pairs (a→b plus b→a) and parallel edges of the same type each surfaced the same neighbour twice. BFS-based shortestPath was saved by its visited bitmap, but all_paths (DFS) paid duplicate work per visit.

In-place sort + dedup after collection. Insertion order isn’t load-bearing for any caller (BFS / DFS use set-membership, not order).

Performance — Per-chunk HashMap dedup in batch flush

ConnectionBatchProcessor::flush_chunk called graph.edges_connecting(src, tgt).find(...) per edge to detect existing edges of the same connection type — O(degree(src)) per edge. For hub-source fan-out into an existing connection type (the skip_existence_check=false path), a chunk of N edges from a hub of degree D ran in O(N·D).

flush_chunk now builds a single HashMap<(NodeIndex, NodeIndex), EdgeIndex> at the top of the chunk keyed on the unique source set’s outgoing edges of the target connection type, then probes the map per edge. The map is mutated as edges are created (preserving within-chunk consolidation semantics) and updated on Replace (so later iterations hit the new edge id, not the removed one).

Microbenchmark on 5 hubs × 10k targets = 50k edges added to an existing :R connection type: scales linearly with N (≈1.4s wall-clock for the fan-out add, ≈0.5s for a re-add in Update or Skip mode), instead of the prior O(N·D) curve.

Added — Multi-label decision doc

docs/explanation/multi-label-rationale.md captures the investigation: KGLite stays single-label by design, the v3 columnar layout is keyed by primary type, and the motivating multi-label use cases (Wikidata, code-tree refinements) are already served by INSTANCE_OF / KIND_OF edges. Lists three stepping-stone helpers (Value::List, subtype-edge planner rewrite, GraphRead::node_types_of shim) that lower the future cost without committing to the full multi-label implementation.

Added — Differential corpus coverage

tests/test_cypher_differential.py::DIFFERENTIAL_QUERIES gains 12 entries covering the shapes the above fixes address (NULL comparisons, NULL through string predicates under NOT, Kleene composition, labels() consumers, undirected shortestPath). The corpus is the regression guard against silent wrong-row bugs; the additions lock in the new behaviour.

Internal — Cleared 9 pre-existing parity-test failures

Housekeeping pass on pytest -m parity — every failure pre-dated 0.9.52 and none were caused by the work above. Fixed in bulk so the release lands a clean gate:

  • Phase 2 — restrict CREATE/MERGE-via-cypher tests to memory+mapped (disk lockout has been intentional since 0.9.26); replaced the legacy “disk MERGE works” test with two dedicated lockout-message guards.

  • Phase 4 — refresh the .kgl v3 golden digest. The hash embeds the version string in the header, so every release shifts it; the allowlist hadn’t been updated since 0.9.7.

  • Phase 5/6 — extend the GraphBackend:: enum-match whitelist to cover mutation/subgraph_streaming.rs (disk-to-disk streaming filter) and pyapi/algorithms.rs (disk-only PyO3 entry points). Both are structural peers of existing whitelist entries.

  • Phase 7 — extract column_store.rs’s 293-line test block to a sibling column_store_tests.rs via #[path], dropping the production file from 2515 to 2228 lines (under the 2500-line god-file cap). Add // SAFETY: comments to 5 mmap fadvise / env-var-cleanup blocks (each was correct as written; only the justification was missing).

  • Phase 5 — bump test_binary_size_regression baseline from the stale 0.9.0 value (23.5 MB) to the current 0.9.52 size (35.9 MB). Gate stays +10% on the new baseline. The docstring carries an explicit “what grew” breakdown so the next bump is grounded — primary growth contributors over the 0.9.0 → 0.9.52 window were the 14 tree-sitter grammars, the fastembed feature default-on for the kglite-mcp-server binary, mcp-methods evolution, and the sodir / wikidata workspace crates.

All 98 parity-marked tests now pass.

Fixed — Four more Cypher correctness fixes (from the fortified suite)

A test-suite fortification pass (see Internal section below) surfaced three additional bugs in the Cypher engine plus one discoverability inconsistency. All four are fixed:

  • IN predicates now propagate NULL per openCypher. Completes the tri-state work the 0.9.52 B1/B2 fix started: WHERE x IN [literal, ...], WHERE x IN $param, and the InLiteralSet fast-path all return NULL on NULL LHS or no-match-with-NULL-element. Pre-fix, those rows leaked through NOT (x IN [...]) and similar shapes.

  • list[..] parses as a full-range slice (both ends omitted). Was a parser asymmetry — [start..] and [..end] worked, [..] errored.

  • Int64::MIN (-9223372036854775808) is now expressible as a literal. Tokenizer-level lookback: when the digit string overflows i64 and is exactly 9223372036854775808 and the previous token is Dash, the pair collapses to a single IntLit(i64::MIN).

  • keys(n) enumerates the user-set unique-id-field and title-field column names (e.g. person_id, name) alongside the virtual aliases (id, title, type). Discoverability fix: n.person_id was readable but absent from keys(n).

Internal — Test-suite fortification

The 0.9.52 release surfaced a structural gap in the test infrastructure: parity gates were excluded from default CI, perf benchmarks tracked but didn’t gate, and several captured constants drifted silently across releases. Six-commit overhaul:

  • Structural parity gates → default CI (god-file cap, unsafe-needs-SAFETY, mod_rs purity, enum-match audit, recording symbol export). Previously opt-in via -m parity; 10 had drifted to red on main before this session cleared them.

  • Perf-regression gate (scripts/compare_bench.py, tests/benchmarks/baselines/0_9_52.json, CI perf-regression job). Blocks PRs on >20% min-time regression against the versioned baseline. Threshold matches the CLAUDE.md performance protocol.

  • Captured-constants refresh ritual (scripts/refresh_release_constants.py, make refresh-release-constants, CLAUDE.md section). One script refreshes the .kgl golden digest, binary-size baseline, and perf baseline at release time. Idempotent; only the version-tagged baseline triggers re-capture. Pre-existing stale .kgl golden digest fixed in the same commit.

  • +25 openCypher edge-case tests + 1 differential corpus query covering NULL semantics, unicode strings, numeric boundaries, collection edges, aggregate NULL handling, and pattern-matching edges (self-loops, zero-length paths, parallel edges). Surfaced the three Cypher bugs above + one pre-existing parser bug (Int64::MIN literal) that’s also now fixed.

  • On-demand Neo4j conformance runner (scripts/cypher_conformance.py, make neo4j-{up,down,conformance}, docs/explanation/cypher-conformance.md). Standalone — not in CI, no external service dependency for the regular test run. Reuses the differential corpus + shared fixtures.

  • Property-naming round-trip pins (test_export.py). Six tests documenting d3 export flattening, alias-table semantics, and to_neo4j renaming. Caught the keys(n) discoverability bug fixed above.

Test count: pytest tests/ goes from ~2786 (pre-session) to 2826 passed / 1 skipped / 0 xfailed. pytest -m parity stays 98/98 green. make lint clean.

[0.9.51] — Dart / Flutter code-tree support

Added — Dart language parser

kglite.code_tree now parses .dart sources, bringing the supported- language count to 14. A Flutter repository — previously indexed as a periphery-only graph (native-runner Swift/C++/C scaffolding, the marketing site) with its entire Dart application core silently dropped — now produces a queryable graph of the real app.

  • Classes, mixins, extensions, enums, top-level and member functions, named & factory constructors, getters/setters/operators, constants and typedefs — emitted with the same node/edge vocabulary as the other languages, so cross-language Cypher patterns just work.

  • extends / with / implementsEXTENDS / IMPLEMENTS edges; call sites → CALLS; methods → HAS_METHOD; cyclomatic branch counts and structured parameters as for every other language.

  • Named and factory constructors resolve to distinct, addressable qualified names (Owner.Owner, Owner.Owner.named).

  • import / export directives → IMPORTS edges (relative and same-package URIs); part / part of files collapse into one logical module.

Added — Mixin node label

Dart mixin declarations land as a dedicated Mixin graph node, beside the existing Class / Struct / Trait / Protocol family. extension / extension type are Class nodes tagged by kind.

Added — Flutter widget pass

StatelessWidget / StatefulWidget / State subclasses carry a flutter_widget property, and their build methods a flutter_build flag — “show me the screens” is one hop away. New queryable Function columns: is_constructor, is_factory, accessor.

Fixed — github_api leading-slash 404

Bumped the mcp-methods dependency to 0.3.39, which fixes github_api malforming its URL for a path written with a leading slash (/repos/owner/repo → doubled /repos/ → 404). A leading slash is now optional and accepted on either path form.

Fixed — comment-annotation char-boundary panic

extract_comment_annotations panicked when a TODO/FIXME comment body exceeded 200 bytes with a multi-byte character straddling the truncation boundary — reachable from every language parser, surfaced by Dart comments that use ──── rules.

[0.9.50] — Lossless edge loading: auto-vivified provisional stub nodes

Lossless edge loading — auto-vivified provisional stub nodes

  • An edge loaded against a node that doesn’t exist is no longer silently dropped. The missing endpoint is auto-vivified as a provisional stub node (marked _provisional) so the edge always connects — across blueprint fk-edges, blueprint junction-edges and the imperative add_connections API. This removes a load-order hazard: loading edges before some of their nodes (e.g. Friends before Class B) previously lost every edge into the not-yet-loaded nodes.

  • A later load of the real node row promotes the stub — the _provisional marker is cleared on node upsert.

  • New KnowledgeGraph.purge_provisional() deletes any stub never promoted (a genuinely dangling reference) and its incident edges, returning {nodes_purged, edges_removed}.

  • A blueprint can set settings.auto_purge: true to run that purge automatically at the end of from_blueprint (default false — stubs are kept so no edge is lost).

  • _provisional is a reserved property name — a blueprint node spec declaring it is rejected.

SEC loader — ownership / 13F / Exhibit 21 extraction scoped to filing_index

  • These three extractors walked raw/filings/ directly, parsing every cached document regardless of the build’s form_types / year scope — whereas Filing nodes come scope-filtered from filing_index.csv. On a re-scoped rebuild of an existing workdir the mismatch produced detail rows (InsiderTransaction, Holding, InstitutionalHolding, Subsidiary) referencing out-of-scope filings. They now walk via walk_filings_in_index — only documents whose filing is in filing_index.csv — so extraction and the Filing node set agree.

[0.9.49] — SEC loader: form-typed extraction + 3-phase build progress

SEC loader — per-filing documents selected by form type, not filename

  • The form extractors (DEF 14A, SC 13D/G, S-1, 424B, 10-K — and 8-K) picked their documents with filename predicates that required a form-type token (def14a, sc13d, …). Modern inline-XBRL filings are named {ticker}-{date}.htm and carry no such token, so on recent filings those extractors silently produced nothing (Compensation, Proposal, ActivistFiling, … came out empty). They now resolve each document’s form type by accession against filing_index.csv (walk_filings_of_form) — reliable regardless of filename. Verified live: TSLA 8-K events (37) and DEF 14A compensation + proposals now extract from 2025-2026 filings.

SEC loader — minimalist 3-phase build progress

  • SEC.open / SEC.fetch render a minimalist 3-phase tqdm display — Fetch (per-filing download, live count), Process (extraction) and Build (graph assembly) — replacing the per-form-bucket bars. The verbose [SEC] lines are muted whenever a progress display (or a caller-supplied progress callback) is active, so the terminal shows the three phases and nothing else. Falls back to the plain [SEC] prints when tqdm isn’t installed.

[0.9.48] — SEC loader: cold-start, Jupyter progress, 8-K extraction

  • SEC.fetch / SEC.open on a fresh workdir now collect per-filing detail in one call. The per-filing dispatcher reads processed/filing_index.csv — emitted by the extractor’s identity pre-pass — which didn’t exist yet on a cold workdir, so the dispatch fetched nothing and the graph had Company/Filing nodes but no insider transactions, events, or roles. The wrapper now builds the filing index before the dispatch and re-extracts after it.

  • The per-filing fetch releases the GIL during the rate-limited download loop, re-acquiring it only to fire each progress event. Holding it for the whole batch starved a Jupyter kernel’s IOPub thread, so tqdm progress couldn’t render until the call returned.

  • 8-K events are now extracted from modern inline-XBRL filings. The extractor’s file predicate required 8k in the filename, but recent 8-K primary documents are named {ticker}-{date}.htm — so CorporateEvent nodes were silently empty. The predicate is now loose (the Item N.NN parser self-gates), and the event description stops at the heading sentence instead of running on into the filing body (inline-XBRL has no newlines).

[0.9.47] — SEC EDGAR value-prop upgrade + blueprint compute pipeline

Two big additions land in this release. The first (J0–J7) overhauls the SEC loader so the detailed-payload extracts actually work and the resulting graph rewards SQL-person traversal patterns. The second (K1–K7) introduces a top-level compute: block in blueprints — a small ETL pipeline that runs as a CSV-shaping pre-phase, so loaders can do unit conversions, conditional flags, temporal chains, calendar joins, and summary nodes declaratively instead of via Python pre-scripts or post-build Cypher passes.

Blueprint compute pipeline (K1–K7)

  • compute: block in blueprints — top-level ordered list of named primitives that runs as a CSV-shaping pre-phase before the existing 5-phase loader. Each primitive writes its outputs to computed/*.csv and the declarative loader consumes them as if they were ordinary inputs.

  • Expression language — hand-rolled Pratt parser + tree-walking evaluator: arithmetic, comparison, logical, membership, function calls, list literals. Built-in functions: math (abs/round/ceil/ floor/sqrt/log/exp/pow/min/max), string (concat/lower/upper/ contains/starts_with/ends_with/len), conditional (if/coalesce), type conversion (int/float/string), date components (year/ month/day/quarter). No expression-crate dependency.

  • Five compute primitives:

    • derive — row-level expressions on an existing node type; new property columns appended or overwriting existing ones.

    • filterwhere predicate; produces a new derived type (into:) or rewrites the source destructively.

    • chain — group + sort + emit consecutive-pair junction edges with step_index property.

    • calendar — synthesises Date nodes for [start, end] plus NEXT_DAY chain edges and ON_DATE-style link edges to source types’ date columns.

    • aggregate — group-by + per-group aggregate expressions (sum/avg/min/max/count/count_distinct/first(...,by=)/ last(...,by=)), emitting one summary node per group plus optional FK edges to the group-key targets.

  • Validation up-front at blueprint load: dangling type/column references, malformed expressions, aggregate-only functions outside aggregate.agg, calendar date ordering — all caught before any CSV is touched.

  • Performance: aggregate uses HashMap<String, ...> with a reused String buffer for the group key (one allocation per new group, not per row). 100K rows / 1K groups / 6 aggregates runs in 68.7 ms end-to-end (full blueprint load including Phase 1-5).

  • Sub-node resolution — compute primitives target both top-level types and sub-nodes (e.g. SEC’s Transaction at nodes.Person.sub_nodes.Transaction). The resolver walks blueprint.nodes first, then each parent’s sub_nodes.

  • SEC blueprint showcase: the dataset’s packaged blueprint ships with a compute: block exercising all five primitives — derive (filing_year, form-type flags on Filing; total_value, is_buy, is_sell on Transaction), filter (AnnualRevenue from MetricFact), chain (NEXT_FILING per company; NEXT_TX per person+issuer), calendar (2020-2030 + ON_FILED_DATE and ON_TX_DATE links), and aggregate — chained in two stages for insider positions:

    • PositionLedger (per ledger, group_by [person_nid, issuer_cik, security_title, direct_indirect]) captures the fact that Form 4’s shares_owned_after is a per-(security, direct/indirect) balance, not a global one. Has current_shares=last(shares_owned_after, by=transaction_date) plus shares_acquired/disposed, first/last_tx_date, n_transactions, and filed-price total_buy/sell_value. Edges LEDGER_OF_PERSON / LEDGER_AT_COMPANY.

    • Position rolls PositionLedger up to one row per (person, issuer) by summing across ledgers — current_shares, shares_acquired, shares_disposed, n_transactions, n_ledgers, total_buy/sell_value — and taking min/max of first/last_tx_date. POSITION_OF / AT_COMPANY edges. MATCH (p:Person)-[:POSITION_OF] -(pos:Position)-[:AT_COMPANY]-(c:Company) RETURN pos.current_shares returns the true total in one hop.

    • FilingYear (per (cik, year)) summarises filing activity with FILINGS_BY edge to Company. Chained aggregates demonstrate the compute pipeline composing — Stage A’s into becomes Stage B’s from automatically via the sub-node resolver.

  • Expression engine — null-propagating arithmetic & comparisons. null * 5, null + 3, null < x all yield null (SQL semantics) instead of erroring. Real-world CSV data routinely has nulls (e.g. SEC insider grants with no price_per_share); the previous “error on null operand” behaviour forced coalesce(x, 0) wrapping on every arithmetic expression. Sum/ avg already skip nulls, so propagation composes cleanly with aggregates.

SEC EDGAR value-proposition upgrade (J0–J7)

The shipped SEC loader through 0.9.45 produced a Filing-index graph but the detailed-payload extracts (Form 4, 13F, 8-K, SC 13D, DEF 14A, Exhibit 21) silently returned zero rows because the per-filing fetcher loop was never wired. This release closes that gap AND reshapes the schema so a SQL person looking at the graph sees an immediate win — same-person multi-role queries, typed insider edges, sector-cohort traversal via the new SicCode node, fund-as-issuer bridge.

Added (foundation — J0–J2)

  • Ticker support in SEC.open(cik_list=...) (J0). Accepts string tickers (case-insensitive), int CIKs, or a mix: cik_list=["AAPL", "BRK-B", 1318605]. Resolves via the SEC company_tickers.json map (~1 MB, cached after first fetch).

  • Generic per-filing fetcher (J1): kglite_sec::fetch_filing_primary_doc for 8-K / SC 13D / DEF 14A primary docs; kglite_sec::fetch_exhibit21_attachment for 10-K Exhibit 21 discovery via index.json. Exposed to Python as _sec_internal.fetch_filing_batch and _sec_internal.fetch_exhibit21_batch.

  • Wrapper batch dispatch (J2): _dispatch_per_filing_fetches reads processed/filing.csv after extract_processed, groups by form type, calls the fetchers. include_subsidiaries and include_8k_events are no longer parsed-but-ignored — they gate the fetch.

  • Fetcher bug fixes (J7-prep):

    • Form 4 was downloading XSL-rendered HTML instead of XML because primaryDocument points inside xslF345X*/; we now strip the directory and fetch the raw XML at the filing root. Without this, every Form 4 file errored at parse time.

    • 13F-HR index.json sometimes labels every document as type: "text.gif" (observed on Berkshire); the info-table discovery now falls back to “any non-primary_doc XML” when the type-label heuristic finds nothing.

Changed (graph value — J3–J6)

These are breaking schema changes. Existing graphs cached under graph/{mode}/ won’t load — call SEC.open(..., force_rebuild=True) to re-derive.

  • Person unification — drops the Director node type (J3). Form 4 reporters and DEF 14A directors now project onto a single :Person node with role edges back to Company. Exact token-sorted name match merges aligned references (“COOK TIMOTHY D” ↔ “Timothy D. Cook”); unmatched DEF 14A directors get a synthetic negative-i63 person_nid so the column stays Int64-typed (mixing string and int nids would downgrade the column to String and break FK lookups). age and since_year move from Director properties to edge properties on SERVES_ON_BOARD (per-filing facts, not per-Person facts).

  • Typed insider edges replace boolean-flag HAS_INSIDER (J4): Company -[:IS_DIRECTOR_OF]-> Person, Company -[:IS_OFFICER_OF]-> Person (with officer_title), Company -[:IS_BENEFICIAL_OWNER_OF]-> Person (10%-owner + the rare is_other catch-all). Pattern matching becomes idiomatic instead of property-filtered, and the planner can use edge-type indexing.

  • Industry as a graph node (J5): processed/sic.csv aggregates distinct (sic_code, sic_description) pairs. New :SicCode node + new Company -[:IN_INDUSTRY]-> SicCode fk edge. Sector cohort queries lose the GROUP BY sic ceremony.

  • Manager ↔ Company link (J6): new InstitutionalManager -[:IS_COMPANY]-> Company fk edge. When a 13F filer’s manager_cik matches a Company.cik (Berkshire, BlackRock, Vanguard), the same legal entity now materialises as one bridged node group across all three role views (issuer, 13F filer, board member).

Schema migration

Existing graphs cached under <workdir>/graph/{mode}/ from 0.9.45 or earlier won’t load post-J3+J4. Re-derive:

g = SEC.open(workdir, ..., force_rebuild=True)

raw/ and processed/ tiers are unaffected (well, processed/ regenerates from raw/ as usual); only the built graph file needs rebuilding.

Showcase notebook

examples/sec_to_claude_mcp.ipynb demonstrates the schema with 7 queries that have no clean SQL equivalent — multi-role insider unification, board interlocks, sector-cohort sells, fund-as-issuer, 8-K → Form 4 proximity, voting-power concentration, subsidiary depth. Ends by registering the graph as a Claude Desktop MCP server (the agent-first framing).

Extraction performance + segfault fix

  • Parallel feature extraction — each raw filing parses independently, so the form extractors now parse a chunk of files across all cores and emit rows single-threaded (lock-free CSV sinks). Combined with a unified ownership-XML pass — Form 3/4/5/144/D are walked and read once and dispatched by detected form type instead of five redundant re-parses — and larger (512 KB) CSV write buffers, SEC feature extraction is ~3.6× faster (14.8 s → 4.1 s on a 6,750-filing cache).

  • Fixed a SEC.open() segfault on macOSkglite.datasets submodules now import lazily (PEP 562), so kglite.datasets.sec no longer pulls sodir/wikidata → pandas → pyarrow into the process. Loading pyarrow after the kglite native extension triggered a dynamic-linker crash; keeping it off the SEC import path resolves it.

  • Identity pre-pass scoped to the corpus — extraction without an explicit cik_list no longer scans the full ~900K-company submissions.zip. The CIKs to load are derived from the filings already on disk (raw/filings/, raw/financials/), so the identity pre-pass uses the direct-lookup fast path instead of an EDGAR-wide scan — ~20 s → tens of ms on a 100-filing corpus, where it had been 99.8% of total extraction wall time.

SEC graph schema rebuilt for the info-row layout (F20)

The dataset blueprint (kglite/datasets/sec/blueprint.json) is rebuilt from scratch against the info-row CSV layout the F-phase extractors now emit. It is fully node-centric — every reported fact is a node that carries its own data; edges are thin foreign-key connectors with no properties:

  • Entity-hub nodesCompany, Person, Security, InstitutionalManager, SicCode.

  • Fact nodesFiling, InsiderTransaction, Holding, InstitutionalHolding, Role, CorporateEvent, MetricFact, Subsidiary. Each row of an info-row CSV is one node; its attributes (role title, share counts, holding value, transaction price, …) live as node properties, not on edges.

  • Thin edges — every connection is a foreign-key edge: a fact node links to its participant entities plus a REPORTED_IN edge to the Filing it came from, so provenance is always a traversal.

  • Compute layer — a Day/Month/Quarter calendar with FILED_ON/TRADED_ON/HELD_ON/OCCURRED_ON links, NEXT_FILING and NEXT_TX temporal chains, and an InsiderActivity per-(person, company) rollup node.

  • Unified insider_transaction.csv — the ownership extractor now emits one transaction table with a direction (“purchase”/”sale”) column instead of separate purchase.csv + sale.csv, so an insider’s whole trading history is one node type (NEXT_TX chains, net-position rollups).

Verified against a 100-filing corpus: a 31,615-node / 60,398-edge graph across 17 node types and 19 edge types, no junction edges.

DEF 14A compensation + governance (F8, F9)

  • compensation.csv + Compensation node (F8) — the DEF 14A extractor now parses the proxy statement’s Summary Compensation Table (Item 402): one node per named-executive-officer / fiscal year, with salary / bonus / stock + option awards / non-equity incentive / pension change / other / total, edged to Person, Company, and the Filing it came from.

  • proposal.csv / ceo_pay_ratio.csv / audit_fees.csv + their nodes (F9) — the DEF 14A pass also extracts ballot proposals (number, description, board recommendation, company vs shareholder), the Item 402(u) CEO pay-ratio disclosure, and the Item 9(e) independent-auditor fee table. Heuristic scans — they drop rather than guess: pay-ratio values in the 1900-2100 range (mis-read dates) and sub-$50k “audit fees” (footnote noise) are rejected.

  • person id scheme unified — Form 3/4/5/144 person ids are now cik-{N} (non-numeric), so they no longer collide-type with the name-keyed proxy/10-K person ids and break FK edges into Person.

  • ownership-table parser hardened — footnote sentences, table captions and city/state address lines no longer leak through as beneficial-holder rows.

8-K officer changes (F13)

  • officer_change.csv + OfficerChange node — 8-Ks carrying an Item 5.02 are scanned for officer / director changes. The change detail is frequently cross-referenced into Item 8.01, so the whole (short) 8-K is scanned: each “Mr./Ms./Mrs./Dr.” person mention yields a change typed from the surrounding verb (resignation / retirement / appointment / election / departure) with a title and effective date. The lowest-precision extractor in the set — a person without a recoverable name or a change verb is skipped.

8-K earnings releases (F14)

  • earnings_release.csv + EarningsRelease node — a new parser reads an earnings press release (8-K Item 2.02 body or its Exhibit 99 attachment) and pulls the headline figures: revenue, net income, and basic / diluted per-share earnings (parenthesised losses read negative, million/billion multipliers honoured). It self-gates on the earnings vocabulary — any non-earnings document yields nothing. Wired into forms/eightk.rs, scanning both 8-K covers and ex-99 attachments; the blueprint gains an EarningsRelease node edged to Company and Filing. Verified by parser fixtures — the benchmark corpus carries no Exhibit 99 attachments, so end-to-end coverage awaits an exhibit fetch.

S-1 / 424B securities offerings (F15)

  • offering.csv / selling_stockholder.csv / underwriter.csv / use_of_proceeds.csv + their nodes — a new parser reads a registration statement (S-1) or prospectus (424B): the offering summary (type, shares, price, gross/net proceeds), the selling-stockholder table (per-seller share breakdown), the underwriting syndicate, and the use-of-proceeds narrative. The previously stubbed forms::s1 and forms::prospectus extractors share one walk/parse/emit routine. Verified by parser fixtures — the benchmark corpus carries no S-1 / 424B documents, so the four nodes load cleanly with zero rows pending a registration-statement fetch.

SC 13D/G amendment + group refinements (F18)

  • Amendment detection — the SC 13D/G parser reads the cover page’s “(Amendment No. N)” marker, so activist_filing.is_amendment is now set from the filing itself instead of hardcoded 0.

  • holder_group.csv + HolderGroup node — when one SC 13D/G carries multiple reporting persons they are a § 13(d) group; schedule13 now links each joint filer to the first.

  • ActivistFiling nodeactivist_filing.csv (long the SC 13D/G output) finally enters the blueprint, edged to Company and Filing. Verified by parser fixtures — the benchmark corpus carries no SC 13D/G documents, so both nodes load with zero rows pending a Schedule 13 fetch.

Deferred-placeholder sinks documented (F19)

  • The eight CSV sinks with no extractor yet — auditor, auditor_change (8-K Item 4.01), restatement (8-K Item 4.02), ma_event (8-K Item 2.01), vote_result (8-K Item 5.07), pay_vs_performance (DEF 14A Item 402(v)), fund_vote (Form N-PX) and merger (Form S-4) — now each carry a PLACEHOLDER (deferred) doc comment naming the form/item that will populate them. The headers were already written; this closes the F-phase program by making every still-empty sink an intentional, documented placeholder rather than an unexplained gap.

SEC.open — lean-core default fetch scope

  • form_types now scopes the per-filing fetch, not just the extract step. Previously a sliced call (form_types=["4"]) still downloaded every form bucket — 13F info tables, DEF 14A proxies, 8-K cover pages, Exhibit 21 attachments, XBRL company-facts — and only filtered them out at extract time.

  • When form_types is unset, the per-filing fetch defaults to a lean core set — insider ownership (Forms 3/4/5) + 8-K cover pages. The heavy payloads are now opt-in: name the form in form_types (["13F-HR"], ["DEF 14A"], ["SC 13D"], ["144"], ["10-K"] for Exhibit 21), or set the matching include_* flag.

  • Default change: include_subsidiaries and include_xbrl_metrics now default to False (were True) — Exhibit 21 and XBRL company-facts are the most expensive fetches (2 requests per 10-K; 5-50 MB JSON per company) and are opt-in under the lean default. include_8k_events stays True — 8-K is part of the lean core.

SEC loader — SEC.fetch shortcut, companies argument, progress bars

  • New SEC.fetch(path, forms, companies, *, years=2, user_agent=...) — an ergonomic shortcut that fetches and builds a graph for a focused slice: name a form, a company, and a span (SEC.fetch(path, "13F-HR", "TSLA", years=2, user_agent=UA)). forms/companies accept a single value or a list; years drives both the filing index and the per-filing payload depth. A force_rebuild flag rebuilds when re-running with a changed scope (the graph cache is keyed by workdir, not by scope). SEC.open remains the full-control entry point.

  • SEC.open’s cik_list parameter is renamed to companies — it has always accepted int CIKs, string tickers, and mixed lists, and the new name reflects that. No back-compat alias; update call sites (SEC.open(..., companies=[...])).

  • New progress parameter on SEC.open — a callable receiving structured progress events from the per-filing fetch. The rate-limited download of Form 4 / 13F / 8-K / DEF 14A / Exhibit 21 documents now drives a tqdm progress bar (one per fetch phase), auto-enabled on verbose runs when tqdm is installed and falling back to the previous [SEC] prints otherwise. tqdm stays an optional dependency. Ctrl+C during a fetch now aborts cleanly.

SEC loader — dead FSNDS bulk-feed fetch removed

  • SEC.open no longer downloads the legacy FSNDS (Financial Statement and Notes Data Set) quarterly bulk ZIPs. F17 replaced that feed with per-company XBRL company-facts JSON, and nothing read the FSNDS num.tsv files anymore — the fetch was pure dead weight. XBRL financial metrics are unaffected; they still come from the company-facts fetch gated by include_xbrl_metrics.

Sodir loader ported to Rust — pandas dropped

  • The Sodir FactMaps dataset loader is now a pure-Rust crate (kglite-sodir) behind a thin Python wrapper, matching the SEC loader’s architecture: ArcGIS REST fetch + GeoJSON→CSV, the two-tier cooldown index, the FK preprocessing, and the blueprint deep-merge all moved out of Python.

  • pandas is no longer a dependency. It was used only by the old Sodir Python modules; with those gone, pandas (and transitively numpy/pyarrow) is removed from pyproject.toml. kglite.datasets. sodir.open() / fetch_all() keep their signatures.

  • The Wikidata dump loader’s download orchestration also moved to a pure-Rust crate (kglite-wikidata) — the resumable download and the staleness/cooldown cache no longer shell out to a curl subprocess. kglite.datasets.wikidata.open() keeps its signature; the N-Triples graph build is unchanged.

[0.9.45] — save_graph mode-aware dispatch in kglite-mcp-server

Correctness fix for an MCP-server-only regression latent since 0.9.20: the save_graph tool errored on in-memory .kgl graphs because the Rust crate’s run_save only handled the disk branch. Ships with the dispatch extracted into kglite::api::save_graph (single source of truth shared with the Python wrapper) and the CI gap that hid the regression closed.

Fixed

  • kglite-mcp-server’s save_graph tool errored on in-memory .kgl graphs with "save_disk requires disk mode". The Rust crate’s run_save at crates/kglite-mcp-server/src/tools.rs:599 called dir.save_disk(path) unconditionally; save_disk is the disk-mode-only path. The Python KnowledgeGraph.save() at src/graph/pyapi/kg_core.rs:505 has always dispatched correctly via is_disk(), but the Rust crate never got the equivalent. Latent since the 0.9.20 architecture change (May 11) — undetected because tests/test_mcp_server_smoke.py is pytest.mark.skipif(not BINARY.exists()) and CI didn’t build the binary.

    Fix: new kglite::api::save_graph(graph, path) in src/graph/io/file.rs performs the same dispatch as the Python wrapper (disk → save_disk; in-memory → prepare_saveenable_columnarwrite_graph_v3). The MCP crate now calls it, removing the duplicated dispatch surface.

Added

  • CI builds the kglite-mcp-server binary before the pytest step, so tests/test_mcp_server_smoke.py runs in CI instead of silently skipping. This is what would have caught the save_graph regression at 0.9.20.

  • Disk-mode save_graph round-trip test (test_c8b_save_graph_persists_disk_mode in tests/test_mcp_server_python_entry.py) locks in the disk-branch of the dispatch — the complement to the existing in-memory test_c8.

  • kglite::api::save_graph and kglite::api::save_inmemory (via kglite::graph::io::file) for non-pyo3 Rust consumers.

Changed

  • tests/test_mcp_server_smoke.py opts into save_graph via the manifest (builtins.save_graph: true) — catching up with the opt-in design ff5cc91 introduced for the canonical tests/test_mcp_server_python_entry.py fixtures.

[0.9.44] — Streaming node + FK-edge loaders (F1–F4)

Completes the streaming-CSV refactor started in 0.9.43. The junction-edge loader was the warm-up (E1–E4); 0.9.44 brings the same per-chunk dispatch to node specs and their FK edges, so peak RAM during from_blueprint is now bounded by chunk size rather than total CSV size for the dominant SEC + Wikidata shapes.

Added

  • Streaming node-loader for simple specs in src/graph/blueprint/build.rs (F1). Specs that are CSV-backed and not manual / timeseries / spatial flow through a per-chunk read_csv_chunks typed_dataframe add_nodes loop. add_nodes is upsert-by-id so successive chunks accumulate cleanly into the same node type. Buffered path still owns timeseries / spatial / manual specs (they need random access to the full row set for grouping, in-place geometry conversion, or FK-target discovery).

  • Auto-pk threading across chunks (F2). pk: "auto" specs stream via a per-spec u64 counter that advances by each chunk’s post-filter row count. Synthesised ids remain dense 1..=N matching the buffered path’s behaviour. Sub-nodes with pk:"auto" + parent FK (the dominant SEC + Sodir shape) now stream end-to-end on both the node and FK sides (F3).

  • FK-edge streaming for streaming-eligible specs (F3). FK edges from streamed parents emit one connect() call per (chunk, declared edge) pair, built on the same build_fk_columns + build_edge_df + connect() primitives as the buffered path. The cache pre-parse step (parse_in_parallel) skips streamed specs — their CSVs are read on demand by the streaming loaders.

  • Adaptive streaming via a file-size gate (F4). Per-spec CSV size is checked at build start: files at or above KGLITE_BLUEPRINT_STREAMING_THRESHOLD_MB (default 100 MB) flow through the streaming path; smaller files stay on the buffered path. The default threshold keeps Sodir / SEC-1yr blueprints on the fast path (zero regression vs 0.9.43) while triggering streaming for the SEC full-universe / Wikidata-scale cases where the RAM bound matters.

Tuning knobs

  • KGLITE_BLUEPRINT_STREAMING_THRESHOLD_MB — file-size gate (default 100 MB). Set to 0 to force streaming on all eligible specs; set higher to keep more on the buffered path.

  • KGLITE_BLUEPRINT_NODE_CHUNK_SIZE — rows per chunk for node

    • FK streaming (default 250K). ~110 MB peak per chunk at typical row widths; reduce for RAM-tight hosts.

  • KGLITE_BLUEPRINT_JUNCTION_CHUNK_SIZE — junction-edge streaming chunk size (default 100K, unchanged from 0.9.43).

Performance

Synthetic 500K-row Employee + 1000-row Company + WORKS_AT FK (13 MB employees.csv), 5 cold rounds, min (per CLAUDE.md perf protocol):

Path

Time

vs 0.9.43

0.9.43 buffered (baseline)

0.373 s

0.9.44 default (file < 100 MB → buffered)

0.370 s

-0.8%

0.9.44 forced streaming (threshold=0)

0.431 s

+15.5%

Synthetic 5M-row Employee + 5K-row Company + WORKS_AT FK (145 MB employees.csv), 3 cold rounds, min:

Path

Time

0.9.44 default (file ≥ 100 MB → stream)

9.91 s

0.9.44 forced buffered (threshold=999)

7.10 s

The streaming path carries ~40% wall-time overhead on in-RAM-fits-anyway sizes — the cost of per-chunk dispatch and loss of off-thread parallel prep. This is the explicit tradeoff the size gate manages: streaming earns its keep when buffering would push the process toward OOM (multi-GB CSVs).

Notes for v0.9.45+

  • Streaming for timeseries / spatial specs. Both currently require multi-pass access (timeseries: grouping by pk; spatial: in-place geometry conversion). A two-pass streaming design is feasible but more invasive — deferred until a real Wikidata-scale timeseries graph asks for it.

  • Per-chunk type-inference stability. build_edge_df infers FK column types per-chunk; chunks with all-int rows + one chunk with a string row would disagree. Real-world FK columns are consistently typed; if this surfaces in production, move FK types to explicit blueprint declarations.

[0.9.43] — Streaming CSV for junction-edge loader (E1–E4)

Added

  • Streaming junction-edge loader in src/graph/blueprint/build.rs. Previously every junction CSV was eagerly parsed into a CsvCache before load_junction_edges could process it. For multi-million- row junction tables (e.g. SEC HOLDS at full-universe scale, ~30M rows) that peaked RAM at 5–10 GB during the prep phase.

    The new path streams each junction CSV in chunks of 100K rows (configurable via KGLITE_BLUEPRINT_JUNCTION_CHUNK_SIZE), building a per-chunk DataFrame + dispatching to connect() before the chunk is dropped. Peak RAM during junction loading is now bounded at ~20 MB per chunk regardless of total file size.

    Trade-off: junction loading is now sequential per-spec instead of parallel across specs. Negligible on small graphs (Sodir); the win is large graphs.

  • read_csv_chunks(path, chunk_size) in src/graph/blueprint/csv_loader.rs: streaming chunked CSV reader that yields RawCsv chunks of configurable size. Foundation for future node-loader streaming (deferred to a later phase since node CSVs interact with multi-pass operations like dedup_by_pk and timeseries grouping that need more careful migration).

  • CsvStream in src/graph/blueprint/csv_stream.rs: low-level per-row streaming iterator. Currently used internally as a design reference; can be picked up by future consumers that need per-row dispatch (e.g. one-shot mutations against a graph).

Notes for v0.9.44+

  • Node-loader streaming (deferred E2 work) — prep_node_spec still uses the buffered CsvCache path. For node CSVs that grow past a few million rows (full-universe SEC MetricFact would be ~50M), that’s the next memory hotspot. The migration needs to thread an auto-pk counter across chunks and handle timeseries / dedup as multi-pass operations.

  • FK-edge streamingprep_fk_edges re-uses the node spec’s CSV from cache, so streaming there only helps when paired with node-loader streaming. Bundle with the v0.9.44 node migration.

Build/test status

  • 19/19 tests/test_blueprint.py parity tests green.

  • All SEC smoke + use-case-v2 tests green.

  • 5 new chunk-reader unit tests, 6 new CsvStream unit tests.

  • make lint green per phase commit.

Documentation

  • README — SEC EDGAR loader promoted to a top-of-page use case. Three placements: a new 🏦 callout right after the codebase → Claude callout, a 🏦 bullet at the top of the Use cases list, and an SEC EDGAR entry as the first item under Bundled datasets. New “Why Cypher?” section between Use cases and How it compares — one concrete example (insider sells at a specific CIK) plus a hint at how the same pattern shape composes into harder questions (swap :HAS_INSIDER:HOLDS, add :SERVES_ON_BOARD).

  • Comparison table updated to list SEC EDGAR alongside Wikidata and Sodir under “Bundled public datasets”.

[0.9.42] — SEC EDGAR loader deepening (D1–D10)

Added

  • D10 — Use-case tests v2kglite/datasets/sec/tests/test_usecases_v2.py runs 10 SQL-style queries (UC11–UC20) against a fully-deepened synth graph, exercising Subsidiary / MetricFact / Event / Stake / Director on top of the v1 Company/Filing/Person/Transaction/Holds. Records min/avg query timing and prints a summary table.

  • D9 — DEF 14A board parser — new parsers/def14a.rs (11 unit tests) extracts directors via heuristic HTML scanning of “DIRECTORS AND EXECUTIVE OFFICERS” sections. Requires age or “since YYYY” marker to register a name. Expected 50–70% accuracy. extract_directors walks raw/filings/ for def14a/proxy filenames. Blueprint adds Director + SERVES_ON_BOARD edge to Company.

  • D8 — SC 13D activist-stake parser — new parsers/sc13d.rs (8 unit tests) extracts Item 4 purpose text + Item 5 percent owned from 13D HTML via Item-anchor scanning + percent regex. extract_13d_stakes emits Stake nodes linked to Filing.

  • D7 — Storage mode auto-escalation_predict_graph_size_gb + _pick_storage_mode together pick memory / mapped / disk based on years × detailed × CIK-fraction × per-deepening cost. SEC.open() default mode=None is now auto.

  • D6 — Form 4 + 13F batch fetchersfetch_form4_batch and fetch_13f_batch pyo3 functions take a list of (cik, accession, …) and process the whole batch with ONE shared SecClient so the 10 req/s governor token bucket applies across the entire batch.

  • D5 — 13F info-table fetcherfetch_13f_info_table hits the filing’s index.json, discovers the info-table XML filename (type=’INFORMATION TABLE’), and downloads it into raw/filings/.

  • D4 — 8-K Item codesextract_8k_events walks raw/filings/ HTM for Item N.NN patterns via the existing parsers::eightk parser. Blueprint adds Event sub-node + OF_FILING fk_edge. include_8k_events flag in wrapper.

  • D3 — FSNDS XBRLfetch_fsnds_quarterly downloads quarterly ZIPs and extracts NUM.tsv (bulk path, no rate limit). extract_xbrl_metrics filters via the existing DEFAULT_TAG_WHITELIST and emits processed/metric_fact.csv. Blueprint adds MetricFact + REPORTED_IN_FILING fk_edge. CIK is reached via Filing -> FILED_BY -> Company traversal.

  • D2 — Exhibit 21 subsidiaries deepeningextract_subsidiaries(workdir, slice, force) walks raw/filings/{cik}/{accession}/*ex21*.htm (and exhibit21, ex-21 variants), parses via the existing parsers::exhibit21, and emits processed/subsidiary.csv with composite subsidiary_nid = "{parent_cik}_{name_normalized}" for dedup across years. Blueprint adds Subsidiary node + OF_COMPANY fk_edge. Python wrapper gains include_subsidiaries flag. User-story test: M&A analyst stages Exhibit 21 HTML → 3 subsidiaries land + Apple → OF_COMPANY → Subsidiary edges work.

  • D1 — Slice grammar wired end-to-endkglite_sec::SliceSpec { cik_list, form_types, year_range } is now applied uniformly across extract_companies_and_filings, extract_insider_transactions, and extract_holdings. The SEC.open() Python wrapper exposes cik_list, form_types, and year_range kwargs that turn a 5-hour full-universe build into a ~5-minute S&P-500-scoped build. User-story test in test_smoke.py validates that cik_list=[789019] produces a Microsoft-only graph regardless of submissions.zip size.

  • kglite.datasets.sec.SEC.open(path, *, years, detailed, mode, user_agent, ...) — first end-to-end SEC EDGAR loader (phase 3 of the planned loader work). Builds a knowledge graph with Company + Filing nodes connected by FILED_BY edges from a three-tier workdir cache (raw/, processed/, graph/{mode}/). Modes memory and mapped work; disk lands in a later phase. Coexisting per-mode subdirs mean opening with one mode never touches another’s graph. Default behaviour reuses a cached graph on reopen without rebuilding.

  • crates/kglite-sec/ extendedSecClient (10 req/s token bucket, mandatory User-Agent, retry-with-backoff), fetch.rs orchestrator for quarterly master.idx + bulk submissions.zip + company_tickers.json, parsers::submissions streaming parser for the bulk submissions ZIP, extract.rs orchestrator that emits processed/company.csv + processed/filing.csv with dedup across sources.

  • PyO3 wrappers in src/sec.rs — exposes the Rust loader as the kglite._sec_internal submodule. Single-threaded tokio runtime per call; Python callers see plain blocking functions.

  • Phase 9 — live SEC integration testkglite/datasets/sec/tests/test_integration_live.py does an end-to-end build against live SEC (env-gated via KGLITE_SEC_INTEGRATION=1). Builds a 1-year graph in ~4.5s on a dev box: ~388K Filing nodes from live master.idx fetches, with the top-5 form types matching real 2024 SEC volumes (Form 4 80K, 424B2 46K, 8-K 26K, 13F-HR 18K, NPORT-P 17K). Validates the cached graph reopen path too. Fixed an accession-number extraction bug uncovered by the test — real SEC master.idx file paths end in .txt not -index.htm; the parser now accepts either.

  • Phase 8 — disk mode + docsSEC.open(mode="disk") now works via from_blueprint(storage="disk", path=graph/disk/). Disk graphs are loaded on subsequent opens via the cache reuse path. Adds docs/guides/sec.md covering the workdir layout, schema, storage modes, sizing, and caveats (CIK-as-int, CUSIP edge cases, per-filing fetch rate limits).

  • Phase 7 — per-filing detail parsersparsers/eightk.rs extracts standardized Item codes from 8-K cover pages (1.01 = entry into material agreement, 5.02 = officer departure, etc.) via regex-light scanning of stripped HTML. parsers/exhibit21.rs extracts subsidiary lists from 10-K Exhibit 21 documents using a permissive line-by-line heuristic (Exhibit 21 has no SEC-mandated schema). Both parser-only; schema wiring deferred to Phase 8.

  • Phase 6 — FSNDS XBRL parserparsers/fsnds.rs streaming reader for the quarterly Financial Statement and Notes Data Set num.tsv (tab-separated XBRL numeric facts). Whitelist-based filtering with a DEFAULT_TAG_WHITELIST covering 20 high-value us-gaap tags (Revenues, NetIncomeLoss, Assets, etc.). Schema wiring deferred to Phase 8 polish so this phase is parser-only.

  • Phase 5 — 13F institutional holdingsparsers/f13f.rs streaming XML parser for Form 13F-HR information tables; extract_holdings orchestrator walks raw/filings/{cik}/{accession}/*.xml and emits processed/{institutional_manager,security,holds}.csv. Schema gains InstitutionalManager + Security node types and the HOLDS junction edge with shares / value / voting authority properties. PyO3 surface gains extract_holdings_py.

  • Phase 4 — Form 4 insider transactionsparsers/form4.rs streaming XML parser for Form 4 / 4/A (XSD schemaVersion X0508); extract_insider_transactions walks raw/filings/{cik}/{accession}/*.xml and emits processed/{person,transaction,has_insider}.csv. Schema extended with Person node + Transaction sub-node + HAS_INSIDER junction edge (Company → Person, with director/officer/10%-owner flags) + OF_PERSON / INVOLVES_ISSUER / REPORTED_IN_FILING fk_edges. fetch_form4_filing per-accession fetcher for the rate-limited Form 4 ingest path. PyO3 surface gains extract_insider.

Changed

  • README: new top-level Serve it to an agent section between Quick Start and Bundled datasets. Three subsections covering the progressive disclosure of MCP capability: one-command (kglite-mcp-server --graph X.kgl), YAML manifest customisation (source_root, extensions.embedder, inline Cypher tools — with a worked example), and bundled skills (.skills/*.md files that teach agents how to use the tools, with applies_when: predicates so only relevant methodology activates).

  • README intro now opens with a concrete “first graph in seconds” hookpip install kglite + kglite.code_tree.build("."). Sells the embedded, zero-setup pitch before the reader sees any prose about MCP servers or validators.

  • README use-case fixes: “Your pandas DataFrames” widened to “Your structured data” (covers SQL / CSV / Parquet / REST → graph); Wikidata claim corrected (the headline is operate/query a billion-edge graph on a 16 GB laptop, not “loads in 7 minutes” — build is ~90 min, reload is <10 s); RAG bullet rewritten with a concrete legal-corpus example (laws + court decisions + citations, semantic-similar cases → walk to related precedents) instead of a generic “document corpus.”

  • README refocused around the agent-first pitch. Tagline now reads “Knowledge graph for Python, built for LLM agents.” The four use cases (codebase / DataFrames / public datasets / RAG corpus) are reframed as a single Use cases section (five bullets covering domain knowledge for agents, business data, public datasets, RAG, and codebase analysis) instead of competing pitches scattered across Why KGLite? + Use Cases + Key Features. Notebook callout promoted to a banner-style H3 and also linked inline under the codebase use case (visible twice in the first 30 lines). Use Cases consolidated into a tighter Recipes section (MCP serve, hybrid retrieval, structural validators, graph algorithms). Stale 0.9.18 → 0.9.20 migration block removed. Broken #public-datasets anchor fixed. 15 inline links to docs guides (code-tree, data-loading, semantic-search, mcp-servers, graph-algorithms, traversal-hierarchy, recipes, datasets, blueprints, spatial, timeseries, import-export, ai-agents, cypher, querying) sprinkled next to the content they describe rather than dumped only at the bottom. Documentation section reorganised into five themed buckets. Net: 408 → 358 lines.

[0.9.41] — 2026-05-18

Changed

  • [mcp] extras removed. MCP server runtime is now a default dep. pip install kglite ships everything needed to run kglite-mcp-server out of the box: mcp, pyyaml, aiohttp, watchdog (~6 MB combined). No more extras-dance for Claude Desktop / Cursor / any MCP client. The old [mcp] name was confusing (it bundled the MCP runtime with the embedder), and after Phase 2’s mcp-methods wheel drop the remaining runtime footprint was small enough to default- ship. Breaking: pip install 'kglite[mcp]' no longer resolves; use pip install kglite. People who used [mcp] for semantic search should now use [embed] (see below).

  • New [embed] extra for semantic search. pip install 'kglite[embed]' pulls fastembed>=0.4 (and ~97 MB of transitives: onnxruntime, tokenizers, pillow, huggingface-hub). Required for text_score() semantic Cypher and the extensions.embedder manifest extension. Niche use case, hence opt-in. Same ONNX backend as before, same ~/.cache/fastembed/ model cache.

  • Notebook + README install instructions updated to the new flat pip install kglite shape. Old [mcp] references in dev-documentation/mediumpost.md swapped too.

  • mcp-methods PyPI wheel no longer a runtime dependency. Skill loading routes through new kglite._mcp_internal.SkillRegistry / kglite._mcp_internal.Skill pyo3 wrappers (in src/mcp_tools.rs), which delegate to mcp_methods::server::SkillRegistry::from_manifest added in the upstream Rust crate at 0.3.38. Drops ~16 MB of upstream wheel + bundled binary from [mcp] extras (~93 MB → ~77 MB). No orchestration logic on kglite’s side — upstream stays canonical. Behaviour is byte-identical against tests/test_mcp_server_python_entry.py -k skill (5 passing) and against examples/open_source_workspace_mcp.yaml end-to-end (5 framework skills load, provenance strings format exactly as the prior pyo3 wheel did: "project" / "bundled" / "domain_pack:<path>"). kglite/mcp_server/skills_loader.py swapped the import mcp_methods to from kglite import _mcp_internal; pyproject.toml dropped mcp-methods>=0.3.36 from [mcp] extras; Cargo.toml pinned mcp-methods floor to 0.3.38 for the new Registry::from_manifest helper.

  • examples/codebase_to_claude_mcp.ipynb polish (no API change):

    • Drop the gratuitous str(ws.root)code_tree.build() already accepts os.PathLike. The notebook now reads build(ws.root).

    • The REPO = comment clarifies storage modes: in-memory (default) handles repos up to millions of LoC; Wikidata-scale graphs need kglite.KnowledgeGraph(storage="disk", path=…).

    • Requirements line corrected to pip install kglite — the notebook itself doesn’t pull any [mcp]-only deps (verified by import). pip install 'kglite[mcp]' is now framed as the env Claude Desktop uses to spawn kglite-mcp-server, not a notebook requirement.

[0.9.40] — 2026-05-18

Added

  • KnowledgeGraph.shape property and human-readable __repr__. g.shape returns (node_count, edge_count) pandas-style — O(1) via the storage backend, no per-type breakdown computed. repr(g) and print(g) now produce KnowledgeGraph(1,245 nodes, 2,996 edges) instead of the default <builtins.KnowledgeGraph object at 0x…>. Use schema() / describe() for full per-type structure when needed.

Changed

  • examples/codebase_to_claude_mcp.ipynb cell 1 now uses kglite.mcp_server.workspace.Workspace (the built-in clone + auto-prune system with stale_after_days) instead of subprocess.run for git clone, and uses print(graph) (the new __repr__) instead of manual schema() lookup. Same workspace dir is reachable from the Claude Desktop MCP server registered in cell 4, so the demo state is continuous.

  • README: notebook callout promoted to immediately after the lead paragraph (was buried in the Examples section near the bottom). Same link still exists in Examples for completeness.

[0.9.39] — 2026-05-18

Fixed

  • code_tree route detector: tuple-form methods=(...) no longer leaks parens into Route.method and Route.id. Flask’s own tutorial uses @app.route('/x', methods=('GET', 'POST')) (tuple), not the list form. Before the fix, parse_methods_list only stripped [/] brackets, so methods came out as ("GET / POST") and ids as flask::("GET::/register. Now accepts list [...], tuple (...), and bare-string forms. Regression covered by tests/test_code_tree_routes.py::test_flask_route_methods_tuple_form and Rust unit tests in src/code_tree/builder/routes/mod.rs.

Added

  • kglite.mcp_server.claude_config — standalone Python helpers for managing MCP server entries across Claude clients: list_mcps / get_mcp / add_mcp / edit_mcp / delete_mcp, plus default_path(client). Supports client="claude_desktop" (platform- aware path to claude_desktop_config.json), client="claude_code" (~/.claude.json), client="vscode" (./.vscode/mcp.json — writes the servers key with type: stdio instead of mcpServers), and arbitrary path="/custom/config.json". Mutations are atomic (write-tmp + os.replace) and preserve every other top-level key in the config — important because claude_desktop_config.json also stores preferences, scheduled-task flags, etc. that must not be clobbered. dry_run=True returns the would-be entry without touching disk. add_mcp / edit_mcp default resolve_command=True: bare binary names are resolved to absolute paths via shutil.which at write time, so the entry survives Claude Desktop’s minimal-PATH subprocess environment (avoids the silent “server doesn’t start” mode where the bare name isn’t on Claude’s launch PATH). Pass resolve_command=False for Docker shims or wrapper scripts.

  • examples/codebase_to_claude_mcp.ipynb — end-to-end notebook: clone a famous open-source repo, parse it into a code knowledge graph, run a few Cypher queries, then register a workspace MCP server in Claude Desktop (via the new claude_config helpers) so the agent can repo_management('org/repo') any GitHub repo on demand.

Changed

  • README: new “How it compares” section positioning KGLite vs Kuzu, NetworkX, rustworkx, and Neo4j Embedded — install, query language, storage, pandas bulk-load, MCP server, describe(), code_tree, bundled datasets, and license.

  • LICENSE: normalised to the canonical MIT template (3-paragraph grant, straight ASCII quotes). No legal change; the previous text was condensed enough that GitHub’s licensee auto-detector misread it as MIT-0. Detection should flip back to mit on the next push.

[0.9.38] — 2026-05-17

Added

  • Mode banner in the MCP server’s instructions block and graph_overview() preamble. Operators running several MCP servers in parallel previously had no clean way to tell which conditional tools (repo_management, set_root_dir, save_graph) were registered in the current mode — agents were fingerprinting mode by trial calls, burning context and turns. The Python entry (kglite.mcp_server.server) now prepends a per-mode banner to both:

    • the instructions block returned during MCP initialize (read once at handshake), and

    • the bare graph_overview() response preamble (re-read on each call, survives context aging).

    Banner names every conditional tool — both the registered ones AND the unregistered ones — across all six modes (graph, workspace, local_workspace, source_root, watch, bare), and flips the save_graph line based on builtins.save_graph. Marker [kglite-mode] identifies the segment for downstream tooling.

[0.9.37] — 2026-05-17

Post-0.9.36 operator-feedback batch. Four independent fixes from the sodir-prospect / legal / open-source MCP session:

Added

  • kglite_version in graph_overview() header. Every <graph> opening tag now carries a kglite_version="…" attribute sourced at compile time from Cargo.toml. Makes client-side ↔ server-side version skew obvious at first inspection (previously a silent failure mode — schema rendered by one version while subsequent queries routed to another). Visible in all four graph-overview shapes: small / medium / large / extreme inventories and the focused-detail XML returned by graph_overview(types=[…]).

Changed

  • Agent-facing hints now name the MCP tool, not the Python method. The XML emitted by describe() / graph_overview() is overwhelmingly consumed by AI agents via the graph_overview MCP tool, but every inline hint pointed at describe(connections=…), describe(types=…), etc. — agents following the hint hit a wall because there is no describe MCP tool. Renamed all agent-facing hints from describe(…) to graph_overview(…) in describe.rs and topics.rs. The Python method KnowledgeGraph.describe(…) is unchanged; the single doc-entry that documents that Python signature also stays as-is.

Fixed

  • is_test no longer false-positives on names like latest.html, contest.css, protest.swift. The HTML / CSS / Swift / PHP parsers used rel_path.to_lowercase().contains("test") — a loose substring check that misclassified every file containing the four letters anywhere in its path. Introduced parsers::shared::is_test_path(rel_path, filename, suffix_patterns) which (a) checks language-specific filename suffixes, (b) checks for full path segments equal to test / tests / __tests__ / spec / specs. No substring matches. TypeScript also gained recognition of test/ and tests/ directories (previously only __tests__/ was honoured). Go and Python keep their own narrow detectors (*_test.go, test_*.py / *_test.py) — too specific for the shared helper.

  • exists(n.prop) now steers callers to IS NOT NULL. KGLite implements the modern pattern-existence forms (EXISTS { (n)-[:R]->() } and EXISTS((n)-[:R]->())) but not the Neo4j legacy property-existence form exists(n.prop). The previous error message pointed at the pattern syntax — sending operators down the wrong rabbit hole when they actually wanted WHERE n.prop IS NOT NULL. Parser now peeks the three tokens after exists(; when they look like <ident> . <ident>, the error explicitly labels the legacy syntax, recommends IS NOT NULL, and also names the supported pattern-existence alternatives. Other malformed exists(…) calls keep an expanded generic message covering both alternatives.

[0.9.36] — 2026-05-17

Web-stack language expansion: closes the biggest remaining language gap by adding PHP, HTML, and CSS — taking KGLite to 13 supported languages. HTML’s “god-file” workflow (single-page-app HTML holding the whole app’s outline + inline JS + forms) is first-class.

Plus two docs-only commits that landed on the unpushed branch earlier: the “avoid double version bumps” rule and the CLAUDE.md dedupe pass (199 → 120 lines). Per the new “One version bump per push” rule, both ride this release.

Release-mode bench gate (release_0936 vs release_0935_v3, same binary built with –release on commit 4a83328 vs 2ecca3e):

Bench

0.9.35 min

0.9.36 min

label_pair_counts_compute

82.8 µs

84.5 µs

planner_two_match_skewed

6.9 µs

7.0 µs

cypher_where

170.8 µs

205.5 µs

columnar_enable

220.8 µs

219.5 µs

add_nodes

266.0 µs

259.6 µs

add_connections

430.2 µs

429.9 µs

save_v3

418.6 µs

416.8 µs

Min-times stable across the board within ±5% (well inside noise per the pytest-benchmark hygiene rule). The new parsers don’t touch any existing hot paths; their cost shows up only when a PHP/HTML/CSS file is parsed. The new bench code_tree_build (god-HTML fixture) captures the end-to-end cost of HTML parsing + embedded JS extraction: 1.16 ms min on the synthetic Flask-shaped package.

Added (code_tree — languages)

  • PHP language parser (.php). Full coverage of classes, interfaces, traits, methods, functions, constants, use imports, namespace declarations (backslash separator), and PHP-8 attributes (#[Route('/x')]) → DECORATES edges via the 0.9.34 pass. Trait declarations land as ClassInfo kind="trait" (matching the Rust-trait encoding). The resolve_owner helper in builder/type_edges.rs gained \ to its separator list so HAS_METHOD edges resolve correctly on PHP qnames.

  • HTML language parser (.html/.htm). God-HTML-file ready: emits new Element nodes for headings (h1-h6), elements with id, and <form action=...> shapes. Restraint built in — decorative <div>/<span>/<p> elements without id stay parse noise. Element -[HAS_CHILD]-> Element edges form the document outline. Inline <script>...</script> blocks are parsed by the existing JS sub-parser; resulting Functions get full CALLS-edge analysis with qnames scoped to <file>:script_<n>. so multi-block helpers don’t collide. <script src="..."> and <link rel="stylesheet" href="..."> populate FileInfo.imports → File→File IMPORTS edges.

  • CSS language parser (.css). Emits Selector nodes (one per rule_set regardless of selector-list count — .foo, .bar, .baz is ONE node, not three), CSS custom properties (--my-color: red) as ConstantInfo with kind="css_custom_property", and @import url(...) / @import "..." → FileInfo.imports. @media / @supports / @layer / generic at-rules are unwrapped — their nested rule_sets emit normal Selector nodes. Regression guard: CSS files never emit Function or Class nodes.

  • Element and Selector node types. Two new graph-schema node types introduced alongside the HTML and CSS parsers. The graph’s dynamic schema picks them up automatically; planner caches (label_pair_counts, refresh_stats) enumerate them like any other type.

Language count: 10 → 13. See docs/guides/code-tree.md for the full node-type list, the god-HTML-file workflow, and the CSS design-token discovery query.

[0.9.35] — 2026-05-17

AgensGraph-inspired planner/lookup improvements. Three commits land:

  • Label-pair edge-count cache (planner selectivity). Generalises the pre-existing type_connectivity_cache into a lazy, mutation-invalidated authority on (src_type, edge_type, tgt_type) count. The planner’s reorder_match_clauses pass now uses per-triple counts when both endpoints carry a label — typically 10–100× tighter on label-skewed graphs than the old “all R edges” proxy.

  • refresh_stats() Cypher procedure. Operator-callable cardinality recomputation, mostly useful as a “what does the planner see?” diagnostic.

  • nodes(p) dicts now include every node property. Lets agents UNWIND nodes(p) AS n RETURN n.age without re-MATCHing each node. Wire shape unchanged.

Side fix: maintain::add_connections (the Python bulk-mutation path) now invalidates the edge-cardinality caches. Pre-0.9.35 only Cypher CREATE/DELETE did; bulk inserts left the existing edge_type_counts_cache stale.

Release-mode bench gate (release_0935 vs release_0934_v2): no consistent regressions across the 11 core benches. Min-times stable or marginally better on 0.9.35; median noise within prior 0.9.34 variance. The planner’s new selectivity branch shows up in test_bench_planner_two_match_with_skewed_labels at 7.3 µs median.

Deferred: the node-label cache (Vec<InternedKey> indexed by NodeIndex) flagged in the AgensGraph review. Profiling didn’t surface node_type_of() as a bottleneck against the planner-perf bench, so the ~150 lines of maintenance + parity-test code didn’t penciled out. Will revisit if it shows up as a real hot frame in a future profile.

Changed (Cypher)

  • nodes(path) dicts now include every node property, not just {id, title, type}. Lets agents use UNWIND nodes(p) AS n RETURN n.age without re-MATCHing each node to fetch property values. Previous dict keys are unchanged; this is purely additive — code that explicitly checked set(dict.keys()) == {"id","title","type"} will see extra keys. Storage and wire shape unchanged (still a JSON-string list under KGLite’s Value::String convention).

Added (Cypher)

  • CALL refresh_stats() YIELD src_type, edge_type, tgt_type, count — operator-callable recomputation of the label-pair edge-count cardinality cache. Forces a fresh O(E) walk of every edge and yields one row per (src_type, edge_type, tgt_type) triple with its current count. Useful for bulk-load workflows that bypass the mutation paths the cache invalidator listens on, and as a “what does the planner think the schema looks like right now?” diagnostic.

    All four YIELD columns are optional individually — the caller may request any subset. Output rows are sorted by (src_type, edge_type, tgt_type) for stable diffing between calls.

Added (planner)

  • Label-pair edge-count cardinality cache. Generalises the pre-existing type_connectivity_cache (which had only been populated by the n-triples loader) into a lazy, mutation-invalidated cache that authoritatively records (src_type, edge_type, tgt_type) count for every graph. The Cypher planner’s reorder_match_clauses pass now uses these triple counts instead of the broader edge-type totals when both endpoints carry a label — typically 10–100× tighter on label-skewed graphs (the AgensGraph-inspired pattern).

    Exposed via KnowledgeGraph.label_pair_counts() returning [(src_type, edge_type, tgt_type, count), ...]. Computed O(E) on first access; subsequent reads are essentially free (release-mode bench: warm read 185 ns).

Fixed

  • Python add_connections now invalidates the edge-cardinality caches. Pre-0.9.35 the existing edge_type_counts_cache would go stale after bulk inserts via the Python API — only Cypher CREATE/DELETE triggered invalidation. Sequences like cypher("CREATE …") add_connections(…) planner-cost-driven query could read stale cardinalities. Fixed alongside the new label-pair cache wiring.

[0.9.34] — 2026-05-17

Code-graph expansion release: closes the feature gap with colbymchenry/codegraph in six commits — File→File IMPORTS edges, the affected_tests Cypher procedure, DECORATES edges, web-framework route extraction (Flask/FastAPI/Django), the explore() one-call codebase tool (pymethod + MCP), and a minimal Swift parser.

Release-mode benchmark snapshot on a synthetic Flask-shaped package (saved as release_0934 under .benchmarks/):

Surface

Median

CALL affected_tests

2.3 µs

MATCH (Function)-[:DECORATES]->(Function)

6.0 µs

MATCH (Route)-[:HANDLES]->(Function)

8.8 µs

kg.explore(query) with source slicing

17.4 µs

code_tree.build() end-to-end

600 µs

Core in-memory benchmarks (tests/benchmarks/test_bench_core.py) were re-run release-mode against 0.9.33 under tightened conditions (--benchmark-min-rounds=200 --benchmark-warmup=on, 30-s thermal settle between runs) and show no real regressions. The original perf-gate single-run flagged three benches with apparent regressions of +44% to +127%; the tightened runs show those deltas were pytest-benchmark variance on M-series macOS — repeating the same 0.9.34 measurement gave columnar_enable medians of 516 µs and 229 µs minutes apart on the same binary, and cypher_match swung between 5.6 µs and 11.6 µs across runs of identical code. The min-time across all measurements stayed flat (cypher_match ~5.1 µs, columnar_enable ~219 µs), which is the cleaner signal at these microsecond scales.

Future regressions in any of these surfaces will surface via make bench-compare against the saved release_0934 baseline. Bench-hygiene rule lives in CLAUDE.md’s Performance Work Protocol: release-mode always, min-rounds ≥ 100, thermal settle between versions, and trust min over median for sub-millisecond benches.

Added (code_tree — Swift)

  • Swift parser. New src/code_tree/parsers/swift.rs via tree-sitter-swift = "0.7.2". Coverage in 0.9.34:

    • class / struct / actor / enum declarations — emitted as Class/Struct nodes with kind tagged from the grammar’s declaration_kind field (Swift’s grammar collapses all five into one AST node).

    • protocol declarations → Interface nodes with kind="protocol".

    • Top-level and method func declarations → Function nodes with HAS_METHOD edges from their owning type.

    • import Foundation → FileInfo.imports → File→Module IMPORTS.

    • Visibility (public / internal / fileprivate / private).

    • CALLS edges resolve via the existing 5-tier name resolver.

    Follow-up scope (separate PRs): extension IMPLEMENTS edges, init / subscript / computed properties, @objc / @MainActor attributes as decorators, async / throws flags. The parser structure leaves clean slots for each.

Added (code_tree)

  • File → File IMPORTS edges. Sibling to the existing File → Module IMPORTS, resolved via a module_path file_path reverse index using the same longest-prefix walk as the module resolver. Multiple imports from one source to the same target collapse into a single edge whose import_count property records the multiplicity. Enables direct file-level impact analysis in one Cypher hop — MATCH (changed:File {path: 'src/foo.py'})<-[:IMPORTS*1..]-(impacted:File) — without joining through Module nodes.

Added (explore)

  • KnowledgeGraph.explore(query) and matching explore MCP tool. One-call codebase exploration over a code-tree graph: lexically ranks Function/Class/Interface/Struct/Trait/Protocol/Enum nodes against a free-text query (name > signature > docstring), takes the top max_entities, 2-hop traverses CALLS / USES_TYPE / HAS_METHOD / DEFINES / REFERENCES_FN, and returns a markdown report with entry points, a relationship-map neighborhood, and grouped source slices for the entry points. Designed for the “how does X work in this codebase” Explore-agent question that would otherwise turn into chained grep + read calls — closes the feature gap with colbymchenry/codegraph’s codegraph_explore tool while composing existing primitives rather than building a parallel index.

    Pyfunction signature:

    kg.explore(query, max_entities=10, max_depth=2,
               include_source=True, source_roots=None)
    

    MCP tool ships with bundled methodology (kglite/mcp_server/skills/explore.md) gated on graph_has_node_type: [Function, Class] so it only activates on code-tree graphs.

Added (code_tree — routes)

  • Web-framework Route extraction. New Route node type plus Route -[HANDLES]-> Function edges, synthesized from decorators and urlpatterns constants. Three frameworks in v1: Flask (@app.route, @app.get/@app.post/…, blueprints), FastAPI (@router.get, @app.get, all HTTP verbs), and Django (urlpatterns = [path('x/', view)] in any urls.py-shaped file). @app.route decorators with methods=[...] fan out to one Route per method so WHERE r.method = 'DELETE' queries match correctly. Express, Axum, Rails, Spring, Laravel land as follow-up PRs — they need parser-side capture of call arguments which the parser model doesn’t preserve today; the per-framework module layout under builder/routes/ makes each subsequent framework a single-file addition.

    MATCH (r:Route)-[:HANDLES]->(f:Function)
    WHERE r.framework = 'fastapi' AND r.method = 'POST'
    RETURN r.path, f.qualified_name
    
  • urlpatterns is now extracted as a top-level constant on Python files. The constant-extraction filter previously required SCREAMING_SNAKE_CASE; a narrow framework-allowlist now also lets Django’s lowercase urlpatterns through so the route extractor can read the list literal. Other lowercase names remain filtered out.

Added (code_tree — DECORATES)

  • Function → Function DECORATES edges. The Python/TS/Java/C# parsers already extracted FunctionInfo.decorators as raw strings; a new builder pass now resolves each to a target Function via the same bare-name lookup CALLS uses. Strips call-args (@app.route('/x')app.route) and the namespace prefix (functools.wrapswraps). Edge property decorator_name preserves the original literal so downstream queries don’t have to re-parse the Function.decorators property. Unresolved (third-party) and ambiguous decorators are silently dropped — same stance as the call-edge resolver.

    MATCH (d:Function)-[:DECORATES]->(f:Function)
    WHERE d.name = 'cache' RETURN f.qualified_name
    

Added (Cypher)

  • CALL affected_tests({files: [...], max_depth?}) YIELD test_file, depth — given a seed set of changed file paths, BFS over inbound IMPORTS edges and yield reachable File nodes whose is_test property is true. Either yield column is optional individually (the common case is YIELD test_file to get just a list of paths). Builds directly on the 0.9.34 File → File IMPORTS edges; closes parity with the codegraph affected CLI feature from colbymchenry/codegraph but as pure Cypher rather than a separate command.

    CALL affected_tests({files: ['src/utils.rs', 'src/api.rs']})
    YIELD test_file
    RETURN test_file ORDER BY test_file
    

[0.9.33] — 2026-05-14

mcp-methods 0.3.37 adopted both operator-reported fixes from the 0.9.31 deployment audit, including kglite’s stop-gap full-body skill-inject shape verbatim as the framework canonical. This is a pin-bump release: the Rust binary path picks up the canonical behaviour automatically; the Python entry’s _apply_skill_hint stays — same shape, no longer “stop-gap”, now aligned with the framework’s serve_prompts auto-inject pass.

Folds in the unpublished 0.9.32 work (overview_prefix plumbing + auto-inject full-body) so the cumulative diff against 0.9.31 is one coherent release rather than two adjacent versions on PyPI.

Changed

  • mcp-methods pin bumped to 0.3.37 (was 0.3.36). Picks up:

    • The framework’s serve_prompts auto-inject pass now embeds the full skill body under a ## Methodology header instead of the dangling [See prompts/get NAME ...] pointer. Operators running the standalone Rust binary (crates/kglite-mcp-server/) get the canonical behaviour without any kglite-side change.

    • ResolvedRegistry.parse_warnings() Rust getter and SkillRegistry.parse_warnings Python getter for the silent-skill-drop visibility (mcp-methods bug 1). The framework’s tracing::warn! channel also continues to fire. Operator-visible boot summary integration in the Python entry queued for 0.9.34 (the PyPI publish of the 0.3.37 wheel is still in flight at release-cut time).

Fixed

These fixes were drafted as 0.9.32 commits but folded into 0.9.33 so a single coherent cut publishes to PyPI:

  • overview_prefix: from the manifest is now prepended to bare graph_overview() output. Pre-0.9.32 the field was parsed by manifest.py but never read by tools.py::run_overview in the Python entry path. The FastMCP path at mcp_methods/fastmcp/_overview.py had honoured it correctly; kglite’s Python entry didn’t. Operators authoring documented overview_prefix: blocks were getting silently-dropped content. run_overview now accepts an optional overview_prefix keyword, prepended only on bare-overview calls (no types=... / connections=... / cypher=... drill-down args), matching the framework’s behaviour and the documented contract.

  • _apply_skill_hint injects the full skill body, not a dangling prompts/get pointer. Operator empirically confirmed that agents in Claude Code, Claude Desktop, Cursor, and Continue don’t expose prompts/get to the model — the MCP prompts/* plane was designed for human slash commands in chat UIs, not agentic retrieval. The pre-0.9.33 bracketed pointer ([See prompts/get NAME for full methodology.]) was a dangling reference in those clients. 0.9.33 embeds the skill body under a ## Methodology header in the matching tool’s description so it reaches the agent via tools/list, which every MCP client exposes. Capped at the framework’s 16 KB hard limit / 4 KB soft target. Operators can still set auto_inject_hint: false per-skill to suppress the embed.

  • mcp-methods Python wheel added to [mcp] extras (pyproject.toml). Without the framework’s Python wheel, SkillRegistry.from_manifest is unavailable and skills_loader.py::load_framework_skills silently returns the empty list — project-layer <basename>.skills/ overrides and operator-declared domain packs silently don’t load. CI surfaced the gap when test_o3 (the auto_inject_hint: false escape-hatch test) failed against an environment without the wheel manually installed.

Added (regression tests)

  • O1: overview_prefix: is prepended to bare graph_overview().

  • O2: overview_prefix: is NOT prepended to drill-down calls (types=[...] etc.).

  • O3: auto_inject_hint: false per-skill suppresses the body embed in the matching tool’s description.

  • O4: Re-calling list_tools doesn’t double-inject (the ## Methodology header is the idempotency marker).

  • SK4 updated to assert the full-body embed semantics.

85/85 mcp Python tests green (was 81 in 0.9.31).

Sequencing notes

The mcp-methods 0.3.37 wheel publish to PyPI is still in flight at release-cut time. CI’s pip install kglite[mcp] resolves mcp-methods>=0.3.36 against PyPI’s current state — once 0.3.37 lands there, pip install --upgrade on kglite will pull it through. The Cargo dep already resolves to 0.3.37 on crates.io, so the Rust binary path (and the wheel’s bundled Rust extension) get the canonical inject behaviour today.

Yanked

The 0.9.32 git commits (50a41c6...54bd095) describe the same fixes that ship in 0.9.33; 0.9.32 was never published to PyPI. Its CI workflow was cancelled in favour of the single 0.9.33 cut so PyPI’s release history stays clean. Operators who pulled 0.9.32 wheels from the in-flight build artifacts (if any) should upgrade directly to 0.9.33.

[0.9.32] — 2026-05-14 — unpublished

Reserved version: the Cargo.toml line was bumped to 0.9.32 mid-day on 2026-05-14 in commits 50a41c6 and 54bd095. CI was cancelled mid-flight in favour of a single 0.9.33 cut bundling the same fixes plus the 0.3.37 framework pin. No 0.9.32 wheel exists on PyPI. See 0.9.33 above for the actual operator-facing changes.

[0.9.31] — 2026-05-14

Two same-day operator bug reports from the 0.9.31 deployment. Both confirmed root-cause; one is kglite-side and shipped here, the other is a framework-design issue forwarded to mcp-methods with our reading of the trade-offs.

Fixed

  • overview_prefix: from the manifest is now prepended to bare graph_overview() output. Pre-0.9.32 the field was parsed by manifest.py but never read by tools.py::run_overview in the Python entry path. The FastMCP path at mcp_methods/fastmcp/_overview.py had honoured it correctly; kglite’s Python entry didn’t. Operators authoring documented overview_prefix: blocks were getting silently-dropped content. run_overview now accepts an optional overview_prefix keyword, prepended only on bare-overview calls (no types=... / connections=... / cypher=... drill-down args), matching the framework’s behaviour and the documented contract.

  • _apply_skill_hint injects the full skill body, not a dangling prompts/get pointer. Operator empirically confirmed that agents in Claude Code, Claude Desktop, Cursor, and Continue don’t expose prompts/get to the model — the MCP prompts/* plane was designed for human slash commands in chat UIs, not agentic retrieval. The pre-0.9.32 bracketed pointer ([See prompts/get NAME for full methodology.]) was a dangling reference in those clients. 0.9.32 embeds the skill body under a ## Methodology header in the matching tool’s description so it reaches the agent via tools/list, which every MCP client exposes. Capped at the framework’s 16 KB hard limit / 4 KB soft target. Operators can still set auto_inject_hint: false per-skill to suppress the embed (useful for clients that DO expose prompts/get, or where context cost matters more than reachability).

    This is a kglite Python-entry stop-gap; the framework’s canonical fix is being discussed with the mcp-methods maintainer (their serve_prompts auto-inject pass would benefit from the same upgrade for the Rust binary path + every other framework consumer). When that lands, this kglite-side implementation will converge.

Added (regression tests)

  • O1: overview_prefix: is prepended to bare graph_overview().

  • O2: overview_prefix: is NOT prepended to drill-down calls (types=[...] etc.).

  • O3: auto_inject_hint: false per-skill suppresses the body embed in the matching tool’s description.

  • O4: Re-calling list_tools doesn’t double-inject (the ## Methodology header is the idempotency marker).

  • SK4 updated to assert the full-body embed semantics (previously asserted the dangling-pointer shape).

85/85 mcp Python tests green (was 81 in 0.9.31).

Acknowledged but not yet fixed (forwarded to mcp-methods)

  • SkillRegistry.from_manifest silently drops files with YAML frontmatter parse errors. Operator hit this with a colon-in-value in an unquoted description: field; spent 25 minutes debugging because no log line surfaces. Framework owns the parser; mcp-methods inbox has the bug report with operator- ranked fixes (log.warning on each skipped file; return parse_warnings on the registry; scaffold helper hints).

  • Skills via prompts/get are unreachable in real MCP clients. Forwarded as a framework-wide design issue with the operator’s ranked fixes (auto-inject full body — adopted here as a stop-gap; expose get_skill as a tool; document the limitation). Maintainer decides the canonical shape; we’ll converge once they ship.

[0.9.31] — 2026-05-14

Skills-aware MCP. Ships kglite-authored methodology for the four custom tools (cypher_query, graph_overview, save_graph, read_code_source) plus the wiring to compose them with framework defaults + operator-side layers + predicate-gated filtering. Opt-in per manifest via skills: true (or a path list); existing deployments without that declaration see no behavioural change.

Added (MCP server)

  • Four bundled skills under kglite/mcp_server/skills/, authored against mcp-methods 0.3.35’s writing-effective-skills.md guide (TRIGGER/SKIP descriptions, Overview → Quick Reference → Common Pitfalls → When wrong body anatomy, ~150-220 lines each). Shipped as Python package data; include_str!’d into the Rust binary at crates/kglite-mcp-server/src/main.rs. One source of truth across both shipping paths.

  • SkillRegistry wiring in the standalone Rust binary (crates/kglite-mcp-server/src/main.rs): add_bundled for each of the four kglite skills, merge_framework_defaults, auto_detect_project_layer, layer_dirs(manifest.skills), predicate evaluator (KglitePredicateEvaluator consults graph_state.has_node_type / has_property for the graph_has_node_type: / graph_has_property: clauses), finalise. Wired into serve_prompts(&registry, &mut server) before the stdio loop.

  • Python entry point prompts handlers at kglite/mcp_server/server.py. The lowlevel mcp.server.Server surface doesn’t have the framework’s FastMCP-shaped register_skills_as_prompts helper, so we hand-roll @server.list_prompts() and @server.get_prompt() backed by skills_loader.build_active_skill_set(...). Predicate gating is re-evaluated at request time so post-boot graph state changes (workspace activation, watch rebuild) reflect immediately.

  • kglite/mcp_server/skills_loader.py — minimal frontmatter parser, Skill/AppliesWhen dataclasses, three-layer merge (kglite-bundled + framework + operator), and runtime applies_when: evaluation. Lives entirely in Python; talks to the framework via mcp_methods.SkillRegistry.from_manifest(...) for the framework+operator layers.

  • Auto-inject hint pass. When a skill’s name matches a registered tool and auto_inject_hint: true (default), the tool’s description gains a [See prompts/get <name> for full methodology.] pointer in tools/list. Agents that scan tools first still discover the methodology surface.

  • Manifest.skills field on the Python dataclass (kglite/mcp_server/manifest.py) parsed from the framework’s polymorphic JSON shape (false / array of true/path entries).

Changed

  • mcp-methods pin bumped to 0.3.36 (was 0.3.34). Picks up applies_when: predicate gating (0.3.36) and skills foundation (0.3.35). Both shipped by the maintainer in response to our design feedback within the same day; no design changes during review.

Added (regression tests)

  • SK1: Manifest without skills: exposes no prompts (opt-in property; pre-0.9.31 behaviour preserved by default).

  • SK2: skills: true exposes kglite-bundled skills via prompts/list. read_code_source filtered out via applies_when on non-code graph fixtures.

  • SK3: prompts/get cypher_query returns the bundled markdown body with description.

  • SK4: Auto-inject hint appends [See prompts/get ...] to matching tool descriptions when skills are enabled.

  • SK5: read_code_source skill ACTIVE on a code-tree graph (Function/Class present); proves the predicate evaluator consults live graph state.

  • SK6: prompts/get with unknown name returns a clean JSON-RPC error.

81/81 mcp Python tests green (was 75 in 0.9.30). Standalone Rust binary builds clean; cargo fmt --check + cargo clippy -- -D warnings clean across the workspace.

Example

examples/open_source_workspace_mcp.yaml opts in with skills: true and carries explanatory comments covering the three value shapes (true / single path / list form) plus the applies_when: predicate behaviour for the legal / o&g / code deployment shape.

[0.9.30] — 2026-05-14

Operator-reported friction from the 0.9.29 deployment audit: agent-facing schema clarity (Item 2), MCP tool-search round-trip counts (Item 1), and identical-tool-surface ambiguity across multiple kglite servers (Item 3). All four items fixed in one release.

Fixed (code_tree)

  • module property is now populated on every code-tree entity type, not just File and Module. Operator reported MATCH (f:Function) WHERE f.module STARTS WITH 'xarray.core' RETURN f returned zero rows — the property only existed on File/Module nodes. The code_tree builder now looks up each Function/Class/Constant/Enum/Interface/Trait/Protocol/Struct’s file_path in a file → module_path map and populates a module property derived from the parent file’s module. Module nodes also get a module alias of their qualified_name for cross- type uniformity. Result: WHERE n.module STARTS WITH '...' works against any node label without branching.

Added (introspection)

  • <prop sample="..." /> attribute on high-cardinality properties in describe() / graph_overview output. Pre-0.9.30 the schema XML showed vals="..." for properties with ≤15 unique values (low-cardinality enums); high- cardinality properties (docstring, signature, file_path with hundreds of values) showed only unique=N with no example. Now one example value is emitted as a sample="..." attribute whenever vals= would be omitted, so the agent always sees what the property looks like (e.g. signature becomes sample="def to_list (self) -> list[dict[str, ..." instead of just unique="16"). Same logic applied to edge property stats in <connections> blocks.

Added (MCP server)

  • Auto-injected ToolSearch batch-load hint into the server’s instructions: field. Operator reported deferred-tool loaders (Claude Code’s ToolSearch) gating each tool family per-tool, forcing N round trips to load N tools from one server. The server now prepends a one-paragraph hint to operator-declared instructions: describing the ToolSearch(query='+<server-slug>', max_results=20) batch-load pattern (one round trip per server). Idempotent: composing twice does not duplicate the hint. Operators who want full control can include the literal marker [kglite-batch-load-hint] in their instructions text to suppress auto-injection.

  • tools[].bundled: <name> overrides accept a rename: field (mcp-methods 0.3.34+). Operator reported that running three kglite servers exposing identical bundled surfaces produced six near-identical entries in ToolSearch results, ambiguous to rank. rename: lets operators expose a bundled tool under a per-deployment name (e.g. legal_cypher_query, prospect_cypher_query) while the canonical handler still runs the body. Composes with the existing description: and hidden: overrides. Boot-time validation refuses renames that shadow another bundled tool, another rename, or a manifest-declared cypher tool.

Changed

  • mcp-methods pin bumped to 0.3.34 (was 0.3.33). Picks up the tools[].bundled: rename: extension. The framework patch was applied locally to mcp-methods, tested (16/16 bundled tests green; +6 new entries covering rename validation and JSON shape), and proposed to the maintainer via inbox note.

Added (regression tests)

  • B14: bundled: cypher_query + rename: legal_cypher_query exposes the renamed identifier in tools/list and removes the canonical name from the listing.

  • B15: Call to renamed tool dispatches through the canonical handler (proves rename isn’t visible-only).

  • B16: Renaming a bundled tool to a name that shadows another bundled tool fails at boot with a clear collision error.

  • B17: Renaming to a name that shadows a cypher tool fails at boot.

  • I1 / I2 / I3: Batch-load hint appears in composed instructions, idempotent across repeated composition, suppressible by operator-supplied marker.

  • S1: module property is populated on Function / Class / Constant / Module / File nodes uniformly (operator’s literal reproducer).

  • S2: describe() emits sample="..." for high-cardinality properties.

75/75 mcp tests green (was 66 in 0.9.29). 603/603 Rust unit tests green.

[0.9.29] — 2026-05-14

Two operator-reported fixes from the post-0.9.28 deployment audit: a hardcoded port-collision default that made parallel server boot impossible, and a workspace manifest layout that forced explicit --mcp-config for the natural folder shape. The second is fixed upstream in mcp-methods 0.3.33 via an opt-in workspace.applies_to declaration — kglite 0.9.29 bumps the pin to pick it up.

Fixed

  • csv_http_server default port changed from 8765 to 0 (OS-assigned). When Claude Desktop launches multiple kglite-mcp-servers concurrently at startup, the first server to boot used to grab port 8765 and every subsequent server crashed with OSError: address already in use — surfaced to the user as “Server disconnected” with no actionable detail. The default now binds to port 0 (kernel-assigned); the actual bound port is captured back into the config so url_for() produces correct URLs (via runner.addresses, aiohttp’s public API for this). Operators who need a stable port for external integrations can still set port: 9000 explicitly.

  • Workspace manifest auto-discovery walks one level up when the parent manifest opts in via workspace.applies_to. Operators with the natural layout

    open_source/
    ├── workspace_mcp.yaml      # declares `workspace.applies_to: ./*`
    └── repos/                  # --workspace points here
    

    no longer have to pass --mcp-config explicitly. The opt-in declaration accepts a literal name (./repos), a glob pattern (./prod-*), or a list of patterns ([./repos, ./clones]). Patterns match the workspace dir’s basename; the parent walk is bounded to one level. Without applies_to declared, the parent-walk is refused — a deliberate safety property to prevent silent-wrong-manifest if --workspace points at any unrelated sibling under a workspace-manifest parent.

    Implementation lives upstream in mcp-methods 0.3.33’s server::manifest::find_workspace_manifest. kglite’s Python wrapper is now a thin pass-through; the unconditional parent-walk fallback we briefly considered for the wrapper was abandoned after the maintainer flagged its silent-wrong- manifest failure mode.

Changed

  • mcp-methods pin bumped to 0.3.33 (was 0.3.31). Picks up the workspace.applies_to opt-in (above) plus the cumulative framework changes from 0.3.32 (initial applies_to design as a single literal, superseded by 0.3.33’s glob + list shape).

  • WorkspaceCfg dataclass gains applies_to: str | list[str] | None (kglite/mcp_server/manifest.py) — parsed from the framework’s polymorphic JSON shape and passed through to consumers verbatim.

Added (MCP server)

  • C9 / C10 / E6 / E7 regression tests. C9: csv_http_server: true produces a URL with a non-zero port. C10: two concurrent servers booted with the default both come up alive without port collision. E6: parent-walk discovery with applies_to: ./* resolves the parent manifest. E7 (safety property): parent-walk is refused when the parent manifest has no applies_to AND when an applies_to literal doesn’t match the child’s basename.

  • Migration guide extended (docs/migrations/mcp-0.6-to-0.9.md): new “Translation cheat-sheet” section maps common pre-0.9.x patterns to the new mode flags (“loads a .kgl at boot → --graph”, “uses set_root_dir → workspace.kind: local”, etc.). New “.kgl format compatibility” section calls out that 0.6.x – 0.8.x graphs cannot be loaded by 0.9.x and must be rebuilt. Workspace manifest auto-detection section describes applies_to opt-in.

  • Example manifest updated (examples/open_source_workspace_mcp.yaml): declares workspace.applies_to: ./* to demonstrate the layout-B (manifest beside workspace dir) pattern. Header comment shows both layouts side-by-side.

[0.9.28] — 2026-05-14

Fixes three bugs the mcp-servers operator surfaced in their 0.6.18 → 0.9.27 deployment-verification audit: workspace mode wasn’t actually building code-tree graphs on activate, local_workspace mode booted with an empty graph, and kglite.code_tree attribute-chain access raised at runtime. Two of the four servers they were migrating couldn’t work end-to-end before this release; they can now.

Fixed

  • --workspace mode now actually builds graphs on activate. The workspace’s post_activate hook was registered as a Python wrapper in 0.9.24 but never wired into _build_server — so repo_management('org/repo') would clone the repo, no code-tree build would fire, and the next cypher_query returned No active graph. The hook is now wired and fires on both repo_management activate and set_root_dir. Triggers graph_state.build_code_tree(active_path) + source_roots[:] = [active_path] so source tools (read_source, grep, list_source) target the active clone.

  • workspace.kind: local mode builds the code-tree at boot. Previously local-workspace booted with an empty graph until the agent issued the first set_root_dir. Mirrors watch mode’s boot-time build_code_tree(mode_path) so the first cypher_query against a freshly-booted local-workspace server sees a populated graph.

  • kglite.code_tree attribute-chain access works again. kglite/mcp_server/tools.py::GraphState.build_code_tree was calling kglite.code_tree.build(...) as an attribute chain on the kglite package, but kglite/__init__.py doesn’t import the submodule eagerly — so the call raised AttributeError at runtime the first time the workspace post-activate hook tried to fire. Now uses from kglite import code_tree to force the submodule load, with a clean error if the bundled tree-sitter grammars aren’t available.

Added (MCP server)

  • Migration guide for operators upgrading from 0.6.x – 0.8.x (docs/migrations/mcp-0.6-to-0.9.md). Covers the shift from custom Python MCP scripts to the bundled manifest-driven kglite-mcp-server: operating modes, tool surface differences (read_source split, grep_sourcegrep, ripgrep is not a bundled name), embedder transition (sentence-transformers/torch/MPS → fastembed/ONNX), manifest cheat-sheets, and common gotchas. Linked from docs/index.md.

  • F6/F7/F8 regression tests. F6: local_workspace mode builds code-tree at boot. F7: set_root_dir(child) rebuilds the code-tree for the new root via the post-activate hook. F8: from kglite import code_tree loads successfully. 62/62 mcp tests green (was 59 in 0.9.27).

[0.9.27] — 2026-05-13

Picks up the tools[].bundled: override shape from mcp-methods 0.3.31 and the cross-binary repo_management gating fix that landed in the same framework release. Closes a customisation gap that had been forcing operators to stuff per-tool guidance into the global instructions: block.

Added

  • tools[].bundled: override shape — manifests can now customise the agent-facing surface of bundled tools without declaring them inline. Two override types:

    • description: "..." — replaces the bundled tool’s default agent-facing description (what shows in tools/list). Lets operators teach agents that repo_management is the FIRST STEP, or that cypher_query returns a specific dataset shape, without burying the guidance in the global instructions blob.

    • hidden: true — drops the tool from tools/list AND rejects direct call attempts with Error: tool 'X' is hidden by manifest configuration. Useful for narrowing the agent surface (e.g. hiding ping on a production server, or suppressing source tools when the auto-bound source_root is wider than the operator wants).

    Both validate against the kglite bundled-tool catalogue at boot: a typo in the bundled: name exits 3 with ERROR: unknown bundled tool name(s) ... Valid names: [...] listing the full catalogue.

    Example:

    tools:
      - bundled: repo_management
        description: |
          FIRST STEP for this server. Call repo_management('org/repo')
          to clone + build a repo before any other tool.
    
      - bundled: ping
        hidden: true                # narrow the agent surface
    
      - name: similar_sessions      # existing cypher-tool shape unchanged
        cypher: ...
    

    Cypher tools (tools[].cypher entries) carry their own description in the manifest entry and are NOT affected by bundled overrides — those apply to the fixed bundled catalogue only (cypher_query, graph_overview, ping, read_code_source, save_graph, read_source, grep, list_source, repo_management, set_root_dir, github_issues, github_api).

  • Four regression tests in tests/test_mcp_server_python_entry.py:

    • B10bundled: cypher_query with description: appears in tools/list with the override text.

    • B11bundled: ping with hidden: true drops ping from tools/list while leaving other tools intact.

    • B12 — calling a hidden bundled tool by name returns the hidden by manifest configuration error rather than falling through to “unknown tool.”

    • B13 — an unknown bundled name (bundled: cipher_query) fails at boot with an error listing the valid catalogue.

    103/103 mcp + extensions_schemas tests green (was 99 in 0.9.26

    • 4 new).

Changed

  • mcp-methods pin: 0.3.30 → 0.3.31 (auto-resolved via Cargo.toml’s version = "0.3" constraint; Cargo.lock locks the exact 0.3.31). The framework half of the bundled-override work landed in 0.3.31 alongside our implementation; we did the Rust changes ourselves and the maintainer reviewed + released with no revisions. The same release also fixed the repo_management cross-binary gating drift we’d flagged from the operator’s post-0.9.25 verification — mcp-server (bare framework) and kglite-mcp-server now register the tool with the same gating rules.

Internal

  • BUNDLED_TOOL_NAMES frozenset at kglite/mcp_server/server.py defines the catalogue against which tools[].bundled: names are validated. Adding or removing a bundled tool requires updating this set; manifest overrides will surface “unknown bundled tool” errors otherwise.

[0.9.26] — 2026-05-13

Operator-driven release combining a CLI fix that unblocks the wikidata pure-YAML migration, the Cat G-N fixture acceptance that closes the 0.9.16 → 0.9.25 arc, a disk-storage write-path guard that turns silent data loss into a clean error, and a significant docs sweep across docs/guides/mcp-servers.md.

Fixed

  • kglite-mcp-server --graph now accepts disk-backed graph directories, not just single .kgl files. Pre-0.9.26 the validator (server.py::_validate_mode_paths) used Path.is_file() which silently rejected any directory — even though kglite.load(path) (the Python API) accepts both shapes fine. The error message even read “does not exist” when the path was demonstrably a valid graph directory, which was misleading. The new validator accepts a path if EITHER it’s a regular file (the .kgl case) OR a directory containing the disk_graph_meta.json sentinel (the disk-graph case, same marker the Rust loader at src/graph/io/file.rs::load_file uses). Reported by the mcp-servers operator after they shipped the wikidata preprocessor migration against 0.9.25; the bug blocked the last 84 lines of wikidata_mcp_server.py from being deleted (their disk-backed 124M-node Wikidata graph couldn’t boot via the CLI). Anyone deploying a storage="disk" graph (the documented kglite path for

    50M nodes) hit this immediately.

  • Cypher CREATE / MERGE on storage="disk" graphs now fails loudly (returns a clear Cypher error pointing at add_nodes() / to_disk() workarounds) instead of silently succeeding-with-no-data. Pre-0.9.26 the disk add_node path only stored a slot (type + row_id) and dropped the NodeData.properties / title / id fields, so CREATE (:Marker {title: 'x'}) against a disk graph completed without error but every property and the auto-title vanished — both in-memory (the column store was never told) and after save/reload. Discovered while writing the B6 regression test; not on any reported issue list but a silent-corruption class worth surfacing. Affects only CREATE and the create-path of MERGE; SET / DELETE on disk-backed graphs work correctly. The proper disk write-path implementation is on the roadmap.

  • Cypher REMOVE n.prop on storage="disk" graphs now works correctly. Discovered as a silent no-op during the disk-CREATE guard investigation: the disk staged-write flush (flush_node_mut_cache) only persisted property keys present in the staged Map, so a bare properties.remove(key) from NodeData::remove_property left the column store untouched and reads returned the original value. Fix: a new NodeData::clear_property helper inserts Value::Null for the key instead, which the flush writes through to the column store. execute_remove now routes to clear_property on disk-backed graphs via an is_disk() branch; memory and mapped backends keep the prior in-place remove_property behaviour (no change). Verified by B9 (regression test) + parity with the documented SET n.prop = null path.

  • DiskGraph::node_weight debug-assertion no longer fires on false-positive cases. The 0.9.0 Cluster 6 hygiene check at disk/graph.rs::node_weight previously fired whenever node_mut_cache had ANY entry for the index being read. That included PropertyStorage::Columnar { row_id, .. } scratch entries left by batch.rs::flush_chunk (the add_nodes path) — those are “already persisted via full-Arc replacement, safe to discard” and not a missed-flush concern. The check now filters to non-empty PropertyStorage::Map entries only (the actual Cypher-style staged writes that WOULD be shadowed by a column-store read). Removes the warning noise that appeared during normal maturin develop test runs. Debug-only assertion; never appeared in release builds.

Added

  • B6 / B7 / B8 / B9 regression tests in tests/test_mcp_server_python_entry.py:

    • B6 — --graph <disk-graph-dir> boots the server and serves a cypher_query against the persisted nodes (built via add_nodes(), the supported disk-mode write path).

    • B7 — --graph <arbitrary-directory-without-meta> is still rejected with the new error message, so the validator isn’t too permissive.

    • B8 — disk-mode Cypher CREATE / MERGE returns the new loud-failure error pointing at add_nodes / to_disk, not a silent no-op.

    • B9 — disk-mode Cypher SET and DELETE still work normally (the guard is narrow by design; existing mutation paths must not regress).

  • Cat J / K / L test fixtures and 8 forward-looking tests — the mcp-servers operator delivered the fixture bundle that was the last open thread from the 0.9.16 → 0.9.25 arc. Four tiny .kgl files (5.9 KB total) + paired manifests under tests/fixtures/{spatial_graph,timeseries_graph,graph_with_orphans,graph_with_duplicates}.kgl, with the fixture catalog at tests/fixtures/CAT_G_N_FIXTURES.md documenting what each one anchors. The wired tests:

    • J1-J3 (spatial Cypher) — contains(area, point(lat, lon)), centroid(polygon), query-side point(...) literal lookup.

    • K1-K3 (timeseries Cypher) — ts_sum(channel, 'YYYY'), ts_at(channel, 'YYYY-M'), and ts_sum across multiple matched nodes. Asserts against the random-seeded values in the fixture (TROLL oil 2019 sums to 1563.55; March 2019 spot is 177.12).

    • L1-L2 (procedures) — CALL orphan_node({type:'Wellbore'}) returns 3 isolated nodes; CALL duplicate_title({type:'Prospect'}) returns 4 duplicate-set members across two pairs. 97/97 mcp + schemas tests green (was 89 in this release before the fixture wire-up).

Changed

  • docs/guides/mcp-servers.md — significant sweep on top of yesterday’s six accuracy fixes (51436df). 0.9.26 adds:

    • Quick Start renumbered 1–4 (was 1, 2, 2½, 3); manifest teaser moved after Claude registration so the install-and- point happy path reads top-to-bottom.

    • Stale --embedder CLI-flag reference (line 58) removed — that flag doesn’t exist; the supported path is extensions.embedder in a manifest.

    • “Custom embedders” subsection under Built-in patterns rewritten as a 4-line pointer to the extensions.embedder reference + worked example (was using the removed-in-0.9.18 embedder: { module, class } shape with --trust-tools).

    • read_source / grep / list_source parameter docs reformatted from dense single-line prose to per-tool parameter tables.

    • New “Deployment shapes” section with a “Large graphs (disk-backed)” subsection covering the canonical Wikidata- scale flow (ntriples loader → storage="disk" → CLI pointed at the directory).

    • New “Known limitations” section documenting the disk- CREATE/MERGE refusal, the disk-REMOVE silent no-op, and the repo_management cross-binary gating drift between kglite-mcp-server and the bare mcp-server CLI.

    • New “Troubleshooting” section gathering common post-boot pitfalls (GITHUB_TOKEN discovery, text_score() returning zero, warm-call slowness, conda PATH shadowing, tools missing from tools/list, PyPI simple-index lag).

    • Pre-0.9.20 migration sections (90 LoC) moved to docs/migrations/mcp-pre-0.9.20.md, leaving a one-line pointer in the main guide.

  • mcp-methods dependency switched from git+rev to crates.io (commit f53e8f1, also between releases). mcp-methods 0.3.30 was the first crates.io publish; library binary surface is functionally identical to 0.3.29’s 71f7ba6. The switch is cosmetic Cargo.toml tidy — no behaviour change. Cargo.lock locks the exact version for reproducible builds.

  • mcp-methods dependency switched from git+rev to crates.io (commit f53e8f1, also between releases). mcp-methods 0.3.30 was the first crates.io publish; library binary surface is functionally identical to 0.3.29’s 71f7ba6. The switch is cosmetic Cargo.toml tidy — no behaviour change. Cargo.lock locks the exact version for reproducible builds.

[0.9.25] — 2026-05-12

Doc + feature release driven entirely by the mcp-servers operator’s end-of-arc audit (inbox/read/2026-05-12-from-mcp-servers-end-of-arc-audit.md). The operator flagged that 0.9.24 was “genuinely solid” but they’d hesitate to recommend kglite-mcp-server to a third party because the docs left them inbox-thread-dependent for edge cases. 0.9.25 addresses every gap in the audit (eight reference doc sections + four worked examples + machine-readable JSON schemas) and ships the one feature that retires their last custom Python MCP server (extensions.cypher_preprocessor).

Added

  • extensions.cypher_preprocessor — manifest-declarable Python hook that fires before every cypher_query and tools[].cypher invocation. The hook can rewrite the query string and/or params before they reach graph.cypher(...). Gated by trust.allow_query_preprocessor: true. The motivating use case is Wikidata Q-number rewriting ({nid: 'Q42'}{id: 42} against the integer-id graph), but the hook generalises to date normalisation, multi-tenant scoping, parameter validation, and any “rewrite agent input before query execution” shape that pure-declarative regex can’t express. Class-based loaders thread kwargs: through to __init__; free-function loaders work for state-free rewriters. Boot-time errors (trust gate, missing module, missing class/function) exit 3 with the operator-facing message; runtime exceptions surface as preprocessor: <message> in the tool body without leaking a traceback. ~50 LOC implementation in kglite/mcp_server/preprocessor.py; 9 regression tests (test_o1-test_o9 in tests/test_mcp_server_python_entry.py) cover the full contract from in-process unit dispatch to end-to-end YAML round-trip through MCP stdio.

  • Eight reference doc sections in docs/guides/mcp-servers.md — fills every gap the operator’s end-of-arc audit called out: mode × YAML-field acceptance matrix; tool gating rules; tool response formats (with stability tags so the 0.9.21 row-formatter regression class can’t recur silently); extensions: schema reference; tools[].cypher template reference ($param semantics, JSON Schema flavour, error envelope, FORMAT CSV inheritance); embedder backend × model catalog; path resolution + manifest discovery rules; operator notes (pip-index lag workaround, conda guidance, watch-mode rebuild costs).

  • Four worked manifest examples under docs/examples/manifest_cypher_tool.md, manifest_with_embedder.md, manifest_workspace.md, manifest_cypher_preprocessor.md. Wired into the docs guide via a toctree.

  • Machine-readable JSON Schema (Draft 2020-12) for each first-class extensions.* block under docs/schemas/extensions/. Linked from the reference docs. Anchored to the Python parsers by tests/test_extensions_schemas.py (44 tests) — schema/parser drift fails loudly in CI.

  • Manifest.trust dataclass fieldallow_python_tools, allow_embedder, allow_query_preprocessor populated from mcp_methods::server::Manifest::to_json() output. Available to the rest of kglite/mcp_server/ (and to tests).

Changed

  • mcp-methods pin: 0.3.28 → 0.3.29 (rev 1ba946971f7ba6). Adds allow_query_preprocessor to ALLOWED_TRUST_KEYS and TrustConfig, plus emits it under the trust object in Manifest::to_json(). Non-breaking JSON shape addition.

  • kglite.mcp_server.tools.run_cypher signature — adds an optional preprocessor: Preprocessor | None = None parameter. Existing callers (every prior release plus all current internal call sites) work unchanged via the default. Same for kglite.mcp_server.cypher_tools.call_cypher_tool.

Internal

  • Pre-release suite expanded to 39 default-mode tests (was 34): added the 9 new cypher_preprocessor tests (O1-O9) plus 44 schema drift tests in tests/test_extensions_schemas.py.

  • W1 watch-callback assertion loosened to accept either the changed file path or the parent directory — macOS FSEvents coalesces depending on rate, and the contract we care about is “the callback receives a list[str] within the debounce window,” not the path-granularity decision the OS makes.

[0.9.24] — 2026-05-12

Architectural cleanup: kglite’s MCP server framework is now a thin shim over mcp-methods Rust rather than a parallel Python re-implementation. ~600 LOC of Python deleted; the validated Rust behaviour replaces it. As a side effect, the 0.9.23 set_root_dir sandbox-narrowing regression the operator flagged is fixed by construction.

Fixed

  • set_root_dir no longer narrows the sandbox with each swap. The pre-0.9.24 Python Workspace.set_root_dir_tool mutated self.root after each successful swap, so the next sandbox check compared against the narrower active root rather than the manifest’s declared workspace.root. After one swap, lateral swaps to sibling projects under the configured root failed with “escapes the workspace root.” Fix: the new wrapper inherits mcp_methods::server::Workspace’s atomic-swap RwLock + immutable configured workspace_dir, so the sandbox check always validates against the manifest’s declared root. Workspaces remain swappable to any sibling under the configured root for the lifetime of the server — no restart required.

Changed

  • kglite/mcp_server/{manifest,workspace,watch}.py are now thin pyo3 wrappers around mcp_methods::server::{Manifest,Workspace, watch_dir}. The Python surface stays the same (Manifest dataclass, Workspace.root / .kind / .repo_management_tool / .set_root_dir_tool, watch.start), but the implementation is ~600 LOC of Rust behind a single passthrough each. The Python manifest dataclass is populated from Manifest::to_json() (new in mcp-methods 0.3.27) so field drift between the framework and downstream consumers is a non-issue.

  • .env walk-up delegated to mcp-methods Rust (load_env_walk). Same parse rules (skip blanks / # comments, strip outer quotes, no-overwrite-existing-env), same result — one fewer parallel implementation to keep in sync. Explicit env_file: paths still loaded inline.

  • File watcher uses notify-debouncer-mini via Rust instead of the pure-Python watchdog + threading debounce. The watchdog extra remains in [mcp] optional-deps for now — downstream tooling may depend on it — but kglite’s own watch path no longer uses it.

  • Pin: mcp-methods rev 1ba9469 (0.3.28). Three same-day releases against this cleanup: 0.3.26 (three-crate split), 0.3.27 (Manifest::to_json), 0.3.28 (local-mode set_root_dir no longer clobbers active_repo_path). Two of those bumped specifically to unblock the 0.9.24 pyo3 wrapper; the third was a bug found during the wrap pass.

Added

  • kglite._mcp_internal.{Manifest, Workspace, WatchHandle, start_watch, load_env_walk} — pyo3 wrappers around mcp_methods::server::*. Internal surface (the public Python entry point is still kglite.mcp_server.server:main), but importable for tests and for downstream tools that want the validated mcp-methods behaviour without a Python re-implementation.

  • 4 new regression tests anchoring the pyo3-wrapper boundary: F4 (sandbox lateral swap — operator’s bug repro), E5 (manifest extensions passthrough — recursive serde_json::Value → dict), F5 (workspace post-activate hook GIL dispatch), W1 (watch callback receives changed paths). 34/34 mcp_server tests green (was 30/30).

Internal

  • anyhow added as a direct dep — required by mcp_methods::server::PostActivateHook’s Result<(), anyhow::Error> signature.

  • MEMORY.md of “thin Python shim” intent updated for future sessions (see CLAUDE.md’s new “Standard plan procedure” section for the per-phase commit rhythm we followed for 0.9.24).

[0.9.23] — 2026-05-12

Fixed

  • extensions.csv_http_server returned HTTP 500 on every GET. aiohttp’s web.Response rejects content_type strings that contain a charset directive — we were passing "text/csv; charset=utf-8". Fix: pass content_type="text/csv" and charset="utf-8" as separate kwargs. Operator workaround was to disable the csv_http_server block; 0.9.23 makes it usable again.

  • github_issues now auto-defaults to the workspace’s active repo when repo_name isn’t supplied. Previously repo_management(name) activated the repo correctly but github_issues without repo_name hit the “could not auto-detect from git remote” error path. Workspaces now track active_repo and the github_issues dispatcher uses it as the fallback. Agents no longer need to repeat repo_name='org/repo' on every call.

  • set_root_dir now actually rebinds the source tools. Previously the tool registered, the workspace’s root field updated, but the source_roots list captured at server build time wasn’t refreshed — so list_source / grep / read_source kept hitting the old root. Tests/test_f2 was designed to catch exactly this and did.

  • BgeM3Embedder.unload() is now a no-op (formerly dropped the ONNX session). kglite’s kg_core.rs::cypher does load embed unload around every text_score call, so dropping the session meant every cypher paid the full ~1s ORT session init. Warm-call latency drops from ~1.1s to ~50ms; ~2 GB RAM stays resident while the embedder is in use.

Added

  • 27-test pre-release suite per the operator’s spec. Every test maps to a specific bug from the 0.9.16 → 0.9.22 release arc. Categories: A (install/boot), B (per-mode tool registration), C (tool output content), D (embedder + semantic search), E (manifest/.env), F (workspace state propagation). The tool-output content assertions (Cat C) are the gate that would have caught 0.9.21’s row formatter and 0.9.22’s csv_http 500 before release.

  • bge-m3 cool-down timer: configurable via extensions.embedder.cooldown (default 900s = 15 min; 0 = never release). Active sessions hold the ONNX session resident for fast queries; long-idle servers release ~2 GB of RAM. Cool-down check fires on each embed() call — no background threads.

  • BgeM3Embedder.release() — explicit counterpart to the no-op unload(). Drops the ORT session + tokenizer when the caller really wants the memory back.

  • tests/fixtures/build_tiny_graph.py — programmatic 50-node-per- type fixture (Person + Company + Article) with semantically clustered article bodies (quantum / baking / programming) for embedder relevance tests.

Changed

  • extensions.embedder YAML now accepts cooldown: (seconds). Falls through to the BgeM3Embedder for BAAI/bge-m3; FastEmbed adapter ignores the field for other models (their lifecycle follows fastembed-python’s defaults).

[0.9.22] — 2026-05-12

Fixed

  • cypher_query row formatter now returns row values, not column names. 0.9.21 regression: _format_inline in kglite/mcp_server/tools.py iterated for v in row against a dict, yielding the column names as values. Every non-CSV cypher_query call produced rows like 'f.name'\t'f.line_number' instead of the actual data. Operator caught it on redeploy and rolled back to 0.9.18. Fix: index the row dict by column (row[col]) so the preview shows real values. The Rust-side Cypher engine + the FORMAT CSV path were always correct — only the inline preview formatter was wrong.

Added

  • test_cypher_query_returns_actual_row_data integration test — asserts the inline preview contains the computed value (e.g. 2) and not the column name ('sum'). The 0.9.21 regression class (“tool registers but returns garbage”) can no longer reach release without breaking the build. Same shape as the existing per-tool content assertions (ping returns pong, read_source returns the file slice, grep returns matches).

[0.9.21] — 2026-05-12

Fixes the two 0.9.20 regressions the operator caught on redeploy: 8 of 11 tools were silently missing, and bge-m3 embedder was broken because fastembed-python doesn’t carry that model in its catalog.

Added

  • All 8 framework tools restored: ping, read_source, grep, list_source, repo_management, set_root_dir, github_issues, github_api. Same output format as the 0.9.18 binary. Implementation comes from the pure-Rust mcp-methods crate (0.3.26+, three-crate split with zero pyo3 in the library half) wrapped via pyo3 in src/mcp_tools.rs and exposed as kglite._mcp_internal. The Python kglite.mcp_server.server entry point dispatches each tool to the wrapped Rust function — cypher()-style GIL release preserves the original Rust performance.

  • BgeM3Embedder (kglite/mcp_server/bge_m3.py): direct onnxruntime + huggingface_hub implementation for BAAI/bge-m3 because fastembed-python’s catalog doesn’t include it. Same ONNX weights as fastembed-rs, same CLS pooling, same ~/.cache/fastembed/ cache directory — operator’s existing downloaded weights reused without re-download. Other models (bge-small/base/large, all-MiniLM-L6-v2, multilingual-e5) continue through fastembed-python.

  • CI gate against tool-surface regression: tests/test_mcp_server_python_entry.py boots the server in every supported mode and asserts tools/list matches tests/fixtures/tool_baseline.json exactly. Any added or removed tool fails the build. Adopted in response to the 0.9.20 failure mode where the regression was caught after release.

Changed

  • kglite::api Rust facade adds mcp-methods 0.3.26 as a curated dep (default-features = false, features = ["server"], no pyo3 in its tree). Downstream Rust consumers can use mcp_methods::* directly without going through us.

  • extensions.embedder dispatcher routes BAAI/bge-m3 to the new BgeM3Embedder; all other models continue to fastembed-python.

  • Auto-bind manifest’s directory as fallback source root when no source_root: is declared. Matches the 0.9.18 binary’s behaviour — without this fallback, sodir-style manifests (no source_root) silently lost read_source/grep/list_source.

Fixed

  • 0.9.20’s tool-surface regression (8 missing tools per manifest).

  • 0.9.20’s bge-m3 catalog regression (text_score() broken on every embedder-enabled deployment).

[0.9.20] — 2026-05-11

Changed

  • kglite-mcp-server is now a Python console-script entry point, not a bundled Rust binary. The 0.9.18/0.9.19 binary bundling forced a 12-wheel (3 OS × 4 Python) build matrix because any Rust binary that transitively depends on pyo3 links libpython at a specific version — no abi3 escape for binaries. The Python entry point bypasses that entirely. Wheel matrix back to 3 abi3 wheels per release (same as pre-0.9.18). Performance unchanged: kglite’s Python cypher() already releases the GIL inside py.detach(), so the wrapping layer is sub-microsecond.

  • The 0.9.18 conda install_name regression and the 0.9.19 install_name_tool / patchelf / mold post-build surgery are gone — there’s no binary to mis-link.

  • Wheel deps: install via pip install 'kglite[mcp]' to pull the server-time deps (mcp, pyyaml, fastembed, aiohttp, watchdog). Plain pip install kglite skips them — for users who just want the graph engine.

Removed

  • kglite/_bin/ directory inside the wheel (was per-Python binary drop site).

  • kglite/_cli.py (was the launcher that exec’d the bundled binary).

  • Per-Python-version wheel build matrix axis.

  • install_name_tool / patchelf / mold steps from the CI workflow.

  • [project.optional-dependencies] embeddings (torch + sentence-transformers) — superseded by [mcp] which uses fastembed natively.

Internal

  • crates/kglite-mcp-server/ stays in the repo for direct Rust consumers (Wikidata-scale deployments, Docker-vendored binaries) but is no longer bundled into the wheel. Build via cargo build -p kglite-mcp-server if you want it.

[0.9.19] — 2026-05-11

Changed

  • Wheel build is substantially faster. Three workflow changes:

    • Drop fastembed’s image-models default feature (jpeg/png/webp decoders we don’t use) — ~3-4 min/wheel saved.

    • Add Swatinem/rust-cache to the wheel-build workflow with a per-target shared key so most deps (mcp-methods, hyper, fastembed, tokio) are reused across the four Python-version cells within each OS. Warm builds reuse the cache.

    • Use the mold linker on Linux. The bundled ld spent ~1-2 min linking the cdylib + binary at end-of-build; mold does the same work in ~10s. macOS and Windows keep their platform linkers.

Fixed

  • pip install kglite now works on conda Python. The 0.9.18 wheel shipped the bundled kglite-mcp-server binary with an absolute install_name pointing at /Library/Frameworks/Python.framework/... (the actions/setup-python build path); conda installations don’t have that path and the binary failed to launch with a dyld error. The wheel-build workflow now rewrites the install_name to @rpath/libpython3.X.dylib and adds an rpath relative to the binary’s wheel install location so dyld finds the env-local libpython under conda, venv, virtualenv, and Python.org installs uniformly. Linux gets the same treatment via patchelf --set-rpath '$ORIGIN/../../../..'.

  • builtins.temp_cleanup: on_overview now actually wipes the configured directory. The 0.9.18 implementation hardcoded the cleanup target to ./temp (cwd-relative) which only worked when the server was launched from the manifest’s parent directory. 0.9.19 resolves the temp directory against the manifest base — and reuses extensions.csv_http_server.dir when configured, so the same place CSVs are written is also the place that gets swept.

  • FORMAT CSV row-count status no longer reports 0 row(s) written for queries with LIMIT N. The status counter read result.rows.len(), which is empty when the planner’s lazy materialisation kicks in — even though the CSV body has the right data. The count now comes from the CSV body itself.

[0.9.18] — 2026-05-11

Changed

  • MCP server is now pure-Rust at the source level. kglite-mcp-server no longer calls PyO3 anywhere — every tool handler goes through the new kglite::api façade (Cypher pipeline, compute_description, build_code_tree, source_location). The mcp-methods Python feature is off, so the framework’s Python tool surface isn’t on the binary’s dep graph either. The bundled binary still links to libpython transitively through kglite’s own PyO3 layer (kglite is a Python library — that’s by design), so the wheel matrix stays at 3 OS × 4 Python = 12 wheels per release.

  • Embedder backend switched to fastembed-rs. The framework-level embedder: Python factory is gone. Configure with extensions.embedder: { backend: fastembed, model: BAAI/bge-m3 } instead — bge-m3, bge-small/base/large-en-v1.5, all-MiniLM-L6-v2, and multilingual-e5 supported out of the box. ONNX weights are downloaded to ~/.cache/fastembed/ on first use; no torch / sentence-transformers install needed.

Added

  • kglite::api — curated Rust façade for downstream binaries. Exposes KnowledgeGraph + DirGraph + Embedder + FastEmbedAdapter + the Cypher parse/plan/execute surface + compute_description + compute_schema + load_file + build_code_tree + SourceLocation/SourceLookup.

  • extensions.csv_http_server — opt-in localhost HTTP listener that serves FORMAT CSV exports as URLs instead of inline strings. Useful for million-row exports that would blow the MCP response budget. Bound to 127.0.0.1, path-traversal hardened, no write surface, CORS-enabled.

  • KnowledgeGraph::set_embedder_native(Arc<dyn Embedder>) — pure-Rust counterpart to the set_embedder pymethod; lets downstream Rust binaries bind embedders without a Py<PyAny>.

  • KnowledgeGraph::source_location(name, node_type) — pure-Rust counterpart to graph.source() used by the read_code_source tool.

Removed

  • tools[].python manifest entries — Python tool hooks no longer loadable. Move tool logic into a tools[].cypher template or a downstream Rust binary that embeds the kglite crate directly.

  • embedder: top-level manifest key — replaced by extensions.embedder: (see Changed).

  • Pre-0.9.18 install-UX workarounds (PYO3_PYTHON=, install_name_tool -add_rpath, conda-env symlinks) are no longer needed; pip install kglite ships kglite-mcp-server on PATH directly.

[0.9.17] — 2026-05-11

Added

  • read_code_source(qualified_name=...) MCP tool — kglite-side companion to the framework’s read_source(file_path=...). Resolves a fully-qualified entity name through the active graph’s graph.source() (which uses the code-tree node attributes), then reads the corresponding file slice from the configured source root(s). Equivalent to cypher → graph.source → read_source in a single MCP call. Same start_line / end_line / grep / max_chars filters as read_source. Restores the qualified-name flow operators relied on pre-0.9.14; reported by the MCP-servers operator after the 0.9.14 framework take-over trimmed read_source to file_path-only.

  • Boot-summary line on stderr now names the .env file actually loaded (or reports (no .env found) when walk-up came up empty), closing the gap between “token missing” and “token present but unreadable”.

Fixed

  • kglite-mcp-server now actually loads .env files. The shim’s main.rs never invoked mcp-methods’ load_env_for_mode() — so the framework’s walk-up + env_file: YAML key support, although present in mcp-methods 0.3.22+, never fired under the kglite binary. Operators ran into “GITHUB_TOKEN not set” with a .env one directory up from their workspace, which should have been auto-discovered. Now wired: walk-up from --graph parent / --source-root / --workspace / --watch / workspace.kind: local root / cwd-in-bare, with explicit env_file: in the manifest as override.

  • embedding_diagnostics() now sees columnar properties. The 0.9.16 implementation iterated NodeData::property_iter(), which yields nothing for PropertyStorage::Columnar — the variant nodes use after save+reload. As a result, diagnostics on a freshly-loaded graph reported nodes_with_property: 0 for properties that actually existed (Cypher WHERE x IS NOT NULL confirmed), flipping the status to store_orphan on a healthy steady-state graph. Same root cause hid the embeddable status when a node_type filter was passed for a type with a string property but no store yet. Fixed by switching to properties_cloned(), which dispatches across all PropertyStorage variants. Two new regression tests cover the save+reload and filter-by-type paths.

Documentation

  • Python linkage policy for kglite-mcp-server — PyO3 picks one interpreter at build time. New section in docs/guides/mcp-servers.md (“Where does the binary find Python? — read this before pip install”) covers the discovery one-liners (otool -L / ldd) and the PYO3_PYTHON=... install-time override. README has a short callout pointing to the long version. Reported after an operator landed 2 GB of torch in base conda because the binary linked to base Python rather than the sub-env where they pip’d kglite.

[0.9.16] — 2026-05-10

Added

  • YAML tools[].cypher entries are now wired into MCP. The kglite-mcp-server shim adds a cypher_tools module that registers each manifest-declared parameterised Cypher tool as a first-class MCP tool, dispatching to graph.cypher(template, params=args) on the active graph. Closes the gap that left all five of the MCP-servers project’s production manifests’ tools: sections invisible to agents after the Python kglite.mcp_server was retired. Schema is taken from the YAML parameters: block when present, otherwise an empty object schema.

  • manifest.workspace.kind: local is now honored. The shim promotes a manifest-declared local workspace into a new internal Mode::LocalWorkspace before mode-specific binding, with set_root_dir registered for runtime root swap and an optional debounced watch loop on watch: true. Manifest declaration wins over the --workspace CLI flag, mirroring the framework’s own binary. Lets users retire code_review_mcp_server.py-style custom Python servers in favour of a YAML manifest.

  • graph.embedding_diagnostics(node_type=None) — companion to list_embeddings() that surfaces per-(node_type, text_column) coverage with three states: "embedded" (store and property both present), "embeddable" (property present, no store), and "store_orphan" (store present, no node has the property — the symptom an import_embeddings() warning indicates). Use it after a silent-drop warning to see which stores are affected.

  • Type stubs for import_embeddings() and export_embeddings() — both methods existed but were missing from kglite/__init__.pyi.

  • Documentation of the code_tree qualified-name format per language with a stability commitment within minor releases — docs/guides/code-tree.md.

  • Recipe: SETadd_properties migration for hub aggregations, with a worked example showing Agg.count() / Spatial.distance() helpers replacing imperative-Cypher WITH ... SET ... chains — docs/guides/recipes.md.

  • End-to-end smoke suite for kglite-mcp-server over JSON-RPC stdio (tests/test_mcp_server_smoke.py) — 25 tests covering every tool the binary exposes (cypher_query, graph_overview, save_graph, read_source, grep, list_source, github_issues, github_api, set_root_dir, ping, plus YAML-declared parameterised Cypher tools). Auto-skips when the binary isn’t built; runs in ~3 s.

Changed

  • kglite-mcp-server now pins mcp-methods 0.3.23 (rev e45a282, bumped from 0.3.21). Brings, in order: .env auto-loading, GitHub-tool drill-down via element_id, honest tool listing gated on GITHUB_TOKEN, inventory.json last_built_sha + auto-rebuild gating on repo_management(update=True), framework parsing of workspace.kind: local, the mcp_methods.fastmcp Python helper submodule (register_overview / register_cypher_query / register_source_tools / register_save_graph / serve_csv_via_http), a public build_tool_attr for downstream cypher-tool registration, and an empty-string filter in auth_token. Existing YAML manifests parse unchanged — every schema addition is optional.

  • The framework’s embedder factory now hands back an Arc<EmbedderHandle> (load/unload/embed/touch + idle tracking) instead of a raw Py<PyAny>. The shim extracts the underlying Python instance via handle.instance() and binds that to the active graph; kglite’s per-batch set_embedder lifecycle drives the same instance the framework’s idle-watch task observes.

  • README.md migration note: replaces the old pip install "kglite[mcp]" flow with cargo install --path crates/kglite-mcp-server.

Fixed

  • import_embeddings() no longer silently drops mismatched files. When imported == 0 but the .kgle file contained data, or when a per-type store had zero matches, the call now emits a UserWarning describing the mismatch (file path, counts, likely cause). The result dict gains a dropped_stores key so callers can detect partial-drop cases programmatically. Reported via the MCP-servers wishlist after a 7 MB embedding file silently became {stores: 0, imported: 0, skipped: 1923} against a graph whose code_tree qualified-name format had drifted.

  • save_disk no longer fails with OSError: Invalid argument (os error 22) on disk-backed graphs. The 0.9.15 unified mega-file writer had an early-return gate that required both total_bytes == 0 and unhandled.is_empty() — but unhandled types only need sidecar fallback, never bytes in the mega-file. With non-zero unhandled and zero planned bytes, the code fell through to mmap::map_mut on a 0-byte file, which returns EINVAL on every Unix. Triggered on every fresh disk graph (KnowledgeGraph(storage="disk", ...).add_nodes(...).save(...)) — the entire tests/test_disk_property_index.py suite was failing.

[0.9.15] — 2026-05-10

Added

  • KnowledgeGraph._save_subset_induced_by_edge_type(path, edge_types) — variant of _save_subset_filtered_by_edge_type that produces the induced subgraph: the kept-node set is still derived from edges matching edge_types (Pass A), but the output keeps every edge between any two kept nodes, not just the filter edge type. On the Wikidata articles_authors carve this expands the result from a single P50 layer to ~174 M edges across 20 distinct types (P2860 citations, P2093 stated authors, P98 editor, etc.) while still pinning the node set to “articles + their authors”. Disk source only.

Fixed

  • Streaming subgraph carve now round-trips non-schema properties. The disk-to-disk save_subset_streaming_disk writer dropped any property whose key wasn’t in the type’s schema — Wikidata stores most low-cardinality properties (P356 DOI, P577 publication date, P304 page numbers, …) in a per-row overflow bag, and those were silently lost on save. TypeWriter now accumulates an overflow blob in the same wire format the source uses ([u16 num_entries] + [u64 key | u8 type_tag | value]), and RowVisitor routes non-schema keys into it instead of dropping them. ColumnStore::replace_overflow_bag is the new setter.

  • Saved DiskGraphs now load with mmap-fast-path speed. A graph built in memory and persisted via save_disk previously emitted per-type zstd sidecars under columns/<type>/columns.zst, which the loader rebuilt eagerly on every open — ~70 s on a 17 M-node carve vs. ~150 ms for the same data when produced by the ntriples builder. save_disk now emits the unified seg_000/columns.bin mega-file format the loader’s mmap fast path consumes (new crate::graph::io::unified_columns module), so saved subgraphs load in tens of milliseconds. Existing sidecar-format graphs still load via the legacy path.

Performance

  • Legacy sidecar column loader (still used for pre-mega-file files) parallelised via rayon — read + zstd decode + load_packed now run per-type concurrently. ~2.3× faster on a 16-core machine for the rare case of opening a sidecar-format graph.

[0.9.14] — 2026-05-09

Added

  • kglite-mcp-server is now a Rust-native single binary, built on top of the mcp-server framework (rmcp + manifest-driven tool registration) shipped from the sibling mcp-methods workspace. The binary lives at crates/kglite-mcp-server/. Full mode coverage matches the previous Python server: --graph X.kgl, --workspace DIR, --watch DIR, --source-root DIR, plus bare framework. The manifest YAML schema is unchanged, so any <basename>_mcp.yaml written for the Python server boots unchanged on the new binary.

  • Workspace mode auto-builds a code-tree graph for each cloned repo via a PostActivateHook calling kglite.code_tree.build(); watch mode re-runs the same path on debounced file changes; embedder factories declared in the manifest are bound to the active graph via graph.set_embedder().

Removed

  • kglite/mcp_server/ Python package + the kglite[mcp] extras dependency group + the kglite-mcp-server console script entry + examples/mcp_server.py + 11 tests/test_mcp_*.py modules (~4,150 LoC). All replaced by the Rust binary above. The manifest schema and tool surface are 1:1 compatible — agents see the same cypher_query / graph_overview / save_graph / read_source / grep / list_source / github_issues / github_api / repo_management / ping tools as before.

  • mcp optional-dependency group removed from pyproject.toml. To install the new server: cargo install --path crates/kglite-mcp-server from a kglite source clone.

Changed

  • kglite is now a Cargo workspace (root crate + crates/kglite-mcp-server). The Python wheel build via maturin is unchanged; a new python-extension Cargo feature gates pyo3/extension-module so cargo build can share the rlib with the new sibling binary.

  • CLAUDE.md “When changing a #[pymethods] function” checklist step 4 now points at crates/kglite-mcp-server/src/tools.rs (instead of the deleted examples/mcp_server.py).

[0.9.13] — 2026-05-09

Added

  • kglite-mcp-server --workspace DIR: multi-graph workspace mode. Boots the server without a graph; the agent activates one with repo_management('org/repo'), which clones the GitHub repo, builds a code-graph via kglite.code_tree.build, and pins it as the active graph for cypher_query / graph_overview / read_source / grep / list_source. Inventory tracks last_accessed / access_count per repo in <workspace>/inventory.json. Idle repos auto-sweep after --stale-after-days (default 7); the active repo is exempt and stale entries preserve their access history. Layout: <workspace>/{repos,graphs,temp,inventory.json,workspace_mcp.yaml}.

  • Manifest embedder: section for project-supplied embedder factories. Declare module: ./embedder.py + class: GraphEmbedder

    • kwargs: {...} and the CLI imports + instantiates via Class(**kwargs) and binds with graph.set_embedder(). Trust-gated by trust.allow_embedder: true plus --trust-tools (both signals required, mirrors the python: tool gate). Replaces the always-loaded --embedder MODEL_NAME shortcut for users who need cooldown-based unload (e.g. BAAI/bge-m3 on consumer hardware).

  • Manifest overview_prefix: field. Sticky preamble prepended to graph.describe() output on bare graph_overview() calls. Skipped for focused drill-downs (types=[...], connections=[...], cypher=[...]) so they stay terse. Lets agents re-discover load-bearing context (validator hints, baseline counts, hidden invariants) deep into a session without competing with the conversation for slot in the system instructions.

  • Manifest builtins: section for pre-blessed tools that don’t need --trust-tools. save_graph: true registers a save_graph() MCP tool that calls graph.save(graph_path) — for persisting CREATE/SET/DELETE Cypher mutations. temp_cleanup: on_overview clears the CSV-export temp/ directory on bare graph_overview() calls so it doesn’t grow unbounded across long sessions; never (default) keeps the existing behaviour.

  • read_source(qualified_name=...) for code-aware servers. When the bound graph carries qualified_name + file_path properties on code nodes, the agent can pass a name like MyClass.my_method and the tool resolves through graph.source() to a file slice in one round-trip (was: cypher-then-read, two round-trips). Suffix fallback handles short bare names (e.g. helper_fn) via Cypher ENDS WITH against qualified_name. Available in both single- graph and workspace modes.

Changed

  • kglite-mcp-server --graph and --workspace are now mutually exclusive flags. Default behaviour (no flag, no graph.kgl in cwd) is unchanged — single-graph mode looking for ./graph.kgl.

  • examples/conference_graph_mcp.yaml annotated with the new manifest fields (overview_prefix, builtins, embedder, trust.allow_embedder).

[0.9.12] — 2026-05-09

Added

  • KnowledgeGraph.save_subset(path) on the fluent selection chain. Equivalent to kg.to_subgraph().save(path) in a single call — produces an independent v3 binary file that reloads via kglite.load(path) (or load(path, storage='disk') for disk mode). All edges between selected nodes are included; node and edge properties round-trip byte-for-byte. Works on any source storage mode.

  • _save_subset_filtered_by_edge_type(path, [edge_types]) — disk-to-disk streaming subgraph filter (Wikidata-scale path). Single-pass over the source’s edge_endpoints.bin builds a kept- nodes bitset; a per-type TypeWriter then streams kept rows directly to dest column files via BufWriters — no intermediate in-memory ColumnStore, no chunk-and-merge step. End-to-end on the full Wikidata graph (124M nodes / 861M edges, P50 + endpoints) produces a 17,364,495-node / 35,448,243-edge subgraph in 349s wall time (down from 550s on the v1 in-memory path). Working set stays in the hundreds of MB regardless of subset size — peak RSS is largely soft mmap pages.

  • BorrowedValue<'a> zero-copy view of Value in datatypes::values. String(&'a str) borrows from the source buffer (typically an mmap region) instead of cloning into a String. Used by the streaming subgraph filter; available as a general read-path primitive. Convert with to_value().

  • MmapColumnStore::id_borrowed / title_borrowed / try_for_each_property_borrowed — allocation-free reads. The property visitor decodes overflow-bag bincode entries in place and yields BorrowedValue::String views into the mmap; previously every overflow row allocated a Vec<(InternedKey, Value)> plus a String per entry. ColumnStore wrapper delegates to mmap_store for disk graphs.

Changed

  • MmapColumnStore::read_str skips UTF-8 validation. Source bytes were always written through String::as_bytes() (Rust’s UTF-8 invariant), so the from_utf8 validator was walking ~25 GB of source data per Wikidata save for nothing. from_utf8_unchecked is now used. Saves ~70 s of wall time on the streaming subgraph filter; no observable behavior change.

  • Streaming subgraph filter uses borrowed-value writes end-to-end. TypeWriter::push_row_borrowed accepts BorrowedValue<'_> and writes &[u8] straight to per-column BufWriters without ever materializing Value::String. Combined with the read-side borrowing above, the Wikidata save phase drops from 550s to 349s (-37%). Node-walk sub-phase: 446s → 241s (-46%); the props portion 330s → 145s (-56%); id+title 87s → 17s (-80%). Output is byte-identical to the prior path — tests/test_subgraph_streaming.py’s round-trip equality suite (14 cases) stays green.

  • Sub-phase timers in save_subset_streaming_disk gated on KGLITE_STREAMING_TIMING=1. Off by default (zero overhead); when enabled, prints per-phase wall times plus per-million-row progress so a future optimizer can iterate in 30-second chunks rather than 10-minute round-trips. Useful for finding bottlenecks without committing to a full bench cycle.

Changed

  • Edge-driven group-by aggregations with a typed target node now use the fast lookup_peer_counts path. Queries shaped MATCH (a)-[:E]->(b:T) RETURN b, count(a) [ORDER BY count(a) DESC LIMIT k] were correctly routed to FusedMatchReturnAggregate but BOTH executor branches (top-K and non-top-K) bailed when the planner reversed the pattern to start at the typed node — the resulting group_elem_idx == 0 short-circuit forced the slow node-centric scan. The fast path now detects “group is semantic target” via (group_elem_idx, edge_direction) and applies a binary-search type filter against type_indices[T] (sorted by construction). On Wikidata:

    • museums-by-works (with ORDER BY): 15 s → 108 ms (140×)

    • most-eponymed-globally: 122 s timeout → 169 ms (~720×)

    • top-influencers: 122 s timeout → 26 ms (~4700×)

    • typed-target without ORDER BY (LIMIT only): 13.3 s → 110 ms (~120×)

    • untyped-target without ORDER BY (LIMIT only): 14.3 s → 535 ms (~27×) No on-disk format change; all-Wikidata graph rebuild not required. Differential-test queries edge_groupby_typed_target_top_k and edge_groupby_typed_target_no_orderby added to the corpus to gate both branches.

  • MATCH...WITH count(...) aggregations now use the fast path post-pattern-reversal too. The third instance of the same position-only group_elem_idx == 2 bug was in try_fast_with_aggregate_via_histogram (the executor’s fast path for FusedMatchWithAggregate). After optimize_pattern_start_node reverses (a)-[:E]->(b:T) WITH b, count(a) to start at the typed node, group_elem_idx becomes 0 and the histogram path silently bailed despite lookup_peer_counts serving both shapes. Same direction-aware predicate fix as the RETURN-aggregate paths, plus count(<edge-var>) now fuses through the WITH-aggregate gate too. c7’s MATCH (n)<-[r]-() WITH n, count(r) now reaches FusedMatchWithAggregate (still bounded by the absence of a global-in-degree histogram for untyped edges; that’s a follow-up workstream). Differential test edge_groupby_match_with_aggregate_typed_target added.

  • count(<edge-variable>) now fuses into FusedMatchReturnAggregate. MATCH (paper)<-[r:CITES]-(citing) RETURN paper.title, count(r) is the natural shape for the Wikidata citation graph, but the gate at fuse_match_return_aggregate only accepted count(<other-node-var>)count(r) for the edge variable bailed silently. Semantically equivalent for a 3-element pattern (each edge is one peer binding), so the fix accepts both. On Wikidata: most-cited scholarly articles drops from 198s timeout to 28s (~7×, bounded by lookup_peer_counts HashMap construction over P2860’s hundreds-of-millions of edges). Differential test edge_groupby_count_edge_variable added.

  • ORDER BY <agg-expr> now fuses equivalently to ORDER BY <alias>. fuse_match_return_aggregate’s top-K absorption matched only ORDER BY <alias-name> (a Variable expression matching a RETURN alias). Writing the same query as ORDER BY count(x) (an expression duplicating a RETURN item’s expression) left ORDER BY + LIMIT unfused in the pipeline, so the fused MATCH-RETURN-aggregate produced every distinct peer’s row (245k for :P138 on Wikidata) before downstream OrderBy + Limit trimmed to k. On Wikidata this cost 8 s vs the alias-form’s 175 ms for the same query. Absorption now also matches via expression_to_column_name so both forms fuse. Differential test edge_groupby_orderby_expression_form added.

  • Group-by-source aggregations now use a fast path too. Queries shaped MATCH (h:T)-[:E]->(other) RETURN h, count(other) ORDER BY count(other) DESC LIMIT k (e.g. “humans with most awards”) were hitting the slow node-centric scan, which on Wikidata’s :human (13.4M nodes) timed out at 30s/75s with random mmap reads thrashing the page cache. The fast path now detects “group is semantic source” via (group_elem_idx, edge_direction)(0, Outgoing) for the user-written form, (2, Incoming) for the post-reversal form — and computes source-keyed counts on the fly via count_edges_grouped_by_peer(.., Direction::Incoming), a sequential scan of edge_endpoints. Sequential I/O is the right shape for this workload (see feedback_disk_io_patterns.md). On Wikidata, humans-with-most-awards drops from 30-75s timeout to 54s answer — bounded by the sequential edge-scan I/O ceiling, not by query-engine inefficiency. Smaller graphs (social_graph) see sub-millisecond results. Differential test edge_groupby_source_typed added.

[0.9.11] — 2026-05-07

Docs

  • Getting Started rewritten to lead with bulk-load (add_nodes / add_connections from DataFrames) instead of three single-row cypher("CREATE ...") statements. The old ordering misrepresented the day-1 workflow — every real project loads data through the columnar path. Single-CREATE demoted to an “Ad-hoc inserts” callout. Adds the missing pip install "kglite[mcp]" line and a preview of the bundled CLI + source_root: one-liner.

  • New audience-ranked guide index at docs/guides/index.md groups the 14 how-to guides by intent: load-bearing path (data-loading → cypher → mcp-servers), domain-specific (code-tree, spatial, timeseries, etc.), power-user, and “if you want to know why”. Sidebar toctree reordered to match.

  • MCP Servers guide polished: 3-line “What’s MCP?” intro with link to modelcontextprotocol.io; “Five tools from one yaml line” preview pulled into the Quick Start so the source_root: ROI lands before the Claude Desktop config; new “Common boot errors” subsection with eight error→fix mappings + exit-code reference; two new rows in the manifest-vs-fork decision table for the 15-row output cap and FORMAT CSV constraints.

  • README examples reordered: conference_graph_mcp.yaml promoted to the first example as the canonical zero-Python starter post-0.9.10. legal_graph.py reframed as the imperative-API alternative; mcp_server.py demoted to fork-only-when-manifest-can’t.

  • recipes.md “Top-K Nodes by Centrality” now shows the CALL pagerank() YIELD node, score Cypher form alongside the inherent graph.pagerank(top_k=10) Python form — manifest / MCP / agent contexts all reach KGLite through cypher(), so the Cypher form is the agent-friendly default.

[0.9.10] — 2026-05-07

Added

  • YAML manifest for kglite-mcp-server. Drop a <graph_basename>_mcp.yaml next to your graph file (or pass --mcp-config FILE) and the bundled CLI auto-loads it at startup. Three tiers, all optional:

    • source_root: ./data (or source_roots: [./a, ../b]) auto-registers read_source / grep / list_source tools sandboxed to those directories. Backed by the mcp-methods Rust-extension package — ripgrep crates, gitignore-aware, parallel walker, with internal grep so agents can search files too large to dump into context. Paths resolve relative to the yaml’s directory; ../ is allowed.

    • tools: cypher: | blocks register parameterised Cypher as named MCP tools. JSON Schema parameters: drives the synthesised input schema, every $param reference is validated at server startup against the schema, and function signatures get built dynamically so FastMCP’s introspection produces clean tool schemas on the wire.

    • tools: python: ./tools.py + function: name loads custom Python hooks. Two-signal trust gate: requires both trust.allow_python_tools: true in the yaml AND --trust-tools on the CLI. Either alone refuses to load.

  • mcp-methods and PyYAML added to the [mcp] extraspip install "kglite[mcp]" now pulls them automatically.

  • --mcp-config FILE explicit-override flag and --trust-tools Python-hook authorisation flag added to kglite-mcp-server.

  • name: and instructions: manifest fields override the default FastMCP server-info values when set.

Docs

  • MCP Servers guide rewritten around the manifest as the primary customisation path. Forking examples/mcp_server.py is now framed as the escape hatch for needs the manifest can’t cover (custom CSV-export logic, FastMCP middleware, alternative transports). Includes a complete manifest example for a conference-catalog graph.

  • KnowledgeGraph.explain_mcp() (the agent-facing XML quickstart) rewritten for the bundled CLI + manifest path. Previously recommended forking a server file and pointed agents at the wrong install / import.

  • New examples/conference_graph_mcp.yaml — copy-paste-ready reference manifest demonstrating all three tiers (source_root, inline cypher, python hooks) with comments explaining each.

[0.9.9] — 2026-05-07

Added

  • kglite-mcp-server console script. The MCP server that exposes any .kgl graph as a Cypher tool now ships as part of the package — pip install "kglite[mcp]" and run kglite-mcp-server --graph my.kgl. Same surface as examples/mcp_server.py, which is now a thin wrapper around the new kglite.mcp_server.main entry point and lives on as the fork-this template for adding custom tools.

Changed

  • add_nodes and add_connections now emit a UserWarning whenever the report flags any errors, not just when rows were skipped. Previously, follow-up loads with type mismatches set has_errors=True on the report but stayed silent; you had to inspect last_report() to notice. Silent partial successes were a recurring footgun.

Docs

  • New “Loading in passes” section in the Data Loading guide covering the second-add_nodes contract (static-then- timeseries, schema-then-enrichment), what carries over between calls, and a conflict_handling cheatsheet.

  • New “Hierarchies” section disambiguating set_parent_type (type-level disclosure for describe()) from explicit PARENT_OF-style edges (instance-level tree structure that Cypher * walks). They look similar; they aren’t.

  • MCP Servers guide rewritten to lead with the bundled CLI: pip install "kglite[mcp]"kglite-mcp-server --graph X.kgl. Claude Desktop / Claude Code configs use "command": "kglite-mcp-server" directly. Tutorial body is now framed as the customisation path for forks of examples/mcp_server.py.

  • add_nodes / add_connections reference sections in the Data Loading guide reframed as parameter tables (no longer redundant with the walkthrough).

[0.9.8] — 2026-05-07

Fixed — add_nodes no longer clobbers the title alias on follow-up calls

A repeated add_nodes(...) on an existing node type without node_title_field (the canonical pattern when layering timeseries onto static rows, the example in the docstring) silently rebound title_field_aliases[node_type] to the unique_id_field. Later Cypher queries for s.id then resolved to the stored title, returning the title string in place of the id. The only visible signal was title_alias="id" in describe() output.

The alias map is now written only when the caller explicitly passes node_title_field. Surfaced by a real-world graph build where the “static rows once, timeseries on top” pattern hit it.

Added — describe(sample_truncate=…) to control title truncation in the XML

Sample values, sample node titles, and sample edge attributes emitted by describe() get truncated at 40 chars by default to keep prompts compact. Pass describe(sample_truncate=None) to emit them in full when you want full titles in an LLM context and have the budget for it; pass an integer for a custom threshold. The knob only affects rendering — stored data is always full-precision and accessible via Cypher.

Docs

  • New “End-to-end walkthrough” section at the top of the Data Loading guide — shape tables → add_nodesadd_connections → Cypher → save/load — so the README’s “DataFrames in” pitch lands on a single connected story instead of scattered reference snippets.

  • AI Agents guide now documents the id_alias / title_alias attributes on <type> elements and the new sample_truncate knob.

[0.9.7] — 2026-05-04

[0.9.7] — 2026-05-04

Jupyter ergonomics — wikidata.open() is now process-cached

wikidata.open(workdir) previously did a fresh disk-graph load (~350 MB in-memory state on the 124M-node truthy graph) on every call, even when the same workdir had already been opened in the same process. Repeating the call in a Jupyter notebook (the typical “rerun-cell” workflow) accumulated RSS until the kernel ran out of room and started swapping or hung after a dozen iterations.

open() now holds a process-local cache keyed by (canonical workdir path, entity_limit_millions)(KnowledgeGraph, disk_graph_meta.mtime). Cache hits return the same KnowledgeGraph instance the prior call handed back. The cache invalidates automatically when:

  • the on-disk graph is rebuilt (mtime advances)

  • force_rebuild=True is passed

  • the user calls the new wikidata.cache_clear() (mirrors functools.lru_cache’s pattern; returns count of entries dropped)

Memory-mode opens skip the cache entirely — they’re meant to be reproducible rebuilds.

Verified: 3 × wikidata.open(WORKDIR) in one process → 426 MB once, then flat. Same instance returned (g1 is g2 is g3).

Examples — examples/wikidata_disk.py rewritten

Replaces the 259-line build-plus-bench harness with a 35-line realistic walkthrough: download/load the dump via wikidata.datasets.wikidata.open(), print graph size + a “name+type lookup → awards” demo for Albert Einstein. Each step shows its wall time so users see what each operation costs. Full benchmark version preserved in dev-documentation/.

Known issue (not fixed in 0.9.7)

MATCH ({nid: $param})-[:T]->() on the 124M-node Wikidata graph runs ~12,000× slower than the literal-form MATCH ({nid: 'Q937'})-[:T]->() (~65 seconds vs ~5 ms) and allocates ~3 GB of RSS per call. The index-lookup planner pass treats Expression::Parameter as a non-indexable predicate when the property name is the global id alias. Workaround: inline the literal value (Cypher injection-safe when the value came from the graph itself) or fold into a single multi-MATCH query that anchors once on a typed node pattern. Tracked for a future release.

585 cargo, 2345 pytest, 97/97 parity, lint clean.

[0.9.6] — 2026-05-03

Cypher correctness fix — collect()[slice] over OPTIONAL MATCH raised a spurious aggregate-context error

Cypher of the shape

MATCH (n {id: $id})
OPTIONAL MATCH (n)-[:T]-(x)
WITH n, collect(DISTINCT x.title)[0..3] AS first_three
RETURN n.title, first_three

failed at runtime with Aggregate function 'collect' cannot be used outside of RETURN/WITH, even though the collect call was clearly inside a WITH projection. The same expression on a non-OPTIONAL MATCH worked fine. Caused users to rewrite the query as WITH ... collect(...) AS xs RETURN xs[0..3], which is identical semantically but unobvious as a workaround.

Root cause. aggregates_only_count in planner/fusion.rs — the gate that decides whether the OPTIONAL-MATCH count fusion can absorb a projection — recursed into arithmetic and function-call nodes but fell through to _ => true on ListSlice, IndexAccess, ListComprehension, and Case. So collect(x)[0..3] (a ListSlice wrapping a FunctionCall) was wrongly classified as “all aggregates inside are count-shaped” and the count-fusion accepted it. The fused executor then ran evaluate_expression per row on the substituted-but-still- containing-collect projection, and the runtime correctly rejected the per-row aggregate call.

Fix. aggregates_only_count now recurses into the same wrapper expression variants that ast::is_aggregate_expression walks — slice, index, list comprehension, case, expression-property-access, map-literal. collect()[…], collect()[i], collect() inside a CASE, and other “aggregate inside a wrapper” shapes all bail fusion correctly and route through the materialised aggregate evaluator.

The same fix incidentally closes a related class of broken queries: sum(x.prop), min(...), max(...), etc. wrapped by ListSlice/IndexAccess/etc. over an OPTIONAL MATCH were also silently broken pre-0.9.6 — never explicitly tested but caught by the new corpus entries.

Differential corpus regressions (tests/test_cypher_differential.py): collect_slice_over_optional, collect_index_over_optional, sum_over_optional.

Cypher perf fix — LIMIT N not pushed into grouping aggregator

Hub-anchored OPTIONAL MATCH + collect/aggregate + LIMIT N queries on the 124M-node Wikidata graph were unnecessarily slow:

MATCH (x)-[:P31]->(hub {nid: 'Q11424'})        -- film hub, 340k inbound
OPTIONAL MATCH (x)-[:P27]->(country)
RETURN x.title AS x, collect(DISTINCT country.title) AS countries
LIMIT 15

Materialised the full 340k MATCH expansion + 340k OPTIONAL P27 expansions + 309k group buckets, then truncated to 15 at the very end. Cold: 64s. Warm: 547ms.

Root cause. The materialised aggregator drained every group key before any downstream LIMIT clause looked at the rows. There was no path for a literal LIMIT N to inform the grouping loop that it could stop creating new groups after N distinct keys.

Fix. New planner pass push_limit_into_aggregate (registered in PASSES between push_limit_into_match and push_distinct_into_match). When the projection clause has both group keys and aggregates AND the next clause is a literal LIMIT N (no intervening ORDER BY, no DISTINCT, no HAVING), the pass stamps a group_limit_hint on the ReturnClause / WithClause. The aggregator then uses a 2× safety margin during the surrogate-key grouping pass (NodeIndex→Value collisions can collapse groups during the resolve step) and truncates to the exact N after resolve. Rows for already-collected keys continue to feed their aggregates so collect() / sum() complete correctly for the surviving groups.

ORDER BY between projection and LIMIT correctly disables the optimisation — needed every group to find the top N. The existing LIMIT clause stays in the plan as a hard safety cap.

Verification on the 124M-node graph (warm steady state):

State

Latency

Profile shape

Pre-fix

547 ms

Return rows_in=340688 rows_out=309004 Limit 15

Post-fix

257 ms

Return rows_in=340688 rows_out=15 Limit 15

The remaining 257ms is genuine MATCH + OPTIONAL fanout work (340k inbound :P31 + 340k OPTIONAL :P27 expansions). True streaming through MATCH itself would need a larger refactor and is left for a future release.

Differential corpus regressions: limit_into_aggregate_collect, limit_into_aggregate_count, limit_with_order_by_no_pushdown.

Investigated, not fixed — first-MATCH cold-mmap warmup spike

User reported ~700ms latency on the first cypher call after process start against the same 124M-node graph; subsequent queries sub-100ms. Reproduced locally at ~1010ms on the same graph; <5ms on a 16M-node graph regardless of whether we’d touched it that session. Not a code bug — the first MATCH page-faults the id_indices, node_slots, and column-store mmap regions for the queried type, and the cost scales with graph size + how cold the OS page cache is.

Existing mitigation: KGLITE_PREFETCH=1 env var triggers madvise(MADV_WILLNEED) against hot regions at load time, paid upfront instead of on the first user query. Recommended for MCP servers that load multi-GB graphs at startup and want predictable first-query latency.

585 cargo, 2345 pytest, 97/97 parity, lint clean.

[0.9.5] — 2026-05-02

Disk-mode regression fix — register_connection_type flipped the conn-type cache into a half-built state

On a disk graph that’s been loaded from disk (kglite.load(path)), the very first add_connections call after load broke MATCH queries on every other edge type. MATCH ()-[:OF_DISCOVERY]->() returned 0 rows even though the edges were still on disk and unanchored MATCH ()-[r]->() RETURN type(r) still listed them. Surfaced on the Sodir prospect graph’s load-then-enhance flow: the moment the re-enhance pass added its first new edge type, every subsequent FK-edge join (OF_DISCOVERY, OF_FIELD, OF_PROSPECT, IN_PLAY, HAS_DEPOSIT_PROSPECT, HAS_PLAY) silently produced 0 rows. The single-pass build_sodir_graph.py --storage disk flow was unaffected because the cache was in the right state during initial build.

Root cause. DirGraph.connection_types is a HashSet<InternedKey> that powers the O(1) fast path of has_connection_type. The set is built from connection_type_metadata.keys() via build_connection_types_cache() — but that function was only called from read_graph_v3 (the .kgl v3 loader), never from load_disk_dir (the disk-graph loader). On a freshly-loaded disk graph the set was therefore empty, and has_connection_type correctly fell through to the metadata-fallback branch which returns true for every edge type in the loaded metadata.

register_connection_type(NEW) then unconditionally inserted NEW into the empty set. The next has_connection_type(EXISTING) call hit the fast path: “set non-empty? consult cache” — which now only contained NEW, so it returned false for every existing edge type. The pattern matcher’s early-exit (“skip iteration when the conn type doesn’t exist”) fired for every typed MATCH on existing edges.

Fix. Two complementary patches:

  1. load_disk_dir now calls build_connection_types_cache() after loading metadata, mirroring the v3 loader. Keeps the cache authoritative throughout the lifetime of any loaded disk graph.

  2. register_connection_type lazy-builds the cache from connection_type_metadata when called against an empty set. Closes the same hole defensively for any future code path that could leave the cache empty before calling it.

Verification. New bench/cross_mode_pipeline.py harness runs an enhance-style sequence (load → create_index → add_connections of a new edge type → SET → re-read baseline queries at every step) on legal + sodir × {memory, mapped, disk}. Pre-fix:

legal disk:  step3_after_add_connections → cites_total: 0 (baseline 592305)
legal disk:  step3_after_add_connections → section_of_total: 0 (baseline 23585)
sodir disk:  step3_after_add_connections → of_discovery_join: 0 (baseline 107)
sodir disk:  step3_after_add_connections → of_field_join: 0 (baseline 2280)
sodir disk:  step3_after_add_connections → of_prospect_join: 0 (baseline 21857)

Post-fix all per-step counts match baseline across all three modes on both graphs. Re-running the Sodir enhance(g) on a loaded disk graph (which was the user-facing surface of this bug) now matches the standalone-build counts: 97 discoveries enriched, 139 fields, 126 production profiles, 4345 prospects tagged, 48 plays calibrated, 2406 prospects calibrated.

Regression test: tests/test_disk_mutation_roundtrip.py::test_register_new_conn_type_preserves_existing_type_lookups loads a small disk graph with WORKS_AT edges, runs add_connections of a new FRIENDS_WITH type, and asserts MATCH on WORKS_AT still finds all 5 edges. Test fails with the fix reverted.

Test harness — pipeline-shaped consistency

bench/cross_mode_pipeline.py (new). Closes the gap that let the sequence-of-operations bug class slip through the earlier single-op harnesses (cross_mode_table.py, cross_mode_consistency.py): record per-step row counts during a multi-op pipeline, compare each step to a fresh-load baseline, flag the exact step + query where any cell drifts. Same code shape that caught the bug in this release; held in bench/ (gitignored) for ad-hoc use.

585 cargo, 2338 pytest, 97/97 parity, lint clean.

[0.9.4] — 2026-05-02

Disk- and mapped-mode regression fix — Cypher SET silently no-oped on mmap-backed ColumnStores

Cypher SET on nodes whose property storage was an mmap-backed ColumnStore (the path load_ntriples always takes for mapped / disk targets, regardless of input size) reported success at the clause boundary — MATCH (a) SET a.x = 1 RETURN count(a) returned the expected count = 1 — but a follow-up RETURN a.x came back None. The clause matched, the writer reported success, but the write was invisible on read. Same pattern for SET a.title = : the title column held the old value forever.

Root cause. ColumnStore::from_mmap_store builds a store with schema = TypeSchema::new() (empty), columns = Vec::new(), title_column = None, and mmap_store = Some(...) carrying the real data. Every read method (get, get_title, str_prop_eq, row_properties) short-circuited to the mmap-backed read at the top of the function — but set and set_title wrote into the local self.columns / self.title_column fields the readers bypassed. The fix to 0.9.2’s disk-mode SET visibility regression (per-clause flush_pending_writes + sync_column_stores_from_disk) landed the writes in the right place; readers just couldn’t see them once the store was mmap-backed.

The bug only surfaced for load_ntriples-built graphs: add_nodes followed by save+reload produces ColumnStores whose blobs reload as in-memory columns: Vec<TypedColumn> with mmap_store: None, so the read short-circuit never fires there. Sodir’s from_blueprint build → save → reload path passes the existing SET-roundtrip parity tests for the same reason.

Fix. get / get_title / str_prop_eq / row_properties now consult the in-memory overlay first and only fall through to the mmap-backed read when no override exists for that (row, key). set_title lazy-promotes the mmap-backed title column into a Mixed in-memory column on first override, so the dense title column isn’t allocated up-front (avoiding the multi-million-row materialisation that an eager promotion would force on Wikidata).

Verification on the cross-mode consistency harness (bench/cross_mode_consistency.py, runs the same edit + read queries against every (graph, mode) cell and SHA-256s the rows):

graph

mode coverage

pre-fix verify_edit digest

post-fix

legal

memory + mapped + disk

identical

identical ✓

sodir

memory + mapped + disk

identical

identical ✓

wiki100m

memory + mapped + disk

memory=278f…9516, mapped/disk=∅

all 278f…9516

wiki500m

mapped + disk

mapped/disk=∅ vs absent canonical

both 278f…9516

Read-only queries (simple / medium / complex) were already identical pre-fix; this release closes the SET-visibility gap.

Regression test test_cypher_set_visible_on_mmap_backed_columnstore in tests/test_disk_mutation_roundtrip.py builds a tiny inline .nt with 5 Q-entities, loads it under mapped + disk, applies SET to both a new property name AND title, and asserts the writes are visible. Test fails with the fix reverted (confirmed by git stash-ing column_store.rs only).

585 cargo, 2337 pytest, 97/97 parity, lint clean.

[0.9.3] — 2026-05-02

Disk-mode regression fix — parallel projection data race on node_arena

Disk-mode queries that reached node_weight materialization through the Cypher executor’s projection phase produced non-deterministic results across runs, ranging from silently wrong row counts (Bug A in the 0.9.2 disk regression report — ~13% of NEAREST_AFEX_HUB edges and ~2% of IN_AFEX_AREA edges silently dropped on the Sodir prospect graph) to use-after-free segfaults with BUG: InternedKey N not found in StringInterner lines on stderr (Bug B in the same report).

Root cause. DiskGraph::node_arena was an UnsafeCell<Vec<NodeData>> and its module-level SAFETY block claimed the single-threaded-query contract guaranteed exclusive access. In reality the Cypher executor’s projection phase (return_clause::project_row) runs evaluate_expression under par_iter_mut once result_set.rows.len() >= RAYON_THRESHOLD (256), and any expression that reaches node_weight — spatial functions (centroid / contains / distance), the spatial-fallback branch of resolve_property, the NodeRef-in-projected branch — pushed onto the unguarded Vec from sibling Rayon tasks. A push that triggered realloc invalidated the &NodeData references already returned to other tasks; downstream reads either (a) saw the wrong row’s properties (Bug A — sometimes the polygon parsed cleanly to a near-but-different centroid and the row simply missed its hub) or (b) followed a dangling pointer into the freed allocation (Bug B — sometimes the dangling slot decoded to an InternedKey the interner didn’t know, surfacing the BUG line; with worse timing, SIGSEGV).

The non-determinism explains why the fresh build → save → reload → read flow showed different counts than the in-process build: the in-process flow used the warm in-memory column stores, while the load-then-read flow re-materialized through node_weight under the parallel projection.

Fix. node_arena is now Mutex<Vec<Box<NodeData>>>, mirroring the long-standing pattern used by edge_arena. The Box gives stable heap pointers that survive Vec growth; the Mutex serialises pushes. The &NodeData references handed back are valid for the lifetime of &self because the arena is only cleared via clear_arenas (&mut self) or reset_arenas (called between top-level queries).

Verification. Fresh disk build of the Sodir prospect graph (557k nodes) now produces edge counts that match the in-memory build byte-for-byte:

Edge type

default

disk (pre)

disk (post)

IN_AFEX_AREA

886

866

886

NEAREST_AFEX_HUB

5,881

5,134

5,881

IN_BLOCK

7,983

7,983

7,983

IN_STRUCTURAL_ELEMENT

6,763

6,763

6,763

The load enhance save workflow that was reported as segfaulting on disk now completes cleanly with no BUG: InternedKey lines on stderr.

Two regression tests cover the race surface: test_disk_parallel_projection_node_weight_is_race_free (8 repeated runs of centroid projection on a 600-node disk graph must report identical counts) and test_disk_parallel_projection_no_interner_corruption (asserts no BUG: InternedKey N not found lines appear on stderr across repeated parallel projections).

585 cargo, 2333 pytest, 97/97 parity, lint clean.

[0.9.2] — 2026-05-02

Disk-mode regression fix — property visibility after blueprint build

Disk-mode from_blueprint(...) left freshly-built graphs in a state where every Cypher property read returned NULL. MATCH (p) RETURN count(p) worked (slot enumeration), but RETURN p.x for any property — even id and title — returned None. The Sodir prospect-graph build on disk surfaced the failure shape: every Stage 2.x derived edge was 0, every Stage 3 SET cascade collapsed because the read-back of the previous stage’s writes returned 0 rows. Single-process build → enhance → save was unusable on disk; default and mapped modes were unaffected.

Two related sync gaps between DirGraph.column_stores and DiskGraph.column_stores:

  • batch.rs deferred-columnar pass after creates. Disk’s add_nodes populated graph.column_stores (DirGraph-side) and updated each slot’s row_id, but never mirrored to disk_graph.column_stores (where disk reads through node_weight / get_node_id / get_node_title). The existing sync_disk_column_stores() only fired when the chunk had UPDATEs, never on creates-only. Fix: after the deferred-columnar loop, call graph.sync_disk_column_stores() when disk-backed.

  • write.rs after Cypher mutation flushes. Each SET / REMOVE / MERGE clause calls flush_pending_writes (0.8.41) which drains node_mut_cache into disk_graph.column_stores. But graph.column_stores stayed stale. A subsequent add_nodes (which now calls sync_disk_column_stores per the first fix) would clobber disk’s post-flush state with the pre-flush DirGraph snapshot — silently losing the SET’s effects on the multi-stage SET add_nodes read pipeline. Fix: after every flush_pending_writes (per-clause + end-of-query), also call graph.sync_column_stores_from_disk() to mirror the post-flush disk state back to DirGraph.

Verification on the Sodir prospect-graph build (557k nodes, 47 edge types):

  • All Stage 2.x derived edges populated (was all 0): IN_BLOCK 7,983, IN_STRUCTURAL_ELEMENT 6,763, IN_AFEX_AREA 779, NEAREST_AFEX_HUB 5,560.

  • All Stage 7 derived edges populated (was all 0): HC_IN_FORMATION 850, ENCLOSES 10,588, PLAY_HAS_FORMATION 248, DRILLED_IN_PLAY 2,627.

  • Stage 3.6 value_basis distribution matches the in-memory baseline byte-for-byte: estimate=2754, realized=546, dry=1437, deflagged=1577, unscored=460. Pre-fix every prospect collapsed into unscored.

All three storage modes — default, mapped, disk — now produce identical results on a 16-case integration smoke test that exercises every 0.9.x gate item plus the multi-stage SET / add_connections / save+reload pipeline.

585 cargo, 2333 pytest, 97/97 parity, lint clean.

[0.9.1] — 2026-05-02

Breaking change in 0.9.0 (Rust crate API) — late-breaking notice

MemoryGraph and MappedGraph no longer implement std::ops::DerefMut to their inner StableDiGraph. Code that mutated through auto-deref now fails to compile and must use one of:

  • the explicit accessor: g.inner_mut().add_node(data), OR

  • trait dispatch: use kglite::graph::storage::GraphWrite; g.add_node(data).

Why: auto-deref-via-DerefMut shadowed GraphWrite::add_node (and peer mutation methods), causing calls to bypass MappedGraph::invalidate_property_index() that the trait impl runs first. Removing DerefMut converts a silent stale-index bug into a compile-time error. Read-only Deref is retained — read calls (g.node_count(), etc.) are unchanged.

Python users: no impact. The PyO3 boundary is unchanged.

This was already in 0.9.0 (commit bcb0bb7, “chore(storage): drop DerefMut on Memory/MappedGraph”) under “Hygiene & test coverage” — this entry simply re-flags it for downstream Rust embedders who might miss it under that subhead.

Blueprint diagnostics

  • from_blueprint(verbose=True) reports actual graph edge counts. Pre-fix, the verbose log printed an accumulated input-row count from the blueprint pipeline; with default Update conflict handling, repeated (src, tgt) pairs across multiple section ingests over-counted vs MATCH ()-[r]->() RETURN count(r). Easy to mistake for a save regression. Now queries graph.get_edge_type_counts() (the post-build truth) and reports those numbers; when input and graph counts diverge, the line is annotated [T]: N edges (M input rows, K deduped) so users see both. Backward-compatible for zero-duplicate datasets.

  • Warning capture documented. Blueprint warnings (target node not found, null FK, etc.) hit stderr by default. Python’s standard logging.captureWarnings(True) routes the Rust-emitted UserWarnings into the py.warnings logger where any standard handler (file, rotating, stream) can catch them — no 2>&1 shell redirect needed. The from_blueprint docstring now documents the pattern with a copy-paste-ready file-capture example. Pinned by test_logging_capture_warnings_pipeline.

Documentation

  • CYPHER.md gained a “Duration semantics” subsection explaining the calendar-vs-clock component split (months/years stay separate from days/hours/minutes/seconds), with worked examples for duration.between() and DateTime ± Duration. Postgres interval users will want this. Pinned by an anchor test (test_duration_between_cypher_md_example).

[0.9.0] — 2026-05-02

Cypher dialect — gate items

  • §5 Integer division (Neo4j-standard). 1967 / 10 now returns 196 (truncated Int64), matching openCypher / Neo4j. Float promotion only when at least one operand is a float. Negatives truncate toward zero (-7 / 2 -3). Modulo behavior preserved.

  • §2 NULLS FIRST / NULLS LAST in ORDER BY. New ast::NullsPlacement + parser support. Default placement is Neo4j 5+: NULLS LAST for ASC, NULLS FIRST for DESC. Plumbed through both the in-memory sort path and the heap_top_k streaming operator.

  • §3 Stable date function set + Cluster 2 proper Value::Duration. Datetime field accessors .year/.month/.day/.dayOfWeek/.dayOfYear/.epochSeconds on Value::DateTime. New Value::Duration { months: i32, days: i32, seconds: i64 } variant — calendar units (months/years) and clock units (days/hours/minutes/seconds) stay separate, so duration({months: 1, days: 5}).months returns 1 (not 35 collapsed to days). duration() constructor, duration.between(), DateTime ± Duration, Duration ± Duration arithmetic. Sub-day precision wired in seconds; Value::DateTime is still NaiveDate, so DateTime + Duration discards the seconds component for now (Cluster 1 deferred). Duration variant is the LAST enum variant — old .kgl files load unchanged.

  • §4 Polygon-vs-polygon contains() in WHERE. The fast-path spatial filter for MATCH (a), (b) WHERE contains(a, b) now handles geometry-vs-geometry when neither side has a Location point. Pre-fix the path silently returned false for every outer-contains-inner match in polygon-only graphs. Bundled MULTIPOLYGON dedupe — single boolean answer per (a, b) pair regardless of how many components match.

  • §1 Better Cypher error messages. Every parse error now carries (line N col M) plus a single-line source excerpt with a ^ caret. Position is byte-precise — the tokenizer attaches char offsets to every token; parser threads them through; format_parse_error walks input.chars() to compute (line, col) on the error path. New intent_level_rewrite hook in parser/mod.rs for “feature not yet implemented” detection (currently empty — all named candidates parse successfully).

  • §6 size() over pattern expressions. size((:A)-[:R]->(:B)), size((a)-[:R]->(:B)) (per-row binding), size((:A)-[:R]->(:B)) >= 2 in WHERE. Wraps the existing 0.8.16 count-subquery code path. Refactored parse_exists_patterns into parse_pattern_subquery_patterns with a caller-supplied delimiter (RBrace for EXISTS/count, RParen for size).

Hygiene & test coverage

  • Cluster 6: dropped DerefMut on MemoryGraph / MappedGraph. Auto-deref-via-DerefMut shadowed GraphWrite trait methods, so e.g. g.add_node(data) on &mut MappedGraph reached petgraph’s inherent method directly, bypassing MappedGraph::invalidate_property_index() that the trait impl runs first. Removing DerefMut forces explicit .inner_mut() or trait dispatch — compile-time catch instead of silently-stale- index runtime bug. Read-only Deref retained.

  • Cluster 6: node_weight_mut staging contract documented. Trait method on GraphWrite now carries explicit doc that disk buffers writes in node_mut_cache and callers must call flush_pending_writes() before any subsequent &self read. Debug-only assertion in DiskGraph::node_weight warns when a staged write is shadowed by a read (catches future code paths that forget the flush).

  • Cluster 7: deep-traversal path-materialization coverage. New test_long_chain_traversal_path_materialization_100_hops exercises actual path enumeration across memory + mapped + disk via RETURN b.id instead of the planner-short-circuited RETURN count(b) shape that the prior 1,000-hop test used.

  • Cluster 7: datetime accessor golden round-trip. Two new queries in the golden corpus pin joined_at.year/.month/.day extraction against the social-graph fixture so §3 accessor behaviour can’t drift unnoticed.

Internal — pre-existing parity audits cleared

  • .kgl v3 fixture digest updated (no format change — CURRENT_FORMAT_VERSION still 3). Fixture-graph hash drifted across the 0.8.x → 0.9.0 line as save-path / interner refinements landed; backward compatibility verified by loading pre-0.9.0 .kgl files cleanly.

  • GraphBackend enum-match audit whitelist refreshed for the pre-existing leaks (column_builder.rs, match_clause.rs, blueprint.rs, indexes.rs).

  • Binary-size baseline reset to 0.9.0 (~22.4 MB) with a +10% gate. Phase 4 baseline (6.67 MB) was 3.4× off the current build — accumulated growth from multi-mode storage + spatial + timeseries + code-tree + MCP + Cypher dialect work.

  • god_file_gate: match_clause.rs (2,679 lines) and fusion.rs (2,923 lines) documented in GOD_FILE_EXCEPTIONS with concrete 0.9.x split plans.

  • mod_rs_purity: caps bumped on executor/, parser/, planner/ mod.rs files to match the dispatch surface they carry; 0.9.x cleanup intent documented.

[0.8.41] — 2026-05-02

Cypher executor — Bug 8 followup

  • Fix: SET on an in-memory graph dropped new properties on save. The 0.8.39 master-path fix that routed Columnar SET writes through graph.column_stores to dodge the per-node Arc-clone storm computed the property’s InternedKey via from_str() (just hashing) without registering the source string in graph.interner. As a result, Cypher SET that introduced a new property name on an in-memory or mapped graph survived in-memory queries but vanished on save+reload, printing BUG: InternedKey N not found in StringInterner to stderr and silently corrupting the saved file. Now registers via graph.interner.get_or_intern(property) before borrowing column_stores. Disk mode is unaffected (gated path, separate pre-existing bug for that backend).

  • Defense in depth: debug-only invariant in write_graph_v3. Walks every column_store’s schema before serialization and panics with a clear, actionable message if any InternedKey doesn’t resolve in graph.interner. Catches the entire class of bug (“writer synthesizes an InternedKey without first registering the source string”) at write time, not at load time on the user’s machine. Zero release-build cost.

  • Regression: tests/test_disk_mutation_roundtrip.py::test_cypher_set_new_property_persists_through_save_reload parameterised over memory + mapped + disk (all three modes now covered after the disk-side fix below).

Cypher executor — disk SET visibility

  • Fix: Cypher SET on a disk-backed graph appeared to no-op on in-session reads until the next &mut self op (e.g. save()) flushed the staged writes. Disk’s node_weight_mut stages writes in node_mut_cache to dodge the Arc<ColumnStore> share-clone storm per row; node_weight (the read path) reads column_stores directly and ignored the cache. Save+reload happened to recover the data because clear_arenas runs as part of save, but every in-session read between SET and save returned the pre-SET value — silently corrupting any analysis that read SET-staged columns back. Affects both new-property SET (SET n.value_score = ) and existing-property SET (SET n.age = n.age + 100). Same query shape: MATCH SET RETURN n.prop returned NULLs for the just- written column.

  • Fix shape: new GraphWrite::flush_pending_writes(&mut self) trait method, default no-op, overridden on the disk backend to call its existing clear_arenas (which already does the clone-apply-replace flush of node_mut_cache / edge_mut_cache into column_stores / edge_properties). execute_mutable calls it after every write clause (SET / REMOVE / MERGE) and once at end-of-query so any trailing RETURN’s property projection — and any next-query read — sees the writes. Memory and mapped backends are unaffected (their node_weight_mut mutates StableDiGraph in place, so reads see writes immediately).

  • Doesn’t affect Sodir — the prospect graph runs in-memory — but unblocks the disk-mode “SET as cached column” pattern for any future workstream that uses storage=”disk”.

[0.8.40] — 2026-05-01

Cypher planner

  • Spatial-join fusion: multi-MATCH + centroid() probe. Extends fuse_spatial_join to recognise the MATCH (a:T1) MATCH (b:T2) WHERE contains(b, centroid(a)) shape (or the inverse contains(a, centroid(b)) — the call decides which MATCH is container vs. probe, not pattern position). Previously only the single-MATCH cartesian form fired; the multi-MATCH form fell back to cross-product + post-filter. Sodir’s IN_STRUCTURAL_ELEMENT enrichment (Prospect → StructuralElement via centroid) drops from ~2 s to ~0.6 s on a 6,775-prospect graph; any project doing point-in-polygon enrichment via centroid() benefits.

  • New Clause::SpatialJoin::probe_kind: SpatialProbeKind carries whether the probe-side point comes from the spatial-config location (single-MATCH cartesian) or the centroid of the probe’s geometry (multi-MATCH centroid). The spatial-join executor honours the kind when sourcing the per-probe point.

  • An optional pre-WHERE between the two MATCHes (e.g. MATCH (p:Prospect) WHERE p.wkt_geometry IS NOT NULL MATCH (s:StructuralElement) ...) is folded into the SpatialJoin’s residual predicate so per-pattern filters still apply after fusion.

  • Regression coverage: tests/test_cypher_spatial.py::TestSpatialJoin picks up test_multi_match_with_centroid_probe (correctness vs. the brute-force two-pattern path) and test_multi_match_centroid_fires_fusion (EXPLAIN must show SpatialJoin).

[0.8.39] — 2026-05-01

Cypher executor

  • Fixed scalar projection from spatial-function results (centroid(n).latitude and WITH centroid(n) AS c RETURN c.latitude). Previously returned the entire {latitude, longitude} dict — or Null on in-memory graphs — instead of the float. Property access on Value::Point now extracts the named field via a new point_field() helper, applied in both Expression::ExprPropertyAccess and the resolve_property projected fallback. Accepted aliases: latitude/lat/y and longitude/lon/lng/long/x. The canonical point(centroid(n).lat, centroid(n).lon) idiom now composes in a single Cypher query.

  • Fixed spatial-predicate failure on partial-coverage typed sets. When a typed node set has spatial config but only a fraction of rows have geometry data populated (real-world example: 312 of 469 AfexAreas in the Sodir graph have wkt_geometry IS NULL), WHERE contains(a, point(lat, lon)) errored on the missing rows instead of treating them as predicate-false. contains() now NULL-propagates: row-level missing geometry returns Boolean(false) so the predicate filters the row out cleanly. Same NULL-propagation for centroid() / area() / perimeter() (return Value::Null). intersects() retains the loud error for the type-level “no spatial config anywhere” case (preserves existing diagnostic test coverage).

  • Fixed superlinear SET cost on typed nodes with shared columnar storage; OOM at ~1k rows on the Sodir Prospect set. Per-node Arc::make_mut(store).set(...) cloned the entire shared ColumnStore on every write — for 6,775 Prospect nodes sharing one store, refcount=N+1 meant a full clone per row, giving O(N²) work and ~11 GB transient allocations. SET now routes Columnar writes through graph.column_stores[type] once per batch, then refreshes per-node Arc<ColumnStore> handles in a single end-of-statement sweep. Verified on the real Sodir graph: 100 → 4 ms (was 0.81 s, 200×), 500 → 4.4 ms (was 13.1 s, 3000×), 1000 / 2000 → 3 ms (were OOM). Linear scaling restored. Disk-mode graphs use a separate write path and are gated out via graph.graph.is_disk().

Fluent API

  • create_connections() now emits a UserWarning when called on a chained graph view. The fluent g.select(...).traverse(...) .create_connections(...) pattern returns a NEW KnowledgeGraph whose mutations live on a temporary clone (Arc COW); discarding the return loses the writes. The warning fires when Arc::strong_count(self.inner) > 1 and points to the two workarounds: capture the return (g = g.select(...) .create_connections(...)) or use the equivalent add_connections(data=cypher_result, ...). Docstring also updated. Proper structural fix (shared interior mutability) deferred — would touch ~hundreds of read-path call sites.

[0.8.38] — 2026-05-01

code_tree

  • Re-add _build to the ignored-dirs list. Caught while perf-testing 0.8.37 against the kglite repo itself: Sphinx docs/_build/_static/*.js was getting indexed (26 JS files), even though the build artifacts aren’t user source. The leading- underscore convention is a strong signal of “tool-generated build output” (Sphinx, mkdocs, mdBook), distinct from the more ambiguous build / dist / out (which stay off the list because dist/bundle.js may be the user’s webpack output they want flagged-as-too-large). Verified against code_tree.build() on the kglite repo: 337 files → 310 files, matching the 0.8.34 baseline exactly. Build time 0.21s.

[0.8.37] — 2026-05-01

code_tree

  • Fixed code_tree.build() over-indexing on projects with nested .venv / node_modules / target / etc. Reported by an MCP consumer whose codebase graph ballooned 7,620 → 70,605 nodes after upgrading to 0.8.36. Root cause: the mixed-language safety net added in 0.8.36 walked first-level subdirs recursively to detect undeclared languages — but the walk filter for ignored directory names (.venv, node_modules, target, __pycache__, site-packages, venv, env, plus all .dot dirs) only applied at the top level. A single C-extension source inside a nested .venv (e.g. numpy in a subprojects venv) attracted the parent dir as a supplemental source root and then parse_directory walked the full venv. The filter is now applied at every depth in both the safety-net detection walk and the actual parser walk.

  • Conservative trim of the ignored-names list. build, dist, out, _build removed — those names are tooling-dependent (e.g. dist/bundle.js is sometimes the user’s webpack output they want indexed-and-flagged as too-large rather than excluded outright). Use max_loc_per_file to handle oversized build artifacts.

[0.8.36] — 2026-05-01

Cypher planner

  • Fixed mark_fast_var_length_paths per-target row drop — closes the lone open xfail in the differential harness. The pass set needs_path_info=false on any unnamed variable-length edge, which triggered a target-node-deduping BFS that returned fewer rows than Cypher’s per-path semantic (e.g., 2 rows where Neo4j returns 3). The fix gates the pass on downstream_is_dedup_safe — the next RETURN/WITH must be DISTINCT or its projections must be entirely dedup-safe aggregates (min/max/count(DISTINCT)/collect(DISTINCT)). Plain RETURN q.name over var-length now uses the slow per-path BFS (correct); users who want the fast path opt in via RETURN DISTINCT q.name or count(DISTINCT q).

  • Differential harness corpus extended to ~95 query shapes plus 9 mutations + 26 per-pass bisection tests. Probed three additional rounds (CALL/list-comp/path-ops/multi-WITH/HAVING/coalesce/CASE-in- agg/expr-filter/etc.) and surfaced no further divergences after fixing the var-length pass. The corpus also now includes var_length_no_var_per_path, var_length_no_var_distinct, and var_length_no_var_count_distinct as permanent regression tests for the fix.

  • Performance cleanups in the new code paths. optimize() now returns a process-lifetime empty HashSet<String> via OnceLock instead of allocating a fresh HashSet::new() on every call; the PyAPI’s cypher() short-circuits the disabled-passes set construction when both disable_optimizer=False and disabled_passes=None (the default), bypassing the validation loop entirely on the hot path.

  • Third debug-mode IR invariant: literal LIMIT and SKIP values must be non-negative. Catches passes that synthesize a literal limit hint (e.g. fusion top-K) and forget to clamp at zero. Zero release cost (#[cfg(debug_assertions)]-gated).

  • Makefile bench targets force maturin develop --release. Saved baselines are release-built; running make bench-compare against a dev build previously showed false ~15× regressions across every benchmark. New baseline 0007_post_robustness_pass saved with all the planner refactor + bug fixes in place.

  • Optimizer is now a registry of named passes (kglite.cypher_pass_names()). The 25-pass orchestrator at src/graph/languages/cypher/planner/mod.rs has been refactored from a 40-line inline body into a single const PASSES: &[(&str, PassFn)] source of truth. Each pass has a stable name and a doc-comment with precondition / pattern / rewrite / why-bail. Adding a new pass is now: write the impl, write a one-line wrapper, register it in PASSES, add a corpus entry — no hidden ordering dependencies.

  • New cypher(disable_optimizer=True, disabled_passes=[...]) kwargs. Diagnostic / testing knob: skip every optimizer pass, or skip a specific subset by name. Validated against the registry — typos raise ValueError. Used by the new differential test harness and the bisection script.

  • Fixed push_limit_into_match multi-pattern row drop. A query with a single MATCH containing multiple comma-separated patterns plus WHERE plus LIMIT (e.g. self-joins: MATCH (p)-[:T]->(q), (p)-[:T]->(r) WHERE q <> r RETURN ... LIMIT 5) silently dropped rows. The 0.8.27 fix narrowed the pushdown to single-MATCH but didn’t check single-pattern; the pattern executor’s max_matches hint applied per-pattern and the cartesian cross-product fell short of the requested LIMIT. The pass now also bails when the MATCH has more than one pattern.

  • Fixed fuse_node_scan_top_k empty-result on alias-sorted top-K. Queries of the form MATCH (p:T) RETURN <expr> AS h ORDER BY h LIMIT k silently produced zero rows when the ORDER BY referenced a RETURN alias — the fused executor’s sort-key evaluator only knows graph variables, not RETURN-alias bindings. The pass now bails when the sort expression references any RETURN alias, falling back to the materializing path which handles aliases correctly.

  • Fixed desugar_multi_match_return_aggregate over-grouping bug surfaced by the new differential harness on first run. MATCH (p:Person) MATCH (c:Company) RETURN p.city, count(c) was producing 20 rows of (city, n=5) instead of 4 rows of (city, n=25): the rewrite introduced a WITH p, count(c) (group by source variable) when the user’s RETURN was grouping by p.city. The rewrite now generates WITH p.city AS <internal>, count(c) AS n so GROUP BY matches Cypher’s standard semantic (the set of non-aggregate RETURN expressions).

  • New differential test harness tests/test_cypher_differential.py. Every query in a curated corpus runs twice (optimized vs. optimizer-off) and asserts identical rows. Includes 9 mutation tests that compare the cypher result and the post-mutation graph state (node + edge counts) across the two modes. Surfaced 4 divergences across two probing rounds, all now fixed and tracked as permanent regression tests:

    • desugar_multi_match_return_aggregate over-grouping (fixed, see above)

    • push_limit_into_match multi-pattern row drop (fixed)

    • fuse_node_scan_top_k empty-result on alias-sorted top-K (fixed)

    • mark_fast_var_length_paths per-target vs. per-path semantics (lone KNOWN_DIVERGENT entry; pending design call).

  • New scripts/cypher_pass_bisect.py. Given a query that diverges, runs each pass disabled in isolation and reports which pass’s absence resolves the divergence. Works against .kgl files or tests/conftest.py fixtures.

  • Debug-mode IR invariant checks run after every pass in debug builds. Catches passes that produce empty MATCH patterns, empty RETURN/WITH item lists, or splice clauses after a terminal RETURN. Zero cost in release.

code_tree

  • Manifest discovery: declared-package strategies and mixed-language safety net. read_pyproject was previously the only Python source-root finder, and it only matched the <name>/__init__.py / src/<name>/__init__.py conventions. Several common configurations silently parsed only a slice of the repo:

    • Workspace path collisions: Cargo workspaces with two crates each containing src/lib.rs collapsed to a single File node because every parser stripped rel_path against its per-root walk directory. Paths are now project-root-relative (any two source roots that share a same-named file at matching depth survive as distinct nodes).

    • Explicit [tool.poetry].packages declarations with a custom from directory (e.g. from = "lib") are now respected. Previously these only worked by accident when the project also lacked a conventionally- placed package — adding a stub <name>/__init__.py would silently suppress the lib/ packages.

    • [tool.setuptools].packages = [...] explicit lists, including dotted names resolved against [tool.setuptools].package_dir.

    • [tool.setuptools.packages.find].where = [...] is honoured — each where directory becomes a source root.

    • [tool.hatch.build.targets.wheel].packages = [...] is honoured.

    • [tool.poetry].name is now a valid name source. Pure-poetry pyprojects without a [project] table previously left name as the parent directory, breaking name-keyed package discovery.

    • Mixed-language safety net: when a pyproject finds a Python package next to first-level directories that contain code in a language NOT declared by the manifest (e.g. tooling pyproject + huge src/*.c), those directories are auto-supplemented as source roots labelled auto:<dirname>. The “undeclared language” gate keeps it surgical: sibling .py directories are not pulled in for pure-Python repos.

    The manifest module was also refactored into a list of small named strategy fns (one per declaration shape) instead of one 150-line function — adding a new build backend is now one fn, not a new branch.

[0.8.35] — 2026-05-01

Ingestion

  • add_nodes(nullable_int_downcast=True) recovers integer columns that pandas auto-promoted to float64. Pandas turns nullable int columns into float64 whenever nulls are present, which surfaces in queries as "2.0" instead of 2. The new opt-in flag scans Float64 columns post-ingestion: when every non-null value is integer-valued and within i64 range, the column is downcast to Int64. Default False so existing callers see no change.

code_tree

  • code_tree.build(max_loc_per_file=N) skips oversized files. Files whose newline count exceeds N are recorded as File nodes with skip_reason="too_large" but never sent to the parser. Default None preserves existing behavior. Targets autogenerated multi-thousand-LOC files (dotnet/runtime’s JIT regression tests at 85k+ LOC each) that dominate parse time without contributing structural information. Also threaded through repo_tree(...).

  • Module nodes with purely-numeric names are no longer synthesized. Repos with numeric directory components (dotnet/runtime’s tests/JIT/Regression/Runtime_<bug-id>/... test layout, in particular) used to produce thousands of Module {title="125042"} nodes when the parser fell back to file-path-derived module names. build_modules now skips path segments that are pure ASCII digits while keeping legitimate alphanumeric ancestors and descendants.

Cypher engine

  • Map-typed list-comprehensions now access fields correctly. [x IN collect({h: a.title, k: km}) WHERE x.k = min_km | x.h] used to silently drop every row because Value has no Map variant — the collected items round-tripped through a JSON-encoded Value::String, and x.k returned the entire string, never matching the aggregated min_km. Property access on a map-shaped projected string now parses the map on demand and returns the field. parse_value_token and extract_map_field were factored out of parse_list_value.

  • Spatial functions infer config from conventional property names. intersects() / contains() / centroid() / distance() etc. now accept nodes that store WKT under wkt_geometry / geometry / geom / wkt, or lat/lon under latitude+longitude / lat+lon, even when no SpatialConfig was registered at ingestion. The fallback inference is per-query and never mutates graph.spatial_configs; explicit configs always win. Also: clearer error message when a spatial argument truly can’t be resolved.

  • Cross-MATCH equality joins on non-id properties now run in O(N+M). When a subsequent MATCH joins on a non-canonical property (e.g. WITH a.key AS k MATCH (b) WHERE b.key = k), the executor builds a query-local hash index over the target type once and probes it per outer row, instead of running the full pattern matcher per row. Only fires when the outer row count is at least 64 and no persistent property index already covers the type+property; below that, the existing path runs unchanged. Internal API: new executor::transient_index::TransientEqIndex. A 5,000×5,000 join that previously degraded to ~25M property reads now finishes in tens of milliseconds.

[0.8.34] — 2026-05-01

Code-tree graph quality

  • File DEFINES Function now includes methods. The previous if !is_method filter dropped every C# method (the language has no top-level functions), so an entire codebase of methods looked edge-less when joined through File. Class HAS_METHOD edges still carry the logical hierarchy.

  • Kind-aware target resolution for IMPLEMENTS / EXTENDS. When a bare base-type name matches multiple namespaces, implements now prefers Interface candidates and extends prefers class-like candidates. On dotnet/runtime the Class -[IMPLEMENTS]-> Class noise (mis-typed because of name collisions) dropped from 1,869 to 90 rows, while the correct Class -[IMPLEMENTS]-> Interface rows rose from 447 to 7,696.

  • Auto-reroute extends implements when the target is an Interface. Fixes the C# parser’s “first base is always extends” assumption for class Foo : IDisposable (no base class).

  • using-directive scope as a CALLS resolution tier. Calls like Assert.True now pin to the Assert class actually imported by the caller’s file. On dotnet/runtime, Xunit.Assert.True collapses from four collision-cloned entries (~11 k each, false equals) to a single 11 k entry; IDisposable implementer count goes from 0 to 236, IEnumerable<T> from 0 to 305, IEquatable from 0 to 469.

  • C# get_base_types captures every secondary base. The hardcoded list of accepted node kinds dropped any base type whose grammar kind wasn’t on it (in practice every base after the first), so class Foo : Bar, IDisposable lost the IDisposable edge entirely.

  • C# generic args are stripped from base type names so IEnumerable<int> resolves against the IEnumerable index entry.

  • is_test propagates from File to defined Functions. Previously meta_bool(f, "is_test") returned false for every Function in every language except Rust #[test], so the codebase-level test filter was unusable.

  • build(save_to=...) now persists the full property graph. The build path skipped the prepare_save + enable_columnar steps KnowledgeGraph.save() does, so everything except id/title/ type was stripped from the file. Round-tripped graphs now match in-memory ones.

Performance

  • code_tree.build is ~15% faster on polyglot codebases. Two changes: the orchestrator walks the source tree once and partitions files by language instead of re-walking per parser (was N+1 traversals — 8 walks of ~57k entries on dotnet/runtime); and a byte-level aho-corasick pre-check skips the full-AST comment walk for files that contain no TODO/FIXME/HACK/etc. keywords at all (the vast majority). On dotnet/runtime: 20.3 s → 17.4 s wall-clock, mostly from the C# parse phase (12.8 s → 11.2 s).

  • New per-language and per-phase timings printed under verbose=True.

Fixed

  • code_tree.build no longer SIGBUSes on deeply-nested expressions in source files (e.g. dotnet/runtime’s JIT/Regression/JitBlue/GitHub_10215.cs, a regression test that is literally a chain of thousands of + operators). Tree-sitter is recursive-descent and was overflowing the rayon worker thread stack (~2 MB on macOS); parsers now share a dedicated rayon pool with a 16 MB stack, so pathological-but-valid inputs parse cleanly across all languages.

[0.8.33] — 2026-04-30

Tooling

  • New bench/bench_cohort_cold.py — spawns a fresh Python subprocess per (query, iteration) and runs sudo purge between each so the OS page cache is dropped before the kglite mmap is re-faulted. Settles “is plan X actually faster than plan Y?” questions that warm-cache timings can’t answer.

  • Planner regression test pinning that the user’s exact cohort top-K shape (with explicit WITH p between MATCHes) absorbs top_k after the fold + desugar + fuse pipeline.

[0.8.32] — 2026-04-30

Performance — cohort top-K with PropertyAccess RETURN now absorbs LIMIT

fuse_match_with_aggregate_top_k previously required every RETURN item to be a plain alias of a WITH-projected column. Cohort queries of the form

MATCH (p)-[:P27]->({id: 20})
WITH p
MATCH (p)-[r]-(other)
WHERE NOT (type(r) = 'P50' AND startNode(r) = other)
RETURN p.title, p.description, count(r) AS d
ORDER BY d DESC LIMIT 10

include p.title and p.description (PropertyAccess) in the RETURN, so the absorber bailed and the fused operator emitted every cohort row before LIMIT — paying property-column I/O for ~73K Norwegians on Wikidata even though only 10 survived. The relaxed gate now accepts PropertyAccess on the WITH’s group variable (the executor already preserves node_bindings[group_var] for K-winner rows). Warm-cache runtime on the Norwegians cohort fell from ≈0.9s to ≈0.4s; cold runs that previously exceeded the MCP 20s ceiling now stay under it.

[0.8.31] — 2026-04-30

Performance — fused OPTIONAL MATCH widens to derived aggregates and edge-var counts

Two follow-on fixes after the 0.8.30 release. Both close gaps in fuse_optional_match_aggregate that kept the cohort-style query in the issue from picking up its existing fast path:

  • Edge-variable counts now fuse. The fusion gate’s local-binding set was built from collect_pattern_variables, which only returns node variables. count(r) over an OPTIONAL MATCH edge variable failed the local-to-OPT check and fell back to the materialized per-row expansion. Replaced with a local walk that includes edge variables in the OPTIONAL pattern’s binding set, minus any names already bound by prior MATCH/WITH/UNWIND.

  • Derived total - count(rp) expressions now fuse. Previously the gate accepted only pure count(...) aggregates; arithmetic involving them blocked fusion entirely. The gate now recognizes any expression whose only aggregate is count(...) and the executor substitutes the per-row count into each count(...) sub-tree before evaluating the surrounding arithmetic. Same row cost as the pure-count path.

  • Output columns now reflect the fused operator’s own RETURN/WITH items. Previously the fused operator silently inherited the upstream’s column names, so a downstream consumer reading row["p50_in"] got a KeyError. Visible only when the OPTIONAL MATCH adds new RETURN columns the upstream WITH didn’t have.

startNode(r) / endNode(r) returning the matcher’s anchor side instead of the actual graph endpoints (caught while testing) now look up the edge endpoints via edge_index. This was a pre-existing bug, exposed by Phase 3 actually exercising the predicate.

Wikidata cohort impact (warm cache, 124M-node graph; queries from the issue’s “narrow then enrich” report):

  • fm1 (WHERE NOT (type(r) = 'P50' AND startNode(r) = other)): 656ms → 624ms (~unchanged; the win was already in 0.8.30)

  • fm2 (post-aggregate OPTIONAL MATCH + total - count(rp)): 1290ms → 403ms (~3.2× faster, fully fused)

[0.8.30] — 2026-04-30

Performance — relationship-predicate pushdown (Phase 3)

Pushes WHERE sub-predicates that reference only the edge variable (and the structural peer endpoint of that edge in the pattern) into the matcher’s expansion loop, before per-edge bindings allocate. Selective edge filters that previously forced the materialized 100M+ row path now run during expansion via the disk CSR sorted-edge-type binary search.

  • extract_pushable_rel_predicates in src/graph/languages/cypher/planner/rel_predicate_pushdown.rs recognizes type(r) = 'X' / type(r) IN […], r.<prop> OP <lit> for =/<>/</<=/>/>=, and startNode(r) = peer / endNode(r) = peer against the structural peer in the same pattern. AND/OR/NOT compositions of those leaves push as a unit when the entire subtree is pushable; partial pushdowns over OR / NOT correctly leave the predicate alone.

  • EdgePattern::edge_filter carries the compiled RelEdgePredicate to the matcher. The hot loop in pattern_matching/matcher.rs evaluates it after the existing connection-type check and before the property check — single branch-predicted if let Some for the no-filter path.

  • Fused count phase honors the filter. The fused FusedMatch{Return,With}Aggregate operators run their own count loop via try_count_simple_pattern / try_count_distinct_peers; both now apply the inline filter during edge iteration so fused queries with a pushed predicate produce correct results without falling back to the materialized path. try_fast_with_aggregate_via_histogram bails when a filter is set (the histogram counts every edge of a type by definition).

startNode(r) / endNode(r) previously returned the EdgeBinding’s pattern-anchor side instead of the actual graph source/target — silently wrong when the planner anchored on the right-hand pattern endpoint and walked incoming edges. Fixed by looking up the edge endpoints via edge_index.

Wikidata cohort impact (warm cache, 124M-node graph):

  • baseline MATCH (p:Q20-citizen)-[r]-() aggregate: 224ms

  • WHERE type(r) = 'P19' (selective): 47s → 667ms (~70× faster)

  • WHERE type(r) IN ['P19', 'P569', 'P570']: 47s → 670ms

  • WHERE NOT (type(r) = 'P50' AND startNode(r) = other): 60s → 656ms (~90× faster, correct result)

Performance — streaming aggregate + heap top-K (Phase 1)

First slice of a multi-phase rework that lifts streaming primitives into the Cypher generic execution path. Falling out of the fused fast path used to cost ~1000× on cohort-scale queries; this slice closes the gap on shapes that combine WITH(group, agg) with ORDER BY LIMIT k decorations the existing fused operators don’t cover.

  • RowStream operator pipeline in src/graph/languages/cypher/executor/stream/. The driver in executor::execute tries to absorb a contiguous clause run (WITH/RETURN(group, agg) [→ ORDER BY LIMIT]) into a single streaming pipeline before the materialized executor sees the clauses. On no match the driver falls through with the input ResultSet unchanged.

  • StreamingAggregate: hash aggregate that builds per-group state inline as upstream rows arrive — same I/O profile as the materialized path (NodeIndex surrogate keys, deferred property reads, re-bucket-by-resolved-value at finalization). Supports count(*), count[(DISTINCT) expr], sum/avg/min/max[(DISTINCT) expr]. Other aggregates (collect, std, percentiles, arithmetic on aggregates) bail to the materialized executor unchanged.

  • HeapTopK: BinaryHeap of capacity K replaces the full sort + truncate path for streaming pipelines that end in ORDER BY <expr> [ASC|DESC] LIMIT k. O(n log k) instead of O(n log n).

  • streaming kwarg on kg.cypher (default True). Pass streaming=False to force the materialized executor — useful for parity debugging.

Phases 2-4 will widen recognized shapes (multi-MATCH, OPTIONAL MATCH streaming, post-aggregate WHERE), inline relationship predicates into pattern expansion, and retire the shape-matched fused operators once benchmarks confirm streaming parity.

[0.8.29] — 2026-04-30

Performance — cohort + multi-MATCH planner improvements

Three planner passes turn cohort top-K and multi-MATCH joins on Wikidata-scale graphs from “borderline timing out” into “sub-second warm.” Validated end-to-end on the 124M-node / 861M-edge Wikidata graph; no regressions on point lookups, 1-hop / 2-hop, aggregates, or load.

  • reorder_match_clauses — orders consecutive id-anchored MATCH clauses by edge-type total-count cost. Drives from the rarer side first.

    MATCH (p)-[:P31]->({id:5}) MATCH (p)-[:P27]->({id:183}) RETURN p.title LIMIT 20
    

    458s cold / 497s warm → 49s cold / 0.5s warm.

  • fold_pass_through_with — strips a WITH x [, y, ...] clause that’s a pure projection (no DISTINCT/aggregate/WHERE/ORDER BY) when every variable referenced downstream is in the projection list. Lets later fusion passes see a contiguous Match-Match span when the user wrote Match WITH p Match .

  • desugar_multi_match_return_aggregate — rewrites Match-Match-Return(group, aggregate) into Match-Match-With(group_var, aggregate)-Return(project). Lets the existing aggregate fusion fire on the natural “RETURN with aggregate” form.

Together, the two simplification passes turn cohort top-K queries from per-row materialization into the streaming aggregate path:

  • Norwegians outgoing-degree top 10: 34s → 0.07s (490×)

  • Norwegians total-degree top 10: 38s → 1.35s (28×)

The reorder pass is gated to avoid in-memory regressions (edge-type-counts cache must be populated, id-anchors required, shared variable required). The simplifications are pure AST rewrites — no executor changes, O(1) planner overhead.

[0.8.28] — 2026-04-30

Performance — slice-built graph load (round 2)

Continuation of the disk-graph load optimisation that landed earlier in this version. Three further changes drop slice-built Wikidata graph loads from seconds to ~100 ms:

  • metadata.json heavy fields → binary sidecars. The two HashMap-of-HashMap fields (node_type_metadata, connection_type_metadata) move into dedicated node_type_metadata.bin.zst and connection_type_metadata.bin.zst files with a hand-rolled length-prefixed format. On the 1B-triple Wikidata slice, metadata.json shrinks from 5.0 MB to 23 KB, and the parse drops from ~1 s to ~10 ms. (The custom Serialize/ Deserialize impls on ConnectionTypeInfo make bincode round-tripping unsafe — hence the hand-rolled binary.)

  • type_connectivity_cache is lazy. Skipped both the cartesian-product derive in apply_to (clones tens of millions of String triples on slice-built graphs) and the eager type_connectivity.bin.zst read at load. Existing read sites in introspection/describe.rs already fall through to a bounded edge scan when the cache is missing; first describe() triggers a compute_type_connectivity populate. Set KGLITE_EAGER_TYPE_CONNECTIVITY=1 to opt back into eager loading for workloads that immediately call describe().

  • apply_to_with(graph, derive_type_connectivity: bool). Splits the implicit derive out of the metadata-application path so the caller can opt out. The original apply_to is now a thin wrapper that defaults to true for the in-memory .kgl load path (which has no separate sidecar).

Round-2 measurements (warm load, M2 macOS, external SSD):

graph

nodes

round-1

round-2

total speedup vs original

graph_500.0

6 M

770 ms

62 ms

(built fresh)

graph_1000.0

16 M

4.89 s

113 ms

43×

main wikidata

124 M

178 ms

179 ms

51× vs 9.08 s baseline

The remaining 100-180 ms is dominated by column_stores_load (72-124 ms) — a per-type ColumnStore wrapper construction that’s already mmap-backed.

Fixed — seg_000 corruption on save of legacy disk graphs

Pre-existing bug exposed by the migration round-trip benchmark: re-saving a disk graph whose disk_graph_meta.json carries sealed_nodes_bound: 0 (the serde default for pre-phase-8 graphs) AND has a non-empty seg_manifest.json would call seal_to_new_segment with tail_lo=0, tail_hi=node_count. That writes a fresh empty seg_001 AND truncates seg_000/out_offsets.bin and seg_000/in_offsets.bin to one entry via reconcile_seg0_csr — the on-disk CSR loses every edge offset, and the graph reloads with zero traversable edges (the edge data files survive, but without offsets nothing is reachable).

DiskGraph::load_from_dir now bumps sealed_nodes_bound to node_count when it detects the legacy zero with a populated segment manifest. Fresh phase-8+ graphs persist the correct watermark, so the bump is a no-op for them.

Performance — disk-graph load

kglite.load(path) on the 124M-node Wikidata graph drops from ~9.0s to ~5.5s warm-cache today, and to ~1s once the graph is re-saved with this version. Three changes:

  • prefetch_hot_regions no longer runs by default. It called madvise(MADV_WILLNEED) on the 1.9 GB out_offsets + in_offsets arrays. On macOS that syscall synchronously schedules readahead and blocks even on warm pages — costing ~2.7s of every load on Wikidata-scale graphs. The kernel pages in offsets on first query anyway, so the upfront cost was a tax with no payoff for typical anchored-MATCH workloads. Set KGLITE_PREFETCH=1 to opt in for first-query latency-sensitive use cases.

  • id_indices is now mmap-resident. New raw id_indices.bin layout: header + sorted-by-type-key directory + per-type sorted u32 keys / u32 NodeIndex arrays. Lookups are O(log N) binary search on a cache-friendly contiguous slice instead of O(1) HashMap probe with a cache miss; cost in practice is parity (~50-100 ns either way for 13M-entry types). Eliminates the 124M HashMap::insert rebuild that cost ~5.3s on Wikidata. New struct IdIndexStore in storage/disk/id_index.rs with overlay for post-load mutations.

  • type_indices is now mmap-resident. New raw type_indices.bin layout: header + directory + contiguous [u32] slices per type. Reads return a TypeNodesRef view that yields NodeIndex either directly from the overlay Vec or by reinterpreting the mmap’d u32 slice. Eliminates the 124M Vec::push rebuild that cost ~890ms on Wikidata. New struct TypeIndexStore in storage/disk/type_index.rs with overlay for post-load mutations (delete paths promote to overlay on first mutation).

  • Sub-stage instrumentation for DiskGraph::load_from_dir. Set KGLITE_LOAD_TIMING=1 and stages emit [TIMING] dg.<name> dur_ms=N lines to stderr (segment_csr, edge_properties, overflow_edges, segment_manifest). Useful for measuring the next round of load optimizations.

Backward compatibility. Loaders try the new id_indices.bin / type_indices.bin first, fall back to the old .bin.zst legacy formats, then to a node_slots scan. Existing graphs continue to load — slower until re-saved. Re-save once with g.save(path) to migrate.

RSS reduction. Wikidata peak RSS post-load drops from 3.6 GB to ~500 MB (the Integer-variant id_indices + type_indices were the bulk of heap). General-variant id_indices entries still materialize on first access.

[0.8.27] — 2026-04-29

Changed

  • Default Cypher query timeout is now 180_000 ms (3 min) for every storage mode (memory, mapped, disk). Previously memory had no default deadline, mapped was 60s and disk was 10s. The old per-mode defaults left memory queries unbounded and disk’s 10s ceiling tripped legitimate cold queries on large graphs. Override per-call with timeout_ms=N (or 0 to disable), or globally via set_default_timeout(ms).

Fixed

  • Cypher property-anchored single-MATCH fusion returned empty results. MATCH (m {id: X})<-[:R]-(p) RETURN m.title, count(p) (and the directed / DISTINCT / undirected variants) returned zero rows on any graph with matching data. try_count_simple_pattern bailed with Ok(None) when the bound node carried property filters; the fused executor’s count_for_node closures .unwrap_or(0) that None into a zero count, which the row-skip guard then dropped. The bail-out has been removed — the bound NodeIndex already satisfies its property filter by virtue of being selected upstream, so re-checking is unnecessary. Also fixed an in-memory-backend miss in try_count_distinct_peers (the new helper added in the count(DISTINCT) work this release): edges_directed_filtered is a hint on the in-memory backend and returns every edge regardless of connection type, so the function now post-filters by connection_type.

  • Cypher fusion machinery now accepts count(DISTINCT v) on a node variable. Previously rejected at fusion time, forcing all distinct-count queries — the canonical “top-N by relationship count” Cypher shape — through the materializing executor (full intermediate cross-product, then group-by, then sort). The planner now propagates a distinct_count flag into FusedMatchReturnAggregate and FusedMatchWithAggregate; the executor uses a per-group HashSet<NodeIndex> of peers instead of an edge counter, naturally collapsing multi-edges. Edge-centric fast paths (which count edges, not distinct peers) are bypassed in distinct mode. Fusion is gated on the group node being type- or property-constrained, since the fused per-node enumeration only beats the materializing path when the group set is small (the materializing path’s single sequential edge scan wins on unconstrained 124M-node groups). Documented limitation: 3+ MATCH queries still don’t fuse and continue to use the materializing path.

  • Cypher planner’s selectivity estimator now considers variables bound by earlier clauses. Previously MATCH (p:Person) MATCH (p)-[:KNOWS]->(c:Type) treated (p) as statically unconstrained — worst-possible selectivity — and reversed the second pattern to start scanning all :Type nodes (millions on large graphs) instead of expanding from the pre-bound p. The estimator now walks clauses in order, accumulates node variables introduced by each MATCH/OPTIONAL MATCH, and treats already-bound variables as selectivity 1 (effectively-anchored). On a 124M-node Wikidata graph, a 3-MATCH query with two {id: …} anchors now completes in ~1s (was 20s+ timeout).

  • Cypher LIMIT pushdown was unsafe for multi-MATCH queries with WHERE on a late-bound variable. MATCH a MATCH b MATCH c WHERE c.id = X RETURN ... LIMIT N was rewritten to push limit_hint = N into the last MATCH clause. The per-row pattern executor’s max_matches = remaining then interacted incorrectly with the outer row loop, causing fewer matching rows than expected to surface (e.g. LIMIT 10 returning 8, LIMIT 5 returning 3 — or zero rows on Wikidata-scale graphs where the WHERE bucket happened to be at the tail of the row stream). The planner now only pushes LIMIT into MATCH for queries with a single MATCH/OPTIONAL MATCH clause; multi-MATCH queries retain LIMIT as a separate clause and apply it after the full pattern matching completes. Reproduced and confirmed against the user’s 124M-node Wikidata disk graph (Issue 1 of the 2026-04-29 bug report).

  • Cypher planner now reverses undirected and variable-length patterns by selectivity. MATCH (other)-[r]-(p {title: 'X'}) previously left other (no constraints) as the start node, causing a full-graph scan with edge expansion from every node. The over-conservative bail-out on EdgeDirection::Both and var_length has been removed — Both reverses to Both (no semantic change) and var-length reversal is symmetric for patterns without a path assignment (path-bound patterns are already protected by a separate guard). On a 124M-node Wikidata graph this turns a multi-minute scan into a sub-second anchored lookup.

[0.8.26] — 2026-04-28

Fixed

  • code_tree Rust parser missed calls inside macro invocations. Calls inside format!, vec!, json!, Err(format!(…)), custom derive macros, etc. were silently dropped because tree-sitter-rust represents them as identifier + token_tree siblings rather than call_expression nodes. The walker now dives into macro_invocation token-trees and reconstructs synthetic call sites. Resolves the dominant source of false-positive orphan-function reports on Rust codebases.

  • code_tree Rust parser dropped turbofish call expressions. Calls of the form path::with::<T>(...) are wrapped in a generic_function AST node that the previous match arm didn’t handle, so e.g. reconcile_seg0_csr::<DiskNodeSlot>(arg) produced no CALLS edge. The type arguments are now stripped and the inner identifier/scoped path is recursively recorded.

  • code_tree Rust parser misresolved Self::method(...) calls. The "Self" segment was emitted as an explicit receiver hint, which matched no function’s owner type and broke disambiguation for non-unique method names. The parser now strips the Self:: prefix so the resolver’s implicit caller-owner hint kicks in, yielding the same behaviour as bare self.method() calls inside an impl block.

  • code_tree IMPLEMENTS edge schema excluded Enum -> Trait. A Rust enum implementing a trait (e.g. impl Clone for GraphBackend) yielded no IMPLEMENTS edge because the IMPLEMENTS routing only mapped Class / Struct sources. Enum is now a recognised source label.

Removed

  • macOS x86_64 wheels. x86_64-apple-darwin is no longer built or published to PyPI. Apple Silicon (aarch64-apple-darwin) remains. Intel Mac users on existing installs are unaffected; new installs will need to build from source.

[0.8.25] — 2026-04-27

Fixed

  • code_tree.build silently parsed only tests/ for repos with a tooling-only pyproject.toml. Manifests that declared no primary source roots (e.g. llama.cpp’s pyproject.toml for poetry-managed scripts, with no <name>/__init__.py package and no maturin) yielded source_roots = [] and test_roots = ["tests"]. The builder then parsed only tests/, set parsed_any = true, and skipped the whole-repo fallback — silently producing an undersized graph (e.g. 58 files instead of thousands). When a manifest declares zero source roots, the builder now logs the situation in verbose mode and falls through to the whole-repo scan instead of trusting the test-only roots.

[0.8.24] — 2026-04-27

C++ parser robustness round driven by analysing nlohmann/json (24% → 12% signature fallback) and llama.cpp’s src/ (32% → 5.9% fallback, function count 742 → 1,263 because previously-unknown-named functions can now be called targets, CALLS edges 352 → 2,971).

Fixed

  • C-style struct T * / enum T / union T parameter typesstruct_specifier, enum_specifier, union_specifier, class_specifier, sized_type_specifier were missing from C++ extract_parameters’s type-recognition list. C-heavy headers like llama.cpp’s void llama_grammar_free(struct llama_grammar * grammar) were losing the parameter type entirely (type_annotation=None).

  • Out-of-class C++ method namesbool Foo::bar() const produces a qualified_identifier (Foo::bar) child in tree-sitter-cpp, which the existing get_name walk missed. Now drills into qualified_identifier and returns the trailing segment (bar).

  • Reference-return functionsT & foo() wraps the real function_declarator in a reference_declarator. parse_function now unwraps it just like it already did for pointer_declarator. Functions returning references no longer show as name="unknown".

  • C++ template-typed methods, destructors, qualifier-stripped return types, and macro-decorated constructors. Four targeted parser fixes that drop nlohmann/json’s signature-fallback rate from 24% → 12% and capture 39 previously-missed template methods.

    • template_type and qualified_identifier now in TYPE_NODES so generic return types like iteration_proxy<int> and std::vector<T> are captured.

    • type_qualifier, storage_class_specifier, virtual_specifier skipped in get_return_type — fixes constexpr int foo() returning “constexpr” instead of “int”.

    • destructor_name recognized in get_name~Widget now returns "~Widget" instead of "unknown".

    • In-class field_declaration items containing function_declarator are routed to parse_function (template-typed methods were being treated as fields). New find_buried_function_declarator walker unwraps parenthesized_declarator wrappers that tree-sitter-cpp emits around macro-decorated constructors (e.g. JSON_HEDLEY_NON_NULL(3) Foo(int x)) so the real function_declarator and its parameters are captured.

[0.8.23] — 2026-04-27

C++ macro-aware parsing, Go method return-type fix, and receiver attribution across Go and Rust. Cross-library validation: testify signature-fallback 49% → 0%, KGLite self-graph USES_TYPE edges 2078 → 3197 (+54%), spdlog macro names eliminated from top return-type list.

Added

  • ParameterKind::Receiver — Go method receivers ((c *Call)) and Rust &self/&mut self/self are now captured as structured parameters with kind: "receiver", distinct from positional/variadic/kw_variadic. Excluded from param_count (receivers aren’t user-supplied arguments). Cypher consumers can filter via parameters JSON column.

  • USES_TYPE position="receiver" — receivers contribute USES_TYPE edges with their own position label. A method (c *Call) Once() *Call (receiver + return) collapses to position="both" consistent with existing aggregation. On testify, this drops position="signature" fallback from 49% → 0% and raises total USES_TYPE edges 251 → 365 (+45%). On KGLite self-graph, USES_TYPE edges go 2078 → 3197 (+54%) — Rust &self methods now surface their owner type as a receiver USES_TYPE edge.

Fixed

  • C++ parser ignores macro decorators (SPDLOG_INLINE, FMT_API, FMT_BEGIN_NAMESPACE, Q_INVOKABLE, etc.). Without this, tree-sitter-cpp parses SPDLOG_INLINE void foo() so that SPDLOG_INLINE looks like a type and foo becomes the return type — producing name="unknown" and return_type="SPDLOG_INLINE". Heuristic in parsers/shared.rs::looks_like_macro_decorator matches all-caps identifiers (length ≥ 2, optional underscores/digits) and is applied in cpp.rs::get_return_type, get_name, and parameter extraction. get_return_type also recovers from tree-sitter ERROR wrappers around primitive type keywords (void, int, bool, etc.) — common when a macro decorator has confused the parser. On spdlog, macro names are eliminated from the top return-type frequency list.

[0.8.22] — 2026-04-27

A code-graph quality round driven by analysing KGLite’s own self-graph and closing every concrete gap that surfaced. Five new node/edge primitives, seven new properties on existing nodes, and one small dead-code cleanup.

Added

  • BINDS edges — Python wrapper to Rust pymethod. Closes the cross-language gap where kglite.KnowledgeGraph.add_nodes (the Python class method) and crate::graph::pyapi::*::KnowledgeGraph::add_nodes (the Rust #[pymethods] impl) lived as disconnected Function nodes. The resolver indexes Rust functions with is_pymethod = true by (parent_struct_short_name, method_name) and emits Function -[BINDS]-> Function for each Python method that finds a unique match. Cypher: MATCH (py)-[:BINDS]->(rs) -[:CALLS*]->(impl) traces a request from the Python entry point to deep Rust impl. On the KGLite codebase: ~184 BINDS edges; closes the false-positive dead-code finding for load_ntriples and other pyapi-exposed functions.

  • Promoted metadata flags as typed Function/Class properties. Eight booleans (is_pymethod, is_pymodule, is_ffi, is_static, is_abstract, is_property, is_classmethod) plus the ffi_kind string are now Function-node columns; is_pyclass is a Class/Struct column. Replaces f.metadata.get("is_pymethod") == true JSON-parsing gymnastics with MATCH (f:Function {is_pymethod: true}) direct filters.

  • USES_TYPE edges carry a position property (parameter | return | both | signature). Distinguishes consumers from producers — a function that takes Widget as a parameter and a function that returns Widget no longer collapse to the same edge shape. Aggregated per (function, type) so a single transformation fn f(w: Widget) -> Widget emits one edge with position: "both". Cypher: WHERE r.position IN ['parameter','both'] to find consumers; IN ['return','both'] for producers.

  • Module HAS_FILE File edges — closes the natural top-down walk from Module → File → Function. Was string-prefix gymnastics on qualified_name; now MATCH (m:Module)-[:HAS_SUBMODULE*0..]->(:Module)-[:HAS_FILE]->(f:File) -[:DEFINES]->(fn:Function) returns “what’s in this module” in one query. Edge name avoids CONTAINS (a reserved Cypher keyword for substring matching).

  • Procedure nodes — annotation-driven, language-agnostic. Functions whose docstring/leading comment contains @procedure: NAME (or @cypher_procedure: NAME) at the start of a line synthesize a Procedure node with an IMPLEMENTED_BY edge to the function. A single function can carry multiple annotations to register under aliases (e.g. both betweenness and betweenness_centrality dispatching to the same impl). Generic mechanism for surfacing project-specific registries (Cypher CALL procedures, RPC method catalogs, command-bus dispatchers) as first-class graph entities. Anchored to line start so prose mentions in docs/tests don’t false-positive.

  • Annotated all 22 KGLite Cypher CALL procedures in src/graph/algorithms/graph_algorithms.rs, src/graph/languages/cypher/executor/rule_procedures.rs, and executor/call_clause.rs::execute_call_cluster. Activates the Procedure node mechanism on the KGLite self-graph: MATCH (p:Procedure {name: 'pagerank'}) -[:IMPLEMENTED_BY]->(f:Function) RETURN f.qualified_name resolves a Cypher procedure name to its Rust impl in one query. 27 Procedure nodes (including aliases) → 22 implementing functions.

  • Function complexity countersbranch_count, param_count, max_nesting, is_recursive now populate on every Function node produced by kglite.code_tree.build(...). Computed from the tree-sitter AST in the same walk that gathers CALLS edges, so there’s no extra parse pass. Per-language branch tables in parsers/shared.rs cover if/for/while/case/catch/ternary and short-circuit &&/|| forms (cyclomatic-style). Enables direct Cypher queries for high-complexity hotspots:

    MATCH (f:Function)
    WHERE f.branch_count > 30 AND NOT EXISTS { ()-[:CALLS]->(f) }
    RETURN f.qualified_name, f.branch_count, f.max_nesting
    ORDER BY f.branch_count DESC
    
  • Generated and minified file skipping during ingestion. The builder now content-sniffs each source file’s first 2 KiB before dispatching to the per-language parser. Files matching codegen markers (auto-generated, DO NOT EDIT, code generated by, <auto-generated>, @generated) are skipped, as are minified bundles (one extreme line, or average line width above 500 chars across the first 50 lines). Skipped files emit only a File node with skip_reason: "generated" or skip_reason: "minified" — no Function/Class/Constant nodes — so phantom CALLS edges from protobuf stubs and webpack bundles no longer pollute the graph.

    MATCH (f:File) WHERE f.skip_reason IS NOT NULL
    RETURN f.path, f.skip_reason
    
  • Structured parameters on Function nodes — JSON-serialised list of {name, type_annotation, default, kind} per declared parameter, with kind {positional, variadic, kw_variadic}. Implicit receivers (self/cls/&self/&mut self) are excluded. Promoting parameters out of the signature string also extends USES_TYPE resolution: parameter type annotations are now scanned alongside the signature and return type, so a function that takes a Widget argument but doesn’t return one now emits the expected Function -[USES_TYPE]-> Widget edge.

[0.8.21] — 2026-04-27

Code-analysis tooling round. Closes seven issues filed against KGLite’s own MCP server after a self-analysis session surfaced them — all visible to users running kglite.code_tree.build(...) or g.cypher("CALL ...") against a Rust codebase.

Added

  • REFERENCES_FN edge type — Function → Function for bare or scoped identifiers passed as arguments to higher-order calls (iter.and_then(some_fn), Option::map(my_helper)). Distinct from CALLS because the referenced function isn’t necessarily invoked at the reference site. Dead-code analysis can union the two:

    MATCH (f:Function)
    WHERE NOT EXISTS { ()-[:CALLS]->(f) }
      AND NOT EXISTS { ()-[:REFERENCES_FN]->(f) }
    RETURN f.qualified_name
    
  • REFERENCES edge type — Function → Constant for bare or scoped identifiers in function bodies that resolve to a known constant. The Rust parser uses SCREAMING_SNAKE_CASE as the parse-time filter, so local variables don’t pollute the edge set. Enables detecting unreferenced constants directly from the graph rather than via ripgrep.

  • orphan_node accepts link_type and direction parameters. The default behaviour (zero edges in any direction) is unchanged; the new params let queries express “no inbound matching edge of a specific connection type” — the natural shape for “functions never called”, “files never imported”, etc.:

    CALL orphan_node({type: 'Function', link_type: 'CALLS', direction: 'in'})
    YIELD node RETURN node.qualified_name
    
  • EXISTS { MATCH ... MATCH ... [WHERE ...] } multi-clause subqueries. The bare-pattern form already worked; the full subquery form with multiple MATCH clauses (sharing variables) and a WHERE predicate evaluated against the merged bindings now parses and executes. Multi-hop existence checks no longer have to be rewritten as MATCH ... WITH collect(...) AS xs ... AND NOT y IN xs.

  • Project.crate_type column captured from [lib] crate-type in Cargo.toml. Lets downstream queries distinguish a regular lib crate (where pub fn is a real export) from a cdylib PyO3 crate (where only #[pyfunction] / #[pymethods] matter).

  • Function.is_test column surfaced as a queryable property on Function nodes (previously only stored in metadata).

Fixed

  • CALLS edges now include calls inside closure bodies. closure_expression was on the parser’s NESTED_SCOPES skip-list, so .map(|x| foo(x)) / .and_then(|x| bar(x)) produced zero CALLS edges to the inner function. Closures are expressions in Rust, not items — calls inside them belong to the enclosing function semantically.

  • self.method() receiver-type disambiguation. When the same method name exists on multiple structs, a bare self.method() call inside a method of Foo now narrows to Foo::method ahead of Bar::method, even when both are candidates and live in different files. Uses the caller’s owner short name as an implicit receiver hint when no explicit one is present.

  • is_test propagates into inline #[cfg(test)] mod tests blocks. Previously only #[test] / #[bench] annotated functions were flagged; helpers inside the test mod weren’t, inflating every dead-code query against a Rust codebase. Files literally named tests.rs are also flagged at the file level.

  • CALL rule procedures list accepted parameters in error messages. Missing-required-parameter errors now show the full schema (required + optional names), so first-time use of a procedure doesn’t cost three error rounds before guessing the parameter name.

Removed

  • ARCHITECTURE.md. A refactor-time artifact from the 0.8.0 storage refactor; framing was past-tense and the parity test that validated its file-path references was removed alongside it. The three other parity gates (god-file cap, unsafe-SAFETY comments, mod.rs purity) are evergreen and stay.

  • Dead disk-build infrastructureblock_pool.rs, block_column.rs, memory/build_column_store.rs, AsyncPropertyLogWriter, plus a sweep of unused methods, fields, and enum variants. v3 disk pipeline replaced these; ~3000 lines net.

  • Legacy benchmark suitestest_nx_comparison.py (NetworkX comparison, required scipy that wasn’t in the venv) and test_performance.py (used the old result["stats"][...] subscript API, every Cypher-mutation test failed). Superseded by test_bench_core.py and test_bench_memory.py.

The “completeness round” — six phases that round out the Cypher and Fluent surface so no primitive a user would reasonably expect is missing. Every domain (legal, code, sodir, Wikidata) gets value from each addition; none are domain-specific.

Added

  • Cypher INTERSECT / EXCEPT (Phase 6 of the completeness round). Cypher now exposes the standard set operators:

    MATCH (n:Person) WHERE n.city = 'Oslo' RETURN n.name AS name
    INTERSECT
    MATCH (n:Person) WHERE n.age > 30 RETURN n.name AS name
    
    MATCH (n:Person) WHERE n.city = 'Oslo' RETURN n.name AS name
    EXCEPT
    MATCH (n:Person) WHERE n.age > 30 RETURN n.name AS name
    

    INTERSECT keeps rows present in both sides; EXCEPT keeps rows in left but not in right. Both always dedupe, matching SQL/openCypher conventions. Internals: new SetOpKind enum on UnionClause (the same Clause::Union variant carries all three operators). The executor dispatches on kind: UNION uses the existing concat-and- dedup path; INTERSECT/EXCEPT pre-build a row-hash set from the right-side result and filter the left.

    Brings Cypher in line with the fluent-API set ops (union, intersection, difference, symmetric_difference).

  • Geospatial primitives (Phase 5 of the completeness round). Round out the spatial surface with the standard GIS toolkit operations.

    Cypher scalar functions on WKT/node geometries:

    • geom_buffer(geom, meters) — planar buffer.

    • geom_convex_hull(geoms) — variadic or list arg.

    • geom_union(g1, g2) / geom_intersection(g1, g2) / geom_difference(g1, g2) — boolean ops.

    • geom_is_valid(geom) — OGC validity.

    • geom_length(geom) — geodesic length for LineStrings; perimeter for polygons (sum of rings); 0 for points.

    Cypher CALL procedure:

    • kg_knn({lat, lon, target_type, k}) YIELD node, distance_mk nearest nodes of a target type to a coordinate (geodesic; location-first, falls back to geometry centroid).

    CALL kg_knn({lat: 60.4, lon: 5.3, target_type: 'City', k: 5})
    YIELD node, distance_m
    RETURN node.title, round(distance_m / 1000.0, 1) AS km
    

    Backed by geo = "0.33" (Buffer, BooleanOps, ConvexHull, Validation, LengthMeasurable traits). New helpers in src/graph/features/spatial.rs; new geom_arg resolver in executor/expression.rs accepts WKT strings, Points, and spatial-configured node/property variables.

  • Weighted shortest path (Phase 4 of the completeness round). graph.shortest_path() and graph.shortest_path_length() now accept an optional weight_property parameter. When set, the search switches from BFS (hop count) to Dijkstra (sum of edge weights). Edges missing the property fall back to weight 1.0 (matching Louvain’s existing weighted-adjacency convention); negative weights cause the path to be reported as missing.

    result = graph.shortest_path(
        "Stop", "A", "Stop", "Z",
        weight_property="cost",
    )
    # {'path': [...], 'connections': [...], 'length': 3, 'weight': 4.7}
    
    graph.shortest_path_length(
        "Stop", "A", "Stop", "Z",
        weight_property="cost",
    )  # → 4.7 (float; int when unweighted)
    

    Internals: new shortest_path_weighted() and shortest_path_cost_weighted() in algorithms/graph_algorithms.rs, Dijkstra with a BinaryHeap<State> keyed on (distance, node_idx). The existing BFS path remains the default — no overhead for unweighted callers.

  • Structural validators v2 — rule packs extension (Phase 3 of the completeness round). Seven new CALL procedures complete the rule-pack family from 0.8.19 by covering n-ary and declarative checks:

    • inverse_violation({rel_a, rel_b}) YIELD a, b — declared-inverse relations not symmetric (e.g. parent_of without matching child_of).

    • transitivity_violation({rel}) YIELD a, b, c(a)-[rel]->(b) -[rel]->(c) chains where the direct (a)-[rel]->(c) is absent. Generalizes the OCTF subclass-fold audit pattern.

    • cardinality_violation({type, edge[, min, max]}) YIELD node, count — declarative cardinality. Setting max:1 catches functional- property violations; min:1 catches missing-required-edge.

    • type_domain_violation({edge, expected_source}) YIELD source, target

    • type_range_violation({edge, expected_target}) YIELD source, target — schema integrity checks on edge endpoints.

    • parallel_edges({edge}) YIELD a, b, count — pairs connected by more than one edge of the same type (almost always an ETL bug).

    • null_property({type, property}) YIELD node — property side of missing_required_edge.

    All seven follow the existing rule-procedure pattern and surface via CALL list_procedures() and describe(cypher=True).

  • Lexical text predicates (Phase 2 of the completeness round). Six string-similarity primitives now expressible in Cypher without dropping to Python:

    • text_edit_distance(a, b) — Levenshtein, UTF-8 aware (uses minimum-row DP for O(min(n,m)) memory).

    • text_normalize(s) — lowercase, drop punctuation, collapse whitespace. The thing every fuzzy-match pipeline reaches for first.

    • text_jaccard(a, b [, sep]) — token-set Jaccard, default whitespace separator.

    • text_ngrams(s, n) — character n-grams as a list.

    • text_contains_any(s, needles) / text_starts_with_any(s, prefixes) — variadic or list-argument forms; short-circuit on first match.

    MATCH (a:Person), (b:Person) WHERE a.id < b.id
    WITH a, b, text_edit_distance(
        text_normalize(a.title), text_normalize(b.title)
    ) AS d
    WHERE d <= 2 RETURN a.title, b.title, d
    
  • Expression-engine fundamentals (Phase 1 of the completeness round). The Cypher engine gains the standard scalar/aggregate/list-fold primitives that were missing:

    • properties(n) / properties(r) — full property map of a node or relationship (returns a JSON-formatted map; works alongside keys()).

    • start_node(r) / end_node(r) — endpoint access on a bound edge variable. start_node(r).name works via the existing dotted property accessor.

    • reduce(acc = init, x IN list | body) — list fold with accumulator. New Expression::Reduce AST variant; mirrors openCypher.

    • percentile_cont(expr, p) — continuous percentile via linear interpolation; p [0,1].

    • percentile_disc(expr, p) — discrete percentile via nearest rank.

    • median(expr) — sugar for percentile_cont(expr, 0.5).

    • variance(expr) / var_samp(expr) — sample variance, n-1 denominator (matching the existing std convention).

    MATCH (n:Person)
    RETURN median(n.age), percentile_cont(n.age, 0.9), variance(n.age)
    
    MATCH (n:Person) WITH collect(n.age) AS ages
    RETURN reduce(s = 0, x IN ages | s + x) AS total
    

Changed

  • Example MCP server simplified to two tools. examples/mcp_server.py now exposes only graph_overview and cypher_query. The convenience tools (search, find_entity, read_source, entity_context, bug_report) are removed — every operation is reachable from Cypher via MATCH (n) WHERE n.title = $text etc., and the docstring shows the equivalent patterns. Same simplification philosophy as the 0.8.19 rule-procedure refactor: lean on the Cypher surface.

[0.8.19] — 2026-04-26

Changed

  • Rule packs rebuilt as native Cypher CALL procedures. The Python-layer kglite.rules package (g.rules.run(...), RuleReport, YAML packs, ~1,200 lines) is removed; six structural-validator procedures live inside the Cypher engine alongside pagerank / connected_components:

    CALL orphan_node({type: 'Wellbore'}) YIELD node RETURN node
    CALL missing_required_edge({type: 'Wellbore', edge: 'IN_LICENCE'}) YIELD node ...
    CALL missing_inbound_edge({type: 'Discovery', edge: 'IN_DISCOVERY'}) YIELD node ...
    CALL self_loop({type: 'Person', edge: 'KNOWS'}) YIELD node ...
    CALL cycle_2step({type: 'Person', edge: 'KNOWS'}) YIELD node_a, node_b ...
    CALL duplicate_title({type: 'Prospect'}) YIELD node ...
    

    Direct graph iteration in Rust replaces the YAML→Cypher→parse round trip — single rule on sodir (564k nodes) runs in <2 ms vs. ~5 ms for the legacy Python pack runner. Composability with surrounding Cypher (WHERE / ORDER BY / aggregation) collapses the previous two-step rules_run + cypher_query flow into a single pass.

    Direction validation, anchored type-by-type iteration, and the DirectionMismatch error survive — ported to Rust. Same agent protection without the parallel API.

    Discovery surface: rule procedures appear in describe(cypher=True) topic list, in the <rules hint="..."/> extension hint of describe(), and in CALL list_procedures() YIELD name. Per-procedure docs via describe(cypher=['orphan_node']). No <rule_packs> block. No opt-in advertise() function. No separate rules_run MCP tool — agents invoke via cypher_query.

    Breaking change. Code using g.rules.run(...) or any kglite.rules.* import from 0.8.16–0.8.18 must migrate to the CALL syntax. The migration is mechanical: one CALL per rule with map-syntax parameters and YIELD node (or YIELD node_a, node_b for cycle_2step).

    Removed: kglite/rules/ package, g.rules accessor on KnowledgeGraph, Rule/RulePack/RuleReport/_RulesAccessor classes, kglite.rules.advertise(), _set_default_rule_pack_xml PyO3 function, _set_rule_pack_xml PyO3 method, rule_packs_xml field on KnowledgeGraph, inject_rule_packs helper in describe.rs, the <rule_packs> block in describe(), the rules_run MCP tool from examples/mcp_server.py and prospect_mcp_server.py, pyyaml>=6.0 runtime dependency.

[0.8.18] — 2026-04-26

Changed

  • Rule-pack discovery via describe() is now opt-in. A fresh kglite.load(...) produces a describe() with no <rule_packs> block — graphs that don’t use rule packs incur no agent-facing noise. Activation is explicit:

    • g.rules.run(...) or g.rules.load(...) activates per-graph advertising (existing behaviour, unchanged).

    • New kglite.rules.advertise() publishes a module-level default visible to every subsequent describe() across all graphs. Use this for MCP servers that expose a rule-pack tool. Idempotent.

    • The examples/mcp_server.py rules_run tool is now commented out; the file documents how to re-enable it for users who want rule packs in their MCP surface. The default MCP example is rule-pack-free.

  • Per-rule Cypher timeout via default_timeout_ms. Rules can declare a YAML-level default_timeout_ms and the runner passes it as the timeout_ms to g.cypher() per rule. A caller-supplied timeout_ms to g.rules.run(...) always wins. Lets full-Wikidata users set realistic budgets on rules that scan dense node types (13M humans, 45M scholarly articles) without affecting the global graph timeout.

  • Rule-pack describe() integration moved into Rust. Slice 1.1 shipped agent-discovery via a Python monkey-patch that wrapped KnowledgeGraph.describe, called the Rust method (preserved as _describe_native), and post-processed the XML to splice in a <rule_packs> block. That dispatch is now native: describe() is the Rust method again. Per-instance pack XML lives on the KnowledgeGraph struct (Mutex<Option<String>>); a module-level default holds the cold bundled-pack inventory. Python’s role shrinks to rendering the XML on pack load() / run() and pushing it via the new _set_rule_pack_xml PyO3 method (and the module-level _set_default_rule_pack_xml). User-visible XML and behaviour are byte-compatible; the wrapper indirection and its per-call str.rfind/slice/concat are gone.

Fixed

  • LIMIT was applied before WHERE filtering, returning fewer rows than expected. A query like MATCH (n:T) WHERE NOT EXISTS { (n)-[:E]->() } RETURN n.id LIMIT 5 could return 0 rows when the first 5 candidate nodes all failed the WHERE predicate. Root cause: the planner pushed the LIMIT hint into PatternExecutor, which capped candidates before the inline WHERE filter ran. Fix: skip the limit hint at pattern-execution time when an inline WHERE is present, and apply LIMIT after the WHERE filter (as the surrounding executor already attempts to). Affects any filtered query with LIMIT, not just NOT EXISTS.

Added

  • Rule packs — agent-discoverable structural validators. New g.rules sub-namespace exposes list(), load(), run(), and describe() for named YAML packs that compile to Cypher and emit a structured RuleReport. The bundled structural_integrity pack (v1.1) ships six universal cross-graph rules: orphan nodes, self-loops, short cycles, missing-required-edge (outbound), missing-inbound-edge, and duplicate titles. g.describe() surfaces a <rule_packs> block so agents discover packs through the same XML they consume for schema. See Cypher guide. Reports are lazy: .summary returns counts without materialising rows, and runs are cached per (pack_name, params, graph). New runtime dependency: pyyaml>=6.0.

  • Rule-pack ergonomics:

    • summary["any_truncated"] — top-level boolean so agents can one-glance check if any rule hit its LIMIT.

    • report.is_suspect(node_id) — O(1) cross-reference helper that returns [(rule_name, severity), ...] for rules that flagged the node. Built lazily; accepts string or int ids.

    • g.rules.list() now reads bundled-YAML headers lazily so cold inventory shows real version + rule_count + description (no placeholders) before any pack has been loaded.

    • Optional usage_hint: field on a pack — surfaced via g.rules.describe(name) and as an XML attribute in g.describe() so agents can read “use this pack when…” guidance inline with the schema.

    • to_markdown() truncates list-typed cells (e.g. the ids column in duplicate_title) to 3 elements + “ (+N more)” so agent-pasted output stays readable.

    • Direction-aware missing_*_edge rules. New optional validates_direction: rule field ("outbound" or "inbound"). The runner inspects g.connection_types() and refuses to execute when the (type, edge) pair flows the wrong way in the graph’s actual schema, surfacing a DirectionMismatch error that suggests the right rule. The bundled missing_required_edge and missing_inbound_edge opt in. Prevents trivial rule firing where e.g. asking for incoming IN_LICENCE on a Wellbore would have matched every wellbore meaninglessly.

[0.8.17] — 2026-04-26

Performance

  • Two-MATCH count fusion: top-K-by-degree filtered queries now run ~20× faster. The shape MATCH (w)-[:T]->(b {nid:'X'}) MATCH (w)-[r]-() WITH ... count(r) ... ORDER BY count DESC LIMIT k used to materialise one row per edge for every group key (e.g. 4 M edge rows for 416 k Wikidata writers — 494 s on the full graph). The aggregation-fusion pass at src/graph/languages/cypher/planner/fusion.rs now also recognises [Match, Match, With(count)] and folds it into a single FusedMatchWithAggregate whose secondary pattern drives the per-group-key degree count via the existing count_edges_filtered fast-path. Measured: top-10-by-degree on Wikidata writers 494 s → 24 s (20×). The remaining time is one degree lookup per group key (832 k mmap reads) — further wins live in storage, out of scope for this session.

  • count_edges_filtered fast-path now handles undirected [r]- edges. Previously the fast-path returned None for EdgeDirection::Both, forcing the slow per-edge enumeration. It now sums incoming + outgoing count_edges_filtered calls — the canonical “total degree” pattern. Both the new two-MATCH fusion and the existing single-MATCH WITH count benefit.

  • Per-group-key count phase in execute_fused_match_with_aggregate now runs in parallel above 4 096 group keys. Each count_edges_filtered call is a read-only mmap lookup, so rayon’s par_iter overlaps the per-call I/O instead of serialising it. Measured on the same Wikidata top-10-by-degree query (124 M nodes / 861 M edges): 24 s → 8.5 s (~2.5×) on top of the fusion win. End-to-end wall on top_writers.py is now 73 s, vs. 510 s before any of this session’s work (~7× total).

  • Top-K hint absorbed into FusedMatchWithAggregate. A new planner pass fuse_match_with_aggregate_top_k recognises the shape [FusedMatchWithAggregate, Return, OrderBy(count_alias), Limit(k)] (where the RETURN is a pure pass-through projection) and pushes the K-bound into the fused stage. The executor sorts by count first and then evaluates the group-key projection expressions only for the K winners — saves N×P evaluate_expression calls when N is large and K is small. The Wikidata top-10-by-degree query goes from materialising 416 k rows to 10; per-row property reads stop being the tail cost (modest 5% gain on top of the parallel-count win, but principled: “only do necessary work” — projection-heavy queries get a much larger benefit).

  • Lazy RETURN — defer per-row property evaluation until Python reads each cell. The planner’s new mark_lazy_eligibility pass annotates the terminal RETURN with lazy_eligible = true when the query is MATCH (WHERE …) RETURN <prop access> and there’s no downstream operator that needs row values (DISTINCT/HAVING/ORDER BY/aggregate/WITH/UNWIND/CALL/UNION/ mutation all force the eager path). The executor skips execute_return_projection’s per-row loop and hands the pending rows + return items to the Python ResultView via a side-channel LazyResultDescriptor. ResultView materialises cells on access (memoised via a Mutex<Vec<Option<…>>> so repeat reads are free), and __len__ becomes O(1). Measured on the same Wikidata script: the find-writers query (MATCH RETURN nid, title, used only for len() in the caller) 57 s → 35 s (~1.6×). End-to-end on top_writers.py is now 49 s — down from the original 510 s before this session — ~10× total.

Performance

  • Phase 1 N-Triples loader is ~1.7× faster. Steady-state on Wikidata’s latest-truthy.nt.bz2 went from ~2.4 M tri/s to ~4.1 M tri/s (--size 50 build dropped 20.83 s → 15.96 s; full Wikidata Phase 1 projects from ~2 h to ~70 min). Profiling with samply showed the loader thread spending ~32% of CPU in libsystem_malloc and ~10.7% in core::str::pattern::TwoWaySearcher (used by str::find). Four targeted changes:

    • Byte-level parse_line (src/graph/io/ntriples/parser.rs): swap line.find("> ") for memchr::memchr(b'>'). URIs in N-triples cannot contain >, so a single byte scan is sufficient.

    • EntityAccumulator capacity preallocation (HashMap::with_capacity(32), Vec::with_capacity(8)): eliminates RawVecInner::finish_grow reallocs in the per-entity accumulator.

    • scratch_props reuse in flush_entity: hoist the Vec<(InternedKey, Value)> out of the function so the alloc cost is paid once per build instead of per entity.

    • mimalloc as global allocator (src/lib.rs): pure Rust / build-time-only dependency; ~10% wall-time win on the loader on top of the parser-side changes.

  • Reader-thread channel batches 50k → 200k. Reduces per-batch sync overhead 4× without growing peak RSS meaningfully.

Added

  • Block-level parallel decoder for single-stream .bz2 files. Wikidata ships latest-truthy.nt.bz2 as a single bz2 stream, so the existing stream-level scanner in parallel_bz2.rs was falling through to a single-threaded MultiBzDecoder (~1 M triples/s ceiling). The new single-stream path delegates to bzip2_rs::ParallelDecoderReader (paolobarbolini/bzip2-rs, MIT/Apache-2.0), which finds bit-aligned block magics inside one stream and decodes blocks on rayon workers. Measured Phase 1 throughput on Wikidata: ~1.0 M tri/s → ~3.3 M tri/s (3.3× speedup) at the same memory ceiling. Multistream files still use the existing stream-level path. Pinned to a git rev because the published 0.1.2 crate ships an older Cargo.toml without the rayon feature flag.

  • Phase 1 progress bar now shows ETA when max_entities is set. When the caller has set an entity cap, the loader emits the bar position as entities_created against total = max_entities, so tqdm can compute ETA from the entity rate. Without a cap the bar still tracks triples (no total → no ETA, just rate). The unused counter ships in the event’s fields dict either way.

  • Ctrl+C cancellation of load_ntriples builds. Phase 1 runs inside py.detach() (GIL released so Python heartbeat threads can run), which previously meant SIGINT couldn’t reach Python until the Rust call returned — i.e. Ctrl+C did nothing during a multi-hour Wikidata build. The progress sink now reacquires the GIL on every update event and calls Python::check_signals; a pending SIGINT flows back through a new Cancelled marker on ProgressSink::emit, unwinds the loader cleanly, and surfaces as KeyboardInterrupt on the Python side. Cancellation requires a progress= callback (which is the default in the bench script and dataset wrappers).

Changed

  • bench/wikidata_e2e.py CLI overhaul. --progress is now the default (use --legacy-progress for the old [Phase X] stderr output). Removed --quiet — wrapper-level status messages (cooldown checks, cache hits) print regardless; the loader’s per-phase eplog lines are auto-silenced when tqdm is active so they don’t fight the bar. Renamed --size to --entities-m to make it clear the cap is in millions of entities, not triples (--size remains as a deprecated alias).

  • kglite.datasets.wikidata.open auto-silences the loader when progress= is set. Wrapper-level verbose=True controls the cache-hit / cooldown messages; loader verbose is forced off when a progress callback is wired so tqdm owns the terminal.

Added (continued)

  • Structured build-phase progress callback for load_ntriples. New progress= kwarg accepts a Python callable that receives one dict per phase event (start / update / complete) for each of phase1 (streaming), phase1b (columnar build), phase2 (edges), phase3 (CSR), and finalising. Phase 1 fires updates every 5M triples (decoupled from the 60s stderr gate) so a UI driven by the callback stays live. Errors raised by the callback are swallowed so a broken UI cannot kill a multi-hour build. Pure-Rust trait ProgressSink lives in src/graph/io/ntriples/mod.rs; the PyO3 adapter that translates a Python callable into a sink lives in src/graph/pyapi/kg_core.rs, keeping the loader free of pyo3 types.

  • kglite.progress.TqdmBuildProgress — drop-in tqdm-backed reporter. One bar per phase, with RSS (via psutil) and per-phase counters in the postfix. pip install tqdm psutil to use.

  • bench/wikidata_e2e.py --progress — opt-in flag that wires TqdmBuildProgress into the e2e benchmark.

Fixed

  • load_ntriples no longer panics with slice index starts at A but ends at B on large disk/mapped builds. Wikidata builds past ~450 M triples crashed in MmapColumnStore::read_str on reload. Root cause: flush_entity for entities whose ID didn’t parse as a Q-code wrote Value::String(acc.id) into the nid column, which flipped that column’s id_is_string=true. Subsequent entities with Value::UniqueId left their string offsets uninitialised (zero), so reload hit start > end decoding them. Fix: in disk/mapped mode, skip entities whose ID is not a parseable Q-code at the top of flush_entity (these were unreachable in the canonical Wikidata query surface anyway). In-memory mode is unaffected.

  • Cypher DETACH DELETE no longer breaks subsequent typed-edge traversals. Pre-fix, after a Cypher DETACH DELETE, fluent g.select(t).traverse(conn_type, ...) (and any make_traversal caller) would throw “Connection type ‘X’ does not exist in graph” even when X still had millions of live edges. The Cypher executor’s execute_delete invalidated the edge_type_counts_cache but left the connection_types HashSet alone — has_connection_type() consults the HashSet first and returned a stale negative. Fix: clear the connection_types cache on Cypher delete, and add a final fall-through in has_connection_type() to the disk backend’s authoritative conn_type_index_* arrays. Surfaced by bench/benchmark_full.py against every disk row.

Performance

  • Parallel multistream .bz2 decoder for load_ntriples — closes the gap with .zst. Wikidata / pbzip2 dumps are a concatenation of independent bz2 streams; the previous bzip2::read::MultiBzDecoder walked them sequentially on a single core. New parallel_bz2::open() (in src/graph/io/ntriples/parallel_bz2.rs) scans the file for BZh[1-9] + 6-byte block magic, dispatches streams to a worker pool sized by a memory budget (256 MB default, after pbzip2’s NumBufferedBlocksMax), and re-orders the decompressed chunks behind a single Read surface. Single- stream .bz2 files take a fast path through MultiBzDecoder with no thread-pool overhead. Workers join before load_ntriples exits Phase 1, so no parallelism leaks into the Phase-2/3 rayon pool. Measured: wiki100m bz2 99 s → 34 s (2.9×), wiki200m 199 s → 71 s (2.8×). bz2/zst ratio 3.4× → 1.19×.

  • enable_columnar() is now idempotent on the already-columnar fast path. Previously, every g.save() re-ran the full per-node columnar rebuild — even when the graph was already columnar and unmodified. At wiki100m memory mode this cost ~257 s of pure waste on consecutive saves. Now enable_columnar walks every node once (O(N) cheap matches) and short-circuits if all nodes are PropertyStorage::Columnar AND their Arc<ColumnStore> matches graph.column_stores for the type (the Arc-pointer check catches the common add_nodes(conflict_handling="update") fork pattern that would otherwise lose updates on save). Measured wiki5m: consecutive save() 455 ms → 177 ms (2.6×).

Changed

  • verbose=True on load_ntriples is now phase-oriented. Previous output was a mix of [T+30s] timestamps and ad-hoc sub-step prints; Phase 2/3 output in particular was developer-grade noise. The new output is a small set of [Phase N] gate messages — open/close pairs around each major stage of the build:

    [Phase 1] Streaming and parsing N-triples (...)
    [Phase 1] 12.3M triples, 2.8M entities, 8.5M edges buffered — 205k triples/s
    [Phase 1] Complete: ... in 47m18s
    [Phase 1b] Building columnar storage (...)
    [Phase 1b] Complete in 8m42s
    [Phase 2] Creating edges
    [Phase 2] Complete: ... edges in 11m22s
    [Phase 3] Building CSR edge index
    [Phase 3] Complete in 2m04s
    [Finalising] Building auxiliary indexes + saving metadata
    [Finalising] Complete in 32s
    [Build] Total elapsed: 1h09m54s
    

    Sub-step timings (CSR step 1/4, peer-count histogram, mmap layout, per-type flush logs, interner save timings, Q-code resolution timings, …) move behind KGLITE_BUILD_DEBUG=1. The legacy KGLITE_CSR_VERBOSE env var is replaced by KGLITE_BUILD_DEBUG (one flag for all build sub-step output).

Added

  • kglite.datasets.sodir.open(workdir, ...) — one-call lifecycle for Sodir factmaps petroleum data. Resolves CSVs from the public ArcGIS FeatureServer at https://factmaps.sodir.no/api/rest/services/DataService, applies the FK pre-processing the existing build script does, and builds the graph via the packaged blueprint. Default storage is memory — Sodir is small enough that disk caching adds little on top of CSV caching:

    g = sodir.open("/data/sodir")  # memory; index_cooldown_days=14, dataset_cooldown_days=30
    g = sodir.open("/data/sodir", storage="disk")  # opt-in for cross-process reuse
    

    Workdir layout: csv/ (fetched datasets, flat), sodir_index.json (per-dataset row count + timestamps), graph/ (disk-mode only). Index sweep cheaply re-checks remote row counts every 14 days; only changed datasets re-download. Hard cooldown forces full per-dataset refresh every 30 days even if counts match.

    Complement blueprints: pass complement_blueprint=path/to/extra.json to add new node types / edges on top of the packaged baseline. The file is persisted to workdir/blueprint_complement.json on first call and auto-loaded on subsequent calls. Pass use_complement=False to skip it for a single call, or sodir.remove_complement(workdir) to drop it permanently. Deep merge with base-wins on key collisions by default (the packaged baseline tracks the canonical Sodir REST catalog and stays authoritative); set complement_overrides=True to flip when the complement should win.

    The blueprint walker auto-detects which datasets are referenced and fetches only those — adding new node types to the blueprint triggers fetches the next time open() runs. The packaged baseline ships only the 33 node types whose CSVs are fetchable from REST (no sideloaded prospect / play / ocean data); use a complement to layer those in.

    Parallel fetcher: workers parameter (default 4) drives a thread-pool that pulls dataset jobs off a shared backlog. tqdm progress bar replaces per-dataset prints so verbose output stays one line tall. Geometry handler is defensive against empty coordinates: [] (some pre-1970 Sodir wellbores) — drops those features’ geometry without aborting the fetch. KGLite is independent of Sodir / the Norwegian Offshore Directorate; see module docstring. Catalog (LAYERS / TABLES / FACTMAPS_LAYERS) vendored from kkollsga/factpages-py.

  • kglite.datasets.wikidata.open(workdir, ...) — one-call lifecycle for Wikidata latest-truthy graphs. Resolves the dump (download, resume .part, refresh on cooldown), builds the disk or in-memory graph, and returns it. Subsequent calls cache-hit on the saved graph at workdir/graph[_<N>m]/. Two storage backends:

    g_full = wikidata.open("/data/wd")                        # disk graph
    g_100m = wikidata.open("/data/wd", entity_limit_millions=100)
    g_mem  = wikidata.open("/data/wd", storage="memory",      # rebuild every call
                            entity_limit_millions=10)
    

    Sized slices (entity_limit_millions=100/200/...) live alongside the full graph (graph_100m/, graph_200m/, graph/) and all share the same latest-truthy.nt.bz2 dump under workdir.

    Also exports fetch_truthy(workdir, cooldown_days=31) for the dump-only path. KGLite is independent of the Wikimedia Foundation; see module docstring.

  • load_ntriples releases the GIL. Multi-minute loads no longer block Python threads — heartbeat / progress monitors and other background workers run on schedule throughout the build. Required for the new minute-cadence reporting in examples/wikidata_disk.py.

  • bench/benchmark_full.py — full-stack lifecycle benchmark (build / save / load / mutate / resave / Cypher / fluent) across every storage mode × Wikidata subset. Wide-pivot CSV output — one row per (run, mode, dataset). Errors preserved both in the errors column (truncated, semicolon-separated) and in a sidecar bench/benchmark_full.errors.log (full text, tab-separated for cut-friendly inspection).

  • bench/results.py — pandas-backed analysis tool over the bench CSV. Three commands: latest (most recent measurement per cell), trends (per-run time-series for filtered cells), deltas (consecutive-run deltas — surfaces regressions). Use --mode / --dataset / --cols filters.

  • --languages CLI flag on bench/wiki_benchmark.py and bench/api_benchmark.py (default en). Matches the existing flag on bench/wikidata_e2e.py. Threads through subprocess scenarios via argv (wiki_benchmark) and KGLITE_BENCH_LANGUAGES env (api_benchmark, which uses positional args). Pass --languages "" to keep all languages, but the canonical query suite expects English type names like :human and will return zero rows otherwise.

  • Post-build SANITY PROBE in bench/wikidata_e2e.py. Quick Q42 title/description lookup + top-5 type histogram printed after every build, even with --no-queries. Catches language-filter or auto-type-rename regressions immediately, before query-suite time.

[0.8.15] — 2026-04-25

Performance

  • Mapped-mode property index — MATCH (n:Type {prop: val}) in O(log N). MappedGraph now carries a lazy per-(node_type, property) and cross-type property index alongside the 0.8.15 conn_type index. On first lookup_by_property_eq / lookup_by_property_prefix / *_any_type hit the backend iterates nodes once, emits a sorted (key, NodeIndex) array — same layout as disk’s persistent PropertyIndex — and caches it behind an Arc<RwLock<…>>; subsequent queries binary-search that array. Alias handling matches disk (reads from node.title for title/label/name and node.id for id/nid/qid so the add_nodes(..., node_title_field=...) pipeline “just works”). Invalidated on add_node/remove_node/node_weight_mut. Measured on a 938 k node / 212 k Q5 subset (wiki100m Wikidata humans): MATCH (n:Q5 {title: 'Douglas Adams'})37 ms first call (builds the index), 0.1 ms warm (index hit). Prefix scans stay in the single-digit-ms range. Correctness pinned by tests/test_mapped_property_index.py against both memory and disk.

Added

  • Cypher count { <pattern> } subquery expression. count { ... } in WITH / RETURN / ORDER BY / WHERE now evaluates to the number of matches of the inner pattern, scoped to the outer row’s bindings. Previously the parser rejected the shape with “Expected property name or .property in map projection, got Some(LParen)” because the identifier-followed-by-brace dispatch routed to map projection (n { .prop1, .prop2 }) unconditionally; the parser now special-cases count and routes to a new parse_count_subquery that mirrors the existing EXISTS { ... } grammar. New AST variant Expression::CountSubquery; the executor runs the pattern via the shared PatternExecutor, bindings- compatible with the outer row, with optional inline WHERE. Parity across all three storage modes verified by tests/test_cypher_count_subquery.py. Cypher shapes like WITH a, count{(a)-[:REL]->()} AS n now work out of the box.

Performance

  • Mapped-mode query acceleration — lazy per-connection-type index. MappedGraph was a bare StableDiGraph wrapper with none of the inverted indexes that make disk-mode fast (conn_type_index_*, peer_count_*, CSR sorted by type). Cypher queries that depend on those structures on disk did full-graph scans on mapped — 2-10× slower than disk despite every byte being in RAM. 0.8.15 adds a lazy MappedTypeIndex populated on first typed-edge query per connection type: CSR-style sorted source lists, per-peer count histograms, and per-source edge slices. Overrides sources_for_conn_type_bounded, lookup_peer_counts, and count_edges_grouped_by_peer on the mapped GraphRead impl; filter_by_connection (powering where_connected) now hoists the source list into a HashSet once per call instead of probing edges_directed_filtered per node. Measured on wiki1000m (1 B triples):

    • Cypher P31 class counts: 150 ms → 0.5 ms (300×).

    • Cypher 2-hop P31 + P279: 180 ms → 0.9 ms (200×).

    • Cypher P31 LIMIT 50: 5.6 s → 0.8-1.1 s (5-7×), now beats disk at every subset (disk wiki1000m was 1.40 s).

    • Cypher Q5 (human) lookup: 77 ms → 50-54 ms (1.5×).

    • Fluent traverse P31 out unlimited: unchanged (74 ms; bare petgraph scan is already optimal for sparse-degree nodes and avoids the ~100 ns/call index-lookup overhead). Correctness preserved: same row counts across storage modes on every benchmarked query. Index is built lazily per conn_type, amortised across subsequent queries of the same type, and invalidated on any edge mutation.

  • Mapped-mode load_ntriples routes through the disk fast path. Previously storage="mapped" fell through to DirGraph::enable_columnar, which iterates every node once, clones each property map into a Vec, and pushes row-by-row into per-column MmapOrVec instances that grow via set_len + remap; each schema extension additionally triggered Arc::make_mut store clones. On wiki50m (377 k nodes / 282 k edges) this ran at ~430 k triples/s with a 5.1 GB peak RSS. Mapped now shares the disk path’s property-log + single-columns.bin pipeline: properties stream to a zstd-compressed log during Phase 1, Phase 1b replays the log once into a pre-allocated mmap, and a new second-pass links each node’s PropertyStorage to the shared Arc<ColumnStore> by row_id. Measured (bench/wiki_benchmark_mapped):

    • wiki50m build: 116 s → 13.5 s (8.6× faster), peak RSS 5073 MB → 828 MB (6× less memory).

    • wiki100m build: ? → 29 s, peak RSS ? → 1.5 GB — now within 1% of disk-mode build time.

    • Same Cypher + fluent rowsets across modes (round-trip oracle in tests/test_incremental_columnar.py::TestNTriplesColumnar). Memory-mode N-Triples load (storage=default) is unchanged — still goes through the non-columnar PropertyStorage::Map/Compact path.

  • Fluent traverse + where_connected use CSR-filtered edge iterator. core/traversal.rs (make_traversal_fast, make_traversal_full) and core/filtering.rs (filter_by_connection, i.e. .where_connected()) previously called graph.edges_directed(node, dir) and post-filtered on connection_type. On disk-mode graphs with csr_sorted_by_type=true (the merge_sort algo that the wikidata_disk.py example uses), we now pass the connection key into edges_directed_filtered so the DiskEdges iterator can binary-search the CSR range — O(log D) instead of O(D) plus per-edge EdgeData materialisation. This is the same fast path the Cypher executor has used since 0.8.0 and targets the shape that the Wikidata fluent suite regressed on: select("Entity").traverse("P31", limit=100) was ~71 s, .where_connected("P31") was ~887 s, and traverse P31 in limit 50 was ~2171 s. Heap backends ignore the hint; correctness is preserved by the existing post-filter.

Changed

  • load_ntriples verbose output is no longer per-type. Phase 1b used to print one N dense cols, M overflow cols line per type with any overflow and one overflow bag X MB for N sparse cols line per type with a non-empty overflow bag. On Wikidata this was ~90 k lines. Both are now collapsed into a single Phase 1b summary per pass (columns N dense, M overflow across K types with sparse cols and overflow bags X.X MB across N types, M sparse cols total).

  • Load-phase progress lines throttled to time (≥ 15 s), not bucket. The loader used to emit a progress line every 5 M triples, which was ~2× per second on a fast machine and scrolled the interesting phase- timing lines off screen. Now the 5 M bucket is still a cheap fast-loop counter but the line only fires when ≥ 15 s have passed since the last one. Lines now prefix [T+NNNNs] so they interleave cleanly with the existing phase-timing output.

Fixed

  • examples/wikidata_disk.py — the fluent cases called .where_(...), but the PyO3 binding exposes the method as .where(...); on a large graph this masked the real traversal slowness behind an AttributeError. Expanded the Cypher/fluent suites with 23 + 15 more diverse queries (typed 1-hop, 2-hop chains, parameter binding, ORDER BY on bounded scans, and string-prefix/contains filters). The fluent suite now introspects via g.node_type_counts() and skips unavailable types instead of raising.

[0.8.14] — 2026-04-24

Performance — disk-graph kglite.load() fast-load series

Four independent on-disk format changes aimed at the serde overhead that dominates kglite.load() on large disk-mode graphs. Profiling the 124 M-node, 863 M-edge Wikidata graph (wikidata_disk_graph_0.8.11, 81 GB on disk) showed zstd decompression + mmap setup account for only ~5–6 s of a ~77 s cold load; the remaining ~70 s is serde rebuild cost on three bulk structures, plus a 266 MB JSON array inside metadata.json. Each format change replaces that cost with flat packed slices + exact HashMap::with_capacity sizing — same in-memory representation, zero consumer surface change.

  • type_connectivity out of metadata.json into a packed binary. On the 81 GB graph this field was 266 MB of a 415 MB JSON file (3,176,503 ConnectivityTriple entries). The new type_connectivity.bin.zst at the graph root is:

    [ 0.. 8]  magic       = b"KGLTCN1\0"
    [ 8..12]  version     = u32 LE (= 1)
    [12..16]  num_entries = u32 LE
    [16..n*32+16]  entries: (u64 src_key, u64 conn_key, u64 tgt_key, u64 count) × n
    

    Keys are interner hashes (InternedKey::as_u64()). Disk-mode save strips the field from metadata.json; in-memory .kgl saves keep embedding it for single-file portability.

  • type_indices.bin.zst flat CSR binary, interner-keyed. Replaces bincode HashMap<String, Vec<NodeIndex>> with three packed slices:

    [ 0.. 8]  magic       = b"KGLTIDX1"
    [ 8..12]  version     = u32 LE (= 1)
    [12..16]  num_types   = u32 LE
    [16..24]  total_nodes = u64 LE
    [24..24 + 8·num_types]        type_keys: [u64]
    [next..next + 8·(num_types+1)]  offsets:  [u64]   (CSR)
    [next..next + 4·total_nodes]   nodes:    [u32]
    

    HashMap capacity is sized exactly from num_types, and each type’s Vec<NodeIndex> is built from a contiguous u32 slice rather than bincode’s per-field serde calls.

  • id_indices.bin.zst per-variant flat binary. Replaces bincode HashMap<String, TypeIdIndex> with:

    [ 0.. 8]  magic     = b"KGLIIDX1"
    [ 8..12]  version   = u32 LE (= 1)
    [12..16]  num_types = u32 LE
    per-type block:
      [ 0.. 8]  type_key:    u64 LE
      [ 8.. 9]  variant_tag: u8  (0 = Integer, 1 = General)
      [ 9..16]  padding:     [u8; 7]
      [16..24]  num_entries: u64 LE
      payload:
        Integer (tag=0):  keys: [u32], node_idxs: [u32]
        General (tag=1):  blob_len: u64 + bincode HashMap<Value, NodeIndex>
    

    The Integer variant dominates Wikidata-style graphs (Q-number ids strip to u32), so the bulk of the 997 MB decompressed bincode blob collapses to two flat u32 arrays per type.

  • interner.bin.zst replaces interner.json. The hash→string JSON map becomes a zstd-compressed bincode Vec<String> of just the originals; hashes are re-derived deterministically on load via get_or_intern. On the 81 GB graph this drops from ~7 MB JSON to ~3 MB bincode and eliminates one JSON parse on the critical path.

  • KGLITE_LOAD_TIMING=1 stage instrumentation. Gated per-stage wall-clock timing in load_disk_dir; off by default (zero overhead). Emits one [TIMING] stage=<name> dur_ms=<ms> line to stderr per major phase (metadata_json, interner_load, disk_graph_load, type_indices_load, column_stores_load, id_indices_load, type_connectivity_load). Used as the measurement harness for the four format changes.

Backward compatibility. All four loaders fall back to the old format on a missing file or magic-byte mismatch:

  • Missing type_connectivity.bin.zst → loader reads embedded JSON from metadata.json, then derives from connection_type_metadata.

  • type_indices.bin.zst without KGLTIDX1 magic → old bincode HashMap<String, Vec<NodeIndex>> path.

  • id_indices.bin.zst without KGLIIDX1 magic → old bincode HashMap<String, TypeIdIndex> path.

  • Missing interner.bin.zst → old interner.json path.

Graphs saved by 0.8.11 and 0.8.12 continue to load without a rewrite. Re-saving an old-format graph with 0.8.13 produces all four new files automatically.

Non-goals / out of scope. No change to query execution, pattern matcher, mutation paths, node_mut_cache / F1 / F2 flow, segmented CSR layout (seg_NNN/), or the KGLCOLv1 sidecar format. In-memory representation of every touched structure is byte-identical to 0.8.12 — zero possibility of query-side regression.

[0.8.12] — 2026-04-24

Fixed

Seven disk-backend correctness fixes stacked on top of the 0.8.11 segmented-CSR foundation. Five cover latent save/reload regressions that slipped through 0.8.11’s phase-1–8 coverage; two (F1 + F2) close the pre-existing Cypher SET and DETACH DELETE mutation holes on disk graphs.

  • save_disk no longer compacts overflow away before seal. The previous dg.has_overflow() gate unconditionally compacted before save_to_dir, which cleared overflow_out/overflow_in and made the phase-6 seal path see empty overflow. Every edge added between saves was silently lost on reload. Gating the compact on “won’t take the seal path” (manifest empty OR no tail above the sealed watermark) preserves overflow for seal and keeps the compact-rewrite semantics for the non-seal case.

  • Segment-local seals merge conn_type_index_sources with global ids. write_conn_type_index walks the segment’s segment-local out_offsets (indices 0..tail_len) and stored those local indices as source ids. Reload’s merge needed to shift each entry by node_lo[seg] for segment-local seals (full-range seals already store global ids). Without this, post-reload MATCH (a)-[:T]->(b) RETURN a.id, b.id returned no rows even though count(*) via the histogram reported the correct total.

  • Compact-rewrite after a prior seal cleans up stale seg_NNN. When save_to_dir falls to compact-rewrite (tombstones, edits, or pure edge mutations between existing nodes), it now removes every seg_NNN > 0 under the target dir before rewriting seg_000. Without this, enumerate_segment_dirs picked up the stale sealed segments on reload and concat’d them against the fresh seg_000, double-counting nodes and edges.

  • Compact-rewrite persists heap-backed core arrays. reconcile_seg0_csr (called inside seal) replaces self.{node_slots, out_offsets, in_offsets, edge_endpoints} with heap-backed MmapOrVec::Heap copies. The same-dir compact-rewrite previously relied on mmap persistence and skipped explicit writes, which left the on-disk files at the pre-seal trimmed sizes; reload errored with “File too small”. save_to_file is now called unconditionally for every core array — it handles both backings.

  • New types added via add_nodes persist on disk save. DirGraph::save_disk gated the per-type columns/<type>/columns.zst sidecar write on the absence of columns.bin. For disk graphs built via load_ntriples, columns.bin is always present, so the sidecar branch was dead code and every type added after the initial build lost its column data on reload (properties read back as None). Save now reads columns_meta.json/.bin.zst to identify the types already covered by columns.bin and emits sidecars for the remainder. Load path additively walks columns/ after the mmap fast-path to pick them up.

  • Cypher SET n.prop = X on disk-backed graphs persists through save + reload. Pre-fix, DiskGraph::node_weight_mut materialised a NodeData into self.node_arena; the Cypher executor mutated that arena copy; clear_arenas dropped it without writing back to the canonical ColumnStore. Affected SET + save + reload was a silent no-op for persistence across 0.8.10 and 0.8.11.

    Fix: mirror the proven batch.rs::flush_chunk full-Arc replacement pattern for exact-row mutations. DiskGraph::node_weight_mut now stages writes in a node_mut_cache (Map-backed NodeData); clear_arenas groups cached entries by type, deep-clones each affected ColumnStore once, applies every staged title / property write + DELETE tombstone to the clone, and replaces both DiskGraph.column_stores[ty] and (via DirGraph::sync_column_stores_from_disk) DirGraph.column_stores[ty] atomically. Avoids the Arc::make_mut → per-row clone + Arc divergence that doomed the earlier attempt. Title writes diff against the current stored value before calling set_title so that TypedColumn::Str’s in-place-update offset-corruption bug (pre-existing) doesn’t trigger on unchanged titles.

  • DETACH DELETE on disk preserves surviving nodes’ property values across save + reload. Pre-fix, a disk-graph delete cycle corrupted title reads (garbage bytes) and returned None for some age values on reload. The surviving count and id set were always correct — only the column-store-backed property columns were affected. Root cause was in the sidecar load path, not in mutation routing: load_column_sidecars derived row_count from type_indices[type].len() (live rows only), while the sidecar blob retains tombstoned rows alongside live ones. The mismatch made ColumnStore::load_packed walk column blobs at the wrong offsets and decode offset bytes as string data.

    Fix: the sidecar columns.zst file now starts with an 8-byte KGLCOLv1 magic tag followed by ColumnStore::row_count (u32 LE) before the existing write_packed payload, and the loader uses that stored count. Old-format sidecars (no magic tag) fall through to the type_indices.len() derivation for backward compat — best effort for legacy graphs, correct for any graph saved by 0.8.12+. Locked by test_detach_delete_property_persistence_disk (was _xfail).

Deferred to 0.8.13+

  • Planner pruning using SegmentManifest. Summaries are populated and persisted since 0.8.11; the pattern matcher doesn’t yet consult them. Initial exploration showed the win is small under the current concat-at-load architecture (typed- edge queries at wiki1000m already run at sub-ms when the histogram path applies, and concat’d reads are uniform). Kept as a future option for 200-segment workloads once those exist in practice.

[0.8.11] — 2026-04-23

Added (disk-graph-improvement-plan PR1, phases 1–8)

This release lands the segmented-CSR foundation on the disk backend and then fills in the incremental-save and auxiliary-index work on top of it. Net result: write amplification on incremental ingest drops from 5–25× to ~2× (target from dev-documentation/disk-graph-improvement-plan.md) while load/save on Wikidata-scale graphs now beats 0.8.10 across every subset. Every existing .kgl directory still loads byte-for-byte identically.

  • Segment manifest (seg_manifest.json). On-disk JSON listing per-segment node_id_range, edge_count, conn_types, node_type_counts, and indexed_prop_ranges summaries. Future planner pruning consults this before scanning. Today populated as a single-segment descriptor on every save. New module src/graph/storage/disk/segment_summary.rs.

  • Segmented CSR directory layout (seg_NNN/). The CSR binaries, ColumnStore, and per-(type,prop) property indexes now live under a per-segment subdirectory. Graph-level metadata (disk_graph_meta.json, seg_manifest.json, DirGraph metadata) stays at the graph root. Gated by csr_layout_version (#[serde(default)] == 0) so legacy flat- layout .kgl directories still load.

  • segment_subdir(id) + enumerate_segment_dirs(root). The directory name is now id-parameterised, and load walks a sorted seg_NNN/ enumeration instead of a hardcoded path.

  • Multi-segment read path. SegmentCsr bundles one segment’s six core CSR arrays (node_slots, out_offsets, out_edges, in_offsets, in_edges, edge_endpoints); concat_segment_csrs stitches them by shifting segment-local edge_idx onto combined edge_endpoints, concatenating per-segment node_slots and edge_endpoints, and welding the offset arrays. Single-segment load stays on the direct-mmap path for zero overhead vs 0.8.10.

  • Multi-segment write path (DiskGraph::seal_to_new_segment). Flushes the still-mutable tail ([sealed_nodes_bound, node_count) + overflow edges between those nodes) to a fresh seg_NNN/ — with per-segment conn_type_index_*, peer_count_*, and edge_prop_* alongside the core CSR — appends a SegmentSummary to the manifest, clears consumed overflow, advances the watermark, and rewrites disk_graph_meta.json.

  • Full-range-offset mode for cross-segment seal. The seal path accepts overflow whose source or target is below the watermark by emitting offsets that span every global node id rather than only the new segment’s tail. concat_segment_csrs distinguishes the two modes per segment via out_offsets.len() > node_slots.len() + 1 and unions contributions per-node. Lets general incremental ingest (not just new-nodes-only batches) take the seal path.

  • Automatic incremental save. save_to_dir now delegates to seal_to_new_segment whenever a graph has a prior segment manifest and a tail above the watermark — the typical incremental-ingest shape. Second save on a 10-chunk build produces 10 segments instead of rewriting the entire tree each time.

  • Per-segment auxiliary indexes survive seal+reload. Multi-segment reload now merges conn_type_index_*, peer_count_*, and edge_prop_* across all segments so typed-edge matches, edge_weight(), and peer_count-backed aggregates return correct results on sealed segments.

Performance

  • Save/load regression on Wikidata-scale graphs undone. 0.8.11’s initial segmented-CSR work regressed save/load 6–22× on wiki100m–wiki500m because the dir.join("columns.bin").exists() guard in DirGraph and io::file didn’t know about the phase-4 seg_000/ relocation. Checking both locations restores the 0.8.10 baseline — and beats it once the rest of the phase work lands (wiki500m build −23 %, save −10 %, load −12 % vs 0.8.10; wiki100m build −22 %, save −16 %, load −15 %).

  • MATCH ()-[:T]->(c) RETURN c, count(*) aggregations sub-millisecond at every scale. The fused MATCH+RETURN aggregate path was running PatternExecutor::execute(MATCH (c)) unconditionally to enumerate the group target before dispatching to the histogram top-K fast path — for an untyped group target, a 14.7 M-node full-graph scan on wiki1000m that the fast path never reads. Enumeration is now deferred to the one node-centric fallback branch that actually needs it. P31 class counts on wiki1000m: 3702 ms → 0.3 ms (12 300×). Same fix applied to FusedMatchWithAggregate: WITH P27 count on wiki1000m 5387 ms → 13 ms (408×), on wiki500m 426 ms → 6 ms (71×).

  • Edge-centric fast path for MATCH (src:T1)-[:T]->(tgt) WITH tgt, count(src). Phase 3 routes this shape through the pre-built peer_count_histogram when the source-type filter is a no-op, or walks conn_type_index source lists otherwise — both O(|T-sources|) instead of O(|all nodes| × in-degree). On wiki500m the query drops from 1210 ms to 445 ms (the result is a stepping stone; the full win lands via the histogram-routing fix above).

  • Multi-key ORDER BY LIMIT on aggregated counts. Phase 4 extends the fused MATCH+RETURN aggregate path so ORDER BY k DESC, c.title no longer disables fusion. Fusion sets a candidate_emit descriptor; the executor picks the primary-key threshold via a heap and emits the qualifying superset (any tie-breaking on secondary keys happens in the unchanged downstream OrderBy + Limit). P31 class counts warm-cache on wiki500m dropped from 1477 ms to 450 ms as a secondary effect, before the group-target-scan fix made it sub-millisecond.

Fixed

  • CSR mmap files now trim in-place on save_to_dir. MmapOrVec::mapped(path, cap) has a 64-element minimum, so small graphs left trailing zeros on disk. The single-segment load path masked this by using meta.*_len, but the new multi-segment load path relies on file-size inference. All six core CSR arrays now pass through save_to_file on the same-dir save path, triggering the file.set_len(byte_len) truncation. Same bug pattern as the 0.8.10 conn_type_index trim.

[0.8.10] — 2026-04-20

Performance

  • GROUP BY aggregation defers property materialization. Queries of the shape RETURN x.prop, count(*) now hash by NodeIndex during the per-row pass and resolve the property once per resulting group, rather than once per input row. For high-fanout aggregations on disk graphs (e.g., walking Wikidata’s 439K country=Norway entities and grouping by their instance_of type) this drops O(rows) random-I/O column reads to O(distinct groups). Cypher semantics are preserved by re-bucketing on resolved values after — two distinct nodes that share a property value still collapse into one group. Implementation in src/graph/languages/cypher/executor/return_clause.rs.

Fixed

  • OPTIONAL MATCH + RETURN with PropertyAccess group keys no longer silently returns NULL groups. The fused OPTIONAL MATCH + aggregation path evaluated group-key expressions against the source row (pre-OPTIONAL), so a query like OPTIONAL MATCH (p)-[:OWNS]->(pet) RETURN pet.name, count(*) would resolve pet.name to NULL for all rows, collapsing every result into one wrong group. The fusion check now rejects PropertyAccess on variables only bound by the OPTIONAL MATCH itself, falling through to the correct (non-fused) aggregation path. is_fusable_return_clause in src/graph/languages/cypher/planner/fusion.rs now takes the OPTIONAL MATCH variable set and rejects matching property accesses.

  • Multi-MATCH re-bind no longer full-scans the graph. When a second MATCH clause re-bound a variable from a prior clause (MATCH (f {id: X}) MATCH (f)-[:R]->(c)), the pattern matcher’s inverted-index fast path ignored the existing binding and returned every source node for the edge type — 20s+ timeouts on Wikidata-scale graphs. The fast path now skips when the first node is already bound, falling through to find_matching_nodes which resolves the variable to a single node. {Gjøa, Norway} goes from >20s timeout to ~36ms on the 124M-node Wikidata graph.

Changed

  • Graph algorithm procedures (CALL pagerank/degree/betweenness/ closeness/louvain/label_propagation/connected_components) now error on timeout instead of silently returning partial results. Algorithm signatures changed to Result<_, String>; the break-on-deadline branches now return Err, and the new algorithm_timeout_err() message points users at timeout_ms=N / timeout_ms=0. Fixes silent half-converged results that looked successful.

  • CALL on graphs over 2M nodes now refuses unscoped procedure runs up front. Prior to this, CALL degree() on Wikidata (124M nodes) ignored its _deadline parameter entirely and ran for minutes — long enough to exhaust MCP transport timeouts and appear to wedge the server. The new guard errors in <1ms with “would scan the whole graph. Subgraph scoping is not yet supported — try a smaller graph, or pass timeout_ms=0 to override this guard.”

  • degree_centrality and weakly_connected_components now honor the 20s Cypher deadline. Both previously ignored deadlines (the former via an unused _deadline parameter, the latter by not accepting one). Periodic checks every ~1M edges.

[0.8.9] — 2026-04-20

Changed

  • Streaming label journal replaces in-memory label_cache during load_ntriples. The previous HashMap<u32, String> grew to ~10GB on Wikidata’s 124M entities, collapsing streaming throughput from 1.8M triples/s to 450K/s via swap pressure. Labels now spill to a sequential on-disk journal ({spill_dir}/labels.bin) — zero heap growth during Phase 1. The post-Phase-1 rename pass reads the journal once, filtering to the ~tens-of-thousands of Q-numbers that actually appear as type names (~3MB final footprint). New module: src/graph/io/ntriples/label_spill.rs.

Fixed

  • Typed MATCH (n:Type {title: 'X'}) now takes the cross-type global-index fast path. Previously only untyped patterns consulted the global index; typed patterns fell through to a full-type scan — 10–14s (and frequent timeouts) on 13M-row types like Wikidata human. The matcher now consults the global index and post-filters by node_type_of(idx), dropping MATCH (n:human {title: 'Barack Obama'}) from 14s to ~25ms. Alias-aware (title↔label↔name).

  • Per-type {nid: ...} / {qid: ...} anchors hit the id index. Both typed and untyped paths previously only checked the literal "id" key, so alias queries fell through to full scans. Now id/nid/qid all anchor via the same per-type id_index.

  • String-form id anchors ({nid: 'Q76'}) hit the id index. TypeIdIndex::get now coerces "Q76"UniqueId(76) by stripping the leading alpha prefix. Works for any [A-Za-z]+[0-9]+ id scheme (Wikidata Q-codes, P-codes, E-codes, …). Previously the lookup fell through to a full-type scan, so MATCH (a:human {nid: 'Q76'})-[r]-(b:human {nid: 'Q13133'}) dropped from ~14s to ~300ms on Wikidata. Also fixes the correctness bug where MATCH (a {id: 'Q76'}) silently returned 0 rows instead of the matching node.

[0.8.8] — 2026-04-19

Fixed

  • EXISTS inline-property filters on target nodes were silently dropped. WHERE EXISTS { (a)-[:REL]->({id: 20}) } used the fast path’s get_property("id") which missed the special id_column, producing silent zero-row results even when the pattern genuinely matched. Ported the same alias resolution that node_matches_properties uses — title/name/label/id/type all route to the right column via resolve_alias. The fast path now behaves identically to the slow path for these literal-property checks. Regression tests added to test_where_exists.py.

Added

  • Cross-type global property index. New create_global_index(property) builds a single mmap’d sorted-string index covering every live node, not just one type. On a disk graph, save() now auto-builds a global title index so MATCH (n {title: 'X'}) — without a type label — is O(log N) out of the box. Solves the “title-to-ID without guessing the type” problem that agents hit repeatedly on Wikidata-scale graphs. Files: global_index_{property}_{meta,keys,offsets,ids}.bin.

  • g.search(text, property='title', limit=10) helper. Returns the top-k nodes whose property matches text (exact match first, then prefix fallback) as [{id, type, title, id_value}]. Backed by the global index. Also exposed as a new MCP tool so agents can skip the “guess the type” ceremony entirely: search('Equinor') returns the right Q-number without MATCH or a type label.

  • Alias-aware cross-type lookups. When the untyped matcher sees {title: 'X'} and the literal title index doesn’t exist, it also tries the hardcoded title family (title/label/name) AND any per-type aliases declared via node_title_field= at add_nodes time. Same for id/nid/qid. An agent who built the index as create_global_index('label') still hits the fast path when querying {title: 'X'}, and vice versa. Derivation is automatic from the graph’s existing schema — no new config API.

Changed

  • save_disk auto-builds the global title index. Every call to save() on a disk graph now produces global_index_title_*.bin files. Adds a one-pass sweep over node_slots at save time — negligible on small graphs, ~single-digit minutes on Wikidata-scale (124M nodes). Opt-out: delete the files after save.

[0.8.7] — 2026-04-19

Added

  • WHERE n.prop STARTS WITH 'prefix' now pushes down into the MATCH pattern and routes through the persistent disk prefix index when available. New PropertyMatcher::StartsWith(String) variant, new apply_prefix_to_patterns helper in src/graph/languages/cypher/planner/index_selection.rs, new path in matcher.rs::try_index_lookup that calls GraphRead::lookup_by_property_prefix. String indexes are annotated indexed="eq,prefix" in describe() output (previously just eq); numeric indexes remain indexed="eq" only.

  • Deadline polling inside unanchored matcher scans. Three hot loops in matcher.rs that used .filter().collect() over 13M+-node type lists now poll the deadline every 4096 rows (via a new check_scan_deadline() helper with a structured hint message). Worst-case overshoot past the deadline drops from 20-60+ s to under a few ms. Other scan loops (variable-length paths, CSR edge counting, column stats) already polled; this closes the final gaps.

  • MCP cypher_query tool accepts timeout_ms. examples/mcp_server.py’s tool signature now exposes the override so agents can deliberately extend or disable the deadline (timeout_ms=0) per call after an EXPLAIN confirms the plan is anchored. Previously the MCP agent was stuck with the backend-aware default.

  • ResultView.diagnostics — lightweight execution diagnostics. Every cypher() call now attaches an always-on diagnostics dict to the returned ResultView with elapsed_ms, timed_out, and the timeout_ms that was in effect. Gives agents immediate feedback on query cost and timeout state without requiring PROFILE. The field is None for mutation paths, EXPLAIN, and transaction queries.

  • describe() indexed-property annotations. Properties covered by an index (in-memory property_indices or the new persistent disk PropertyIndex) are now emitted with an indexed="eq" attribute in the <properties> detail block. A new <indexing> hint inside <extensions> explains the annotation and reminds agents to prefer anchored queries over unanchored scans on disk-backed graphs. New helper DirGraph::has_any_index(node_type, property) consolidates the “in-memory or persistent” check.

  • Persistent disk-backed property index. create_index('T', 'label') on a storage='disk' graph now writes four mmap’d files (property_index_{type}_{property}_{meta,keys,offsets,ids}.bin) next to the CSR instead of rebuilding a HashMap<Value, Vec<NodeIndex>> on every load(). The previous in-memory path consumed ~1-3 GB of heap on 13M-row types and made create_index effectively unusable on Wikidata-scale disk graphs. The new persistent index is lazy-loaded on first query after reopen, keys are sorted lexicographically (so both equality and prefix can share the same structure), and the Cypher planner consults it via a new GraphRead::lookup_by_property_eq trait method. MATCH (n {label: 'X'}) now hits the index on disk in O(log N + k). Supports string columns and title aliases (node_title_field at add_nodes time — label, name, etc.). Numeric equality and STARTS WITH pushdown are follow-ups. In-memory graphs are unchanged (keep the existing property_indices HashMap). The create_index return dict grows a persistent: bool field indicating whether the disk path was taken.

  • Cypher schema validation at plan time — catches typos in pattern-literal property names (MATCH (n:Person {agee: 30})) before the executor commits to a scan. Returns a Did you mean 'age'? hint. Runs in O(clauses) against node_type_metadata; skipped when a graph has no declared schema. Pattern-literal properties are the only v1 target — unknown node types, connection types, and WHERE/RETURN n.prop accesses deliberately pass through (existence-check queries and virtual columns would otherwise false-positive). Phase 3 will surface those as non-fatal diagnostics.

Changed

  • cypher() default timeout is now backend-aware. Disk-backed graphs default to 10 s, Mapped to 60 s, Memory to no deadline. Users can override per-call via timeout_ms=N or globally via set_default_timeout(ms). The documented escape hatch timeout_ms=0 disables the deadline entirely. Previously, disk-backed queries without an explicit timeout_ms ran until the harness killed them; the new default returns a structured timeout error after 10 s with hints pointing at anchoring / index usage. (Also applies to transaction-level cypher().)

  • Cypher timeout error message now carries remediation hints. Replaces the bare string Query timed out with guidance on anchoring queries, raising timeout_ms, or using the timeout_ms=0 escape hatch.

  • set_default_timeout(None) behaviour updated. Passing None now falls through to the backend-aware default rather than meaning “no timeout”. Pass 0 for the old behaviour explicitly.

[0.8.6] — 2026-04-19

Performance

  • describe(connections=['T']) fast path on disk graphs. Rewrote write_connections_detail to use the persisted conn_type_index_* inverted index instead of three full edge_references() sweeps. The previous path materialised every visited edge into a per-query edge_arena that was never cleared mid-call, growing VSZ linearly with scanned edges — on Wikidata (863 M edges) a single describe(connections=['P31']) call was SIGKILLed by the kernel after exhausting VM. The new path:

    • Reads pair counts from type_connectivity_cache when populated (zero edge I/O).

    • Skips the property-stats scan entirely when the connection type’s metadata declares no edge properties.

    • Walks only matching edges via the inverted index, capped at two samples via an early-exit callback.

    • Measured on Wikidata (wikidata_disk_graph_p12rebuild, 122 M nodes, 863 M edges, cold page cache):

      • describe(connections=['P170']) (1.3 M edges): 108 s → 0.24 s (~450× faster; previous in-flight code held VSZ at +27 GB after 90 s without completing).

      • describe(connections=['P31']) (122 M edges): 0.25 s (was SIGKILLed by OOM killer before this change).

      • describe(connections=True) unchanged at ~0.15 s.

Changed

  • describe(connections=['T']) pair-breakdown now capped at 50 entries by default (sorted by count desc), overridable via a new max_pairs keyword argument. Wide fan-out connection types like Wikidata’s P31 have tens of thousands of distinct (src_type, tgt_type) pairs — P31 alone has 191 k — which produced ~13 MB of XML that overshot typical MCP response budgets. The cap emits <endpoints total="N" shown="…"> plus a trailing <more pairs="…" edges="…"/> marker so agents see both the dominant relationships and the exact size of the tail. P31 output drops 13 MB → ~4 KB by default; pass max_pairs=500 (or similar) to drill into the full distribution on demand.

Added

  • GraphBackend::for_each_edge_of_conn_type — monomorphic closure iterator yielding (src, tgt, edge_idx, properties) per match. On disk uses the inverted index and never materialises EdgeData; on Memory/Mapped filters petgraph’s resident edge_references. The callback returns bool so callers can stop after a bounded prefix.

  • DiskGraph::edge_properties_at(edge_idx) — borrow an edge’s property slice without going through the materialize_edge arena.

  • describe(..., max_pairs=<int>) keyword argument — controls the pair-breakdown cap described above. None (default) resolves to 50.

[0.8.5] — 2026-04-19

Internal: test coverage, SAFETY docs, storage module reorganization.

[0.8.4] — 2026-04-19

Performance

  • Correlated-equality pushdown in the Cypher planner. WHERE cur.prop = prior.other_prop — where prior is a node bound by an earlier MATCH — now pushes onto the current MATCH’s pattern as a new EqualsNodeProp matcher that the executor resolves per-row via the bound node’s property. When the probe-side property is indexed, the pattern executor then picks an indexed lookup instead of scanning all nodes of that type. Also pushes cur.prop = scalar_var (where scalar_var is projected by a prior WITH/UNWIND) as EqualsVar. WHERE stays as a safety-net filter. Fallback: unchanged behavior when no index exists.

  • add_connections(query=...) now runs the planner. Previously, the query path in add_connections went straight from parse → execute, skipping the entire planner — so no pushdowns (equality, IN, comparison), no spatial-join fusion, no LIMIT/DISTINCT pushdown. It now calls cypher::optimize like g.cypher() does. Combined with the correlated-equality pushdown, the Sodir prospect graph’s derived connections (Phase 7) now build ~8.5× faster — 29.6 s → 3.5 s:

    • 7a HC_IN_FORMATION (3 UNION ranks): 11.0 s → 1.2 s (~9×)

    • 7b/7c StructuralElement ENCLOSES: 0.6 s → 0.05 s (fuse_spatial_join now fires here)

    • 7d PLAY_HAS_FORMATION (primary + fallback): 17.3 s → 1.5 s (~11×)

[0.8.3] — 2026-04-19

Performance

  • Spatial-join operator for MATCH (s:A), (w:B) WHERE contains(s, w). A new planner pass (fuse_spatial_join) rewrites this two-pattern containment shape into Clause::SpatialJoin, bypassing the cartesian product. The executor builds an R-tree over the container side (via the new rstar dependency), iterates the probe side once, and emits only matching (container, probe) pairs — O((N+M) log N + K) rather than O(N·M). Speedups on tests/bench_spatial.py (release build):

    • contains 500K pairs (500 polygons × 1 K points): 86.96 ms → 0.52 ms (~167×)

    • contains 2.6M prospect_shape (263 complex polygons × 10 K points): 480.51 ms → 3.32 ms (~145×)

    • contains 100K pairs: 17.65 ms → 0.55 ms (~32×)

    • Complex polygons (50 vertices): 18.29 ms → 0.24 ms (~76×)

    Fires when both types have SpatialConfig (container needs geometry, probe needs location), the two patterns are disjoint typed nodes with no edges, and the WHERE is contains(var, var) optionally ANDed with a residual predicate. Other shapes (NOT contains, constant-point contains(a, point(…)), intra-pattern edges, three-plus patterns, disjunctions) fall back to the existing per-row fast path unchanged.

[0.8.2] — 2026-04-19

Changed

  • Blueprint loader rewritten in Rust. kglite.from_blueprint() now runs entirely in a new src/graph/blueprint/ module (schema + CSV reader + filter DSL + geometry + timeseries + build orchestrator). pandas is no longer touched during ingestion — CSVs are parsed with the csv crate straight into the internal columnar DataFrame, then handed to mutation::maintain::add_nodes / add_connections.

    • Every parallelisable phase is pipelined: CSV pre-parse, per-spec prep (filter + geometry + typed-column build), FK edge DataFrames, and junction edge DataFrames all run across threads via rayon. Only the graph mutation calls (add_nodes / add_connections) stay serial, because the graph is &mut. GeoJSON→WKT centroid extraction is also parallelised per row.

    • The Python shim (kglite/blueprint/__init__.py, ~60 lines) now only handles optional save + schema lock on top of the native build. The old 831-line kglite/blueprint/loader.py is deleted.

    • Sodir blueprint (564 K nodes, 759 K edges): 9.87 s → 1.6 s (~6×).

    • Node / edge counts match the previous Python loader exactly (parity verified per-type across all 90 node types and all 93 edge types).

    • New runtime deps: csv, geojson, indexmap (the last so node / sub-node iteration preserves blueprint JSON order, which in turn keeps edge counts byte-identical to the old loader).

    • Set KGLITE_BLUEPRINT_PROFILE=1 for a per-phase / per-sub-phase ms breakdown on stderr.

[0.8.1] — 2026-04-19

Changed

  • code_tree rewritten in Rust. The polyglot codebase parser previously implemented in Python (kglite/code_tree/*.py, ~7,500 LOC) is now a first-class Rust module (src/code_tree/) exposed via PyO3. All eight language parsers (Python, Rust, TypeScript/JavaScript, Go, Java, C#, C, C++) plus the builder and manifest readers run natively. Tree-sitter grammars are bundled into the native extension — no optional dependency needed. pip install kglite[code-tree] is no longer required; the [code-tree] extras entry has been removed.

  • abi3 wheel — one wheel per platform, Python 3.10+. PyO3’s abi3-py310 stable-ABI target is now enabled, collapsing the CI wheel matrix from 20 wheels (5 Python versions × 4 platforms) to 4. Users on any Python ≥ 3.10 install the same wheel.

  • Parallel parsing via rayon + thread-local parsers. File-level parsing runs across CPU cores with one tree-sitter Parser per thread (via thread_local!) — no Mutex contention.

  • Parallel CALLS-edge tier resolution. The 5-tier name-matching pass (84 K functions → 200 K edges) now runs in parallel via rayon; each function’s edges are independent.

  • Aho-Corasick for USES_TYPE. The multi-pattern type-name scan replaces a giant regex alternation with an Aho-Corasick automaton, yielding ~2.5× faster USES_TYPE edge building on Java-scale corpora.

  • End-to-end performance on real repos:

    • duckdb C++ (2,805 files): 29 s (Python) → 0.63 s (Rust) — ~46×

    • neo4j Java (7,966 files, 84 K functions): crashed in Python → 1.69 s in Rust

    • KGLite mixed Py+Rust (248 files): 0.17 s

  • New code_tree module shape. kglite/code_tree/__init__.py is a 4-line shim importing from the native kglite._kglite_code_tree submodule. The previous Python modules under kglite/code_tree/ have been removed.

Fixed

  • build() no longer crashes on pure-Java repos (e.g. neo4j/neo4j) with Source type 'Struct' does not exist in graph. Edge routing now picks source/target node types per-row from the graph schema rather than defaulting to hardcoded names.

[0.8.0] — 2026-04-18

Internal-only storage-architecture refactor plus a handful of disk-mode bug fixes and large performance wins. No Python API signature changes. kglite/__init__.pyi signatures are byte-identical to v0.7.17 (git diff v0.7.17 HEAD -- kglite/__init__.pyi contains only docstring additions). Users upgrading from 0.7.x will see no behavioural differences other than the fixes and performance gains listed below.

Fixed

  • Concurrent load_ntriples calls no longer wipe each other’s spill directories. The previous cleanup logic deleted all other kglite_build_* directories in /tmp at every ingest start. Two load_ntriples calls running at the same time (e.g. a long Wikidata build and a small test suite) would kill each other’s property-log files, causing the in-flight build to crash at Phase 1b with No such file or directory. The cleanup now only removes spill dirs whose contents haven’t been modified in the last hour, so active builds are always safe.

  • save() on Wikidata-scale disk graphs no longer appears to hang. On large disk graphs (e.g. 124 M nodes / 88 K types from N-Triples ingest), save() was iterating self.column_stores and writing each type’s columnar data as a separate columns/<type>/columns.zst zstd file — a multi-hour serial loop, redundant because the v3 single-file columns.bin (written during Phase 1b of the N-Triples builder) already contains everything the loader needs. save_disk now skips the per-type loop when columns.bin exists on disk, and reduces to the metadata flush it was always meant to be. Measured on Wikidata (124 230 686 nodes, 862 810 243 edges, 88 931 types): save() went from ≥ 60 min → 5.52 s. In-memory graphs persisted to disk still write the per-type files as before (that path never produces columns.bin).

  • Disk-mode add_nodes(conflict_handling="update") now applies property updates. Previously on disk graphs, re-inserting an existing node via add_nodes(..., conflict_handling="update") silently dropped the new values — node_weight_mut materialised NodeData into a per-query arena that clear_arenas discarded before the next read, so the mutation never reached DiskGraph::column_stores where reads happen. The batch-update path now mutates the per-type column store directly via Arc::make_mut and re-syncs with sync_disk_column_stores at the end of the chunk. Memory and mapped graphs are unaffected (they already worked).

  • Disk-mode add_nodes(conflict_handling="replace") now clears omitted properties. Same root cause as above; Replace now nulls out every previously-set property on the row before writing the new set, matching the PropertyStorage::replace_all semantics of the heap backends.

  • Disk-mode MERGE edges are visible to subsequent MATCH queries. DiskGraph used to default defer_csr = true so every add_edge on a fresh graph queued into pending_edges, which edges_directed never reads. One-off Cypher mutations now route directly to the overflow buffer (visible immediately); bulk loaders (add_connections, ntriples) still use the pending+rebuild path via build_csr_from_pending.

Performance

  • Cypher query primitives faster across the board vs v0.7.17 (N=20 trials, macOS dev box). Memory and mapped modes both win:

    • pattern_match at 10 k nodes: −60 %

    • two_hop_10x: −24 %

    • describe(): −21 % (memory), −24 % (mapped)

    • pagerank: −17 %

    • find_20x: −13 % (mapped)

    • Construction sweep (1 k / 10 k / 50 k nodes): −11 % to −22 %

    • No memory-mode query regressed above the +5 % gate; only four cells flagged under 5 % (find_20x_memory +4.7 %, simple_filter / multi_predicate minor noise).

  • N-Triples disk-graph build is 2.5 % faster on Wikidata. Added #[inline(always)] on the hot GraphBackendGraphRead/GraphWrite trampolines (node_type_of, edge_endpoint_keys, edge_endpoints, node_weight) and a new closure-based GraphBackend::for_each_edge_endpoint_key that bypasses the boxed-iterator virtual-dispatch on hot edge iteration. Phase 1b (columnar write) −86 s, Phase 2 (edge creation) −32 s, Phase 3 (CSR build) −55 s on a 7.65 B-triple / 862.8 M-edge build. Total load_ntriples: 4747 s → 4627 s.

  • rebuild_caches() is 28 % faster on large disk graphs. Two fixes: (a) compute_type_connectivity is now Rayon-parallel on the disk backend — shards the edge range across all cores and merges per-shard HashMaps serially, matching build_peer_count_histogram’s pattern; (b) removed a madvise(DONT_NEED) call at the end of build_peer_count_histogram that was evicting the 13.8 GB edge_endpoints from page cache right before compute_type_connectivity had to re-read it. Also reordered rebuild_caches to run compute_type_connectivity first so its sequential sweep warms the cache for the histogram builder. Measured on Wikidata (862.8 M edges): 235 s → 169 s. Memory and mapped modes unaffected (serial path retained).

Changed

  • Deterministic .kgl v3 saves. save() now produces byte-identical output for identical graphs regardless of per-process HashMap randomisation. write_graph_v3 iterates column_stores in sorted order and canonicalises the metadata JSON (object keys sorted). Old .kgl files load unchanged — the format on the wire is a strict subset of the previous format’s possible outputs. Enables byte-level golden-hash format-drift tests.

  • ConnectionTypeInfo serialises with sorted keys. source_types and target_types (HashSet) and property_types (HashMap<String, String>) now emit in lexicographic order, hardening the v3 golden-hash invariant for fixtures richer than single-element sets. Existing .kgl files load unchanged.

Changed (internal, not user-visible)

  • Internal reorganization — src/graph/ split into domain subdirectories. Code previously flat in src/graph/ now lives under algorithms/, languages/cypher/, features/, introspection/, io/, mutation/, pyapi/, core/ (shared primitives, was query/), and storage/. storage/ further splits into memory/, mapped/, and disk/ per-backend folders. Pure file moves via git mv (rename similarity 97–100 %; git blame preserved). Filenames cleaned of redundant prefixes / suffixes (pymethods_**, filtering_methodsfiltering, etc.). See ARCHITECTURE.md for the final layout.

  • Every .rs under src/graph/ is now at or under the 2,500-line hard cap. The Phase 9 split carved nine god files (12,144-line executor.rs down through the 2,610-line pattern_matching.rs) into themed submodules. GOD_FILE_EXCEPTIONS is empty; test_god_file_gate passes unconditionally.

  • MappedGraph promoted to a distinct struct (was a type alias for MemoryGraph pre-Phase 5). Per-backend impl GraphRead / impl GraphWrite land in src/graph/storage/impls.rs, setting up future backend-specific optimizations without breaking callers.

  • RecordingGraph<G> ships as a Rust-only validation wrapper. Generic over any G: GraphRead, logs every read-path method call. Used internally to prove the architecture is actually open/closed — adding a new backend is a 3-src-file change. Not exposed to Python. See docs/adding-a-storage-backend.md for the worked example.

  • Testing envelope hardened. New parity tests cover zero-node / single-edge / 1 000-hop / Unicode / type-promotion / null-NaN / 100 000-row cypher results across memory / mapped / disk (tests/test_edge_cases_parity.py). Golden-fixture regression suite (tests/test_golden.py + tests/golden/) pins byte-exact output for a deterministic 1 000-node / 3 000-edge graph across every storage mode. New @pytest.mark.stress tier for the 30 GB mapped bench and 10 k-hop traversal.

  • Unsafe-block hygiene. All 40 unsafe { ... } blocks in src/ carry // SAFETY: justifications. A module-level invariants block at the top of src/graph/storage/mapped/mmap_vec.rs documents the shared mmap safety contract.

  • Python API docstring clarification. The find() docstring now warns that it searches only code-entity node types (Function, Struct, Class, Enum, Trait, Protocol, Interface, Module, Constant). The signature is unchanged.

  • Deprecated TempDir::into_path() calls migrated to TempDir::keep() per tempfile 3.14+ API.

  • pub type Graph = GraphBackend alias dropped. Every call site uses GraphBackend directly. Removes a hygiene wart flagged in the Phase 9 report-out.

  • RecordingGraph audit methods (log, log_len, drain_log) are now #[cfg(test)] rather than #[allow(dead_code)]. Release builds no longer compile these helpers at all.

[0.7.17] — 2026-04-17

Added

  • Python 3.14 wheels. CI test matrix and build_wheels.yml now cover 3.14 across Linux/macOS (Intel + arm64)/Windows, alongside 3.10–3.13. Full test suite passes on 3.14 (1758 tests, same as 3.12 minus the optional code-tree tests that require tree-sitter wheels). pyo3 0.28 (shipped in 0.7.16) enables this via ABI3_MAX_MINOR = 14.

[0.7.16] — 2026-04-17

CI-fix release on top of 0.7.15. No functional changes to the Cypher engine; dependency bumps + clippy 1.95 compatibility only.

Dependencies

  • pyo3 0.27 → 0.28, geo 0.29 → 0.33, wkt 0.11 → 0.14, bzip2 0.5 → 0.6. API changes absorbed: #[pyclass(skip_from_py_object)] on KnowledgeGraph (pyo3 0.28 opt-in); Geodesic is now a static value (call as Geodesic.distance(...) / length(&Geodesic)) with the LengthMeasurable trait imported from geo::line_measures.

  • Clippy 1.95 compat: sort_bysort_by_key(Reverse), collapsed if/ match guard patterns, file_len.checked_div(elem_size), removed redundant .into_iter() in IntoIterator args.

[0.7.15] — 2026-04-17

Added

  • WHERE n:Label predicate. Cypher now supports label checks as boolean predicates (not just MATCH-level filters). Composes with AND/OR/NOT and chained n:A:B form (n:A AND n:B). Example: MATCH (n) WHERE n:Person OR n:Org RETURN count(n).

  • Value::as_str() -> Option<&str>. Borrowing companion to the existing as_string(). Prefer when ownership is not required — avoids the per-call String clone.

Changed

  • Function names lowercased at parse time instead of per-row during dispatch. Every Cypher scalar/aggregate dispatch used to call .to_lowercase() on the function name each time it evaluated a row (21+ sites); names are now normalized once in parse_function_call and compared directly. Pure CPU win on function-heavy queries.

  • count(DISTINCT n) uses typed identity setsHashSet<usize> keyed on node/edge indices (with a HashSet<Value> fallback for non-binding expressions) instead of per-row format!("n:{}", idx.index()) string formatting. ~20–26% faster on DISTINCT-count queries.

  • substring() skips intermediate Vec<char> — uses chars().skip(start) .take(len).collect() instead of materializing the full char vector. ~10–18% faster on substring-heavy queries.

  • Zero-allocation property iterators. PropertyStorage::keys() and ::iter() return explicit PropertyKeyIter / PropertyIter enums instead of Box<dyn Iterator>. Saves one heap allocation per keys(n) / RETURN n {.*} / property-scan call. ~10% faster on keys(n) over all nodes.

Fixed

  • HAVING with aggregate expressions. HAVING count(m) > 1 was silently returning zero rows when the RETURN item was aliased (count(m) AS c). Root cause: the aggregate function call fell through to per-row scalar dispatch, which errored with “Aggregate function cannot be used outside of RETURN/WITH”, and the error was swallowed by unwrap_or(false), dropping every row. Now HAVING count(m) and HAVING c both resolve to the pre-computed aggregate value regardless of aliasing. Unaliased, DISTINCT, and no-group-by forms all covered.

  • rand() / random() correctness under tight loops. The previous SystemTime-per-call seeding could return identical values for adjacent rows when the system clock resolved two calls to the same nanosecond, and constant folding could collapse rand() to a single value for the whole query. Replaced with a thread-local xorshift64 PRNG, seeded once per thread with a splitmix64-avalanched counter (so parallel Rayon workers don’t collide), and marked as row-dependent so it bypasses constant folding. Also uses the top 53 bits of state for full f64 mantissa precision.

[0.7.14] — 2026-04-17

Added

  • Per-(conn_type, peer) edge-count histogram as a persistent disk cache. Built once at CSR-build time (parallelised via Rayon, single sequential scan of edge_endpoints.bin), stored as three flat peer_count_*.bin files. Unanchored aggregate queries like MATCH (a)-[:TYPE]->(b) RETURN b.title, count(a) ORDER BY cnt DESC LIMIT N now return in ~ms instead of scanning the full 13 GB edge_endpoints array. Rebuildable on existing disk graphs via g.rebuild_caches() without a full graph rebuild.

  • FusedCountAnchoredEdges planner rule + executor. MATCH (var)-[r:TYPE?]->({id: V}) RETURN count(var) (and the three symmetric variants) is now fused into O(log D) CSR offset arithmetic. The anchor is resolved to a NodeIndex at plan time via graph.id_indices. Combined with the tombstone short-circuit (below) this turns hub-node count queries (e.g. ~40 M incoming edges on Q5) from 100 s TIMEOUTs into sub-second lookups.

  • Tombstone-free short-circuit in count_edges_filtered. When no nodes/edges have been removed and no peer-type filter is set, the function returns end - start + overflow_count directly after binary-searching for the connection-type range — skipping the per-edge tombstone check on hot hubs. Adds a has_tombstones: bool flag to DiskGraph and DiskGraphMeta (defaults to conservative true on legacy graphs so correctness is preserved; new builds flip it false).

  • Bounded sources_for_conn_type. DiskGraph::sources_for_conn_type_bounded(conn_type, max) stops copying source node IDs after max entries, avoiding the ~400 MB eager heap allocation on cold-cache LIMIT-bounded pattern-matching queries. pattern_matching.rs now passes the source_cap through so e.g. LIMIT 10 queries only read 1 000 sources from conn_type_index_sources.bin on first access.

  • FusedCountTypedEdge uses cached edge-type counts. A one-liner that had been missed in v0.7.12: MATCH (_)-[:TYPE]->(_) RETURN count(*) now returns edge_type_counts[TYPE] in O(1) instead of scanning edge_weights() (64 s → sub-millisecond on Wikidata’s 862 M edges).

  • rebuild_caches refreshes the peer-count histogram on existing disk graphs, so users don’t need to rebuild from scratch to get the v0.7.14 aggregate speedups.

Fixed

  • DataFrame / blueprint disk builds now rebuild indexes at save time. Previously, the first add_connections batch triggered a CSR build (via ensure_disk_edges_built) which wrote conn_type_index and peer_count_histogram reflecting only that first batch’s edges. Subsequent batches added edges to overflow but never refreshed those indexes. Fix: save_disk now calls compact() once when overflow has accumulated, merging overflow back into CSR and rebuilding the indexes from all live edges. The per-batch ensure_disk_edges_built is now a no-op for overflow purposes (no O(E²) cost during multi-batch builds).

  • lookup_peer_counts returns None on type miss. Previously returned Some(empty_map), which blocked the caller from falling back to the sequential-scan path when the histogram was stale. Now returns None so callers see a clean cache miss.

  • Deadline checks in anchored-count paths. try_count_simple_pattern / count_edges_filtered now accept an Option<Instant> deadline and check it every 1 M iterations. Closes the bypass that let Q5_count_P31_incoming run to 100 s past the 20 s default timeout.

  • Deadline check in expand_var_length_fast inner loop. The outer queue loop was already checked every 512 pops, but the per-edge inner loop was unbounded — a single hub expansion could process 100 M+ edges without checking. Added an inner check every 1 M iterations.

  • Benchmark metric: Wikidata unanchored_P31_count now returns in 0.7 ms (was 64 s with a wrong answer on cold deadline checks), Q5_count_P31_incoming 615 ms (was 100 s TIMEOUT), Q5_incoming_all_count 670 ms (was 20 s TIMEOUT), cross_type_limited 3 ms (was 2.5 s), limit_10_P31 10 ms cold-cache (was 2.6 s).

[0.7.12] — 2026-04-16

Added

  • Parallel Phase 3 CSR build: The per-node out_edges sort-by-connection-type and the conn_type_index inverted-index build are now Rayon-parallelised. On a 124 M-node / 862 M-edge Wikidata build, this cuts combined CSR-build wall-clock on the parallelised portions from ~1000 s serial to ~100-200 s on 8+ P-cores. Build output is bit-identical to the serial version (index source lists are sorted post-reduce for determinism).

  • Deadline enforcement on long edge scans: count_edges_grouped_by_peer (used by fused aggregate top-K and streaming HAVING paths) now accepts an optional deadline and checks it every 1 M edges. Pattern-matching’s parallel expansion short-circuits when any thread detects a timeout. Together these stop unanchored aggregate queries from running unbounded past the default 20 s timeout.

  • Disk mode iterative updates: Loaded disk graphs now support add_connections() — new edges go directly to overflow and are immediately visible to queries without CSR rebuild.

  • compact() method: Merges overflow edges back into CSR arrays via full rebuild. Call after accumulating significant overflow (e.g., >10% of edges) to restore optimal query performance.

  • Connection-type inverted index for overflow: sources_for_conn_type() now includes nodes with overflow edges, so Cypher queries on new edge types work immediately.

  • Partitioned CSR build parity: Out-edges now sorted by connection type (enables binary search), and connection-type inverted index built for both CSR algorithms.

Fixed

  • Streaming HAVING aggregate no longer OOMs: MATCH ...-[...]->(...) RETURN group_key, count(...) HAVING ... ORDER BY ... (without LIMIT) used to materialise all edge rows before grouping — a 10 GB materialisation on Wikidata-scale graphs that triggered macOS OOM kill. The planner now fuses this shape into FusedMatchReturnAggregate; the executor’s non-top-k path uses edge-centric count_edges_grouped_by_peer and applies HAVING post-aggregation on the small group-by map. On 16 GB hosts, queries that previously SIGKILL’d the Python process now return a clean “Query timed out” error.

  • Benchmark CSV preserved across crashes: bench/benchmark_wikidata_cypher.py now streams each query’s result to the CSV row-by-row with per-row flushes, instead of batching the write at the end. SIGKILL / OOM / Ctrl-C mid-run leaves every completed row on disk.

  • Mmap lifecycle during CSR build: CSR build now writes to a temporary directory, then atomically swaps files into place. Fixes panics on large DataFrame builds where CSR output overwrote mmap’d files.

  • Overflow edges missing from edges_directed: The edges_directed_filtered_iter iterator now correctly includes overflow edges (was passing None for overflow parameter).

  • Column store corruption on save→load→save cycle: write_packed() now handles mmap-backed column stores (from loaded disk graphs) by materializing data from the MmapColumnStore. Also skips writing empty schema columns that duplicate id/title columns.

  • defer_csr not reset after CSR build: After the first CSR build, defer_csr stayed true, causing all subsequent add_edge() calls to route to pending_edges instead of overflow. Each CSR rebuild then lost all previous edges. Fixed by setting defer_csr = false in build_csr_from_pending().

  • edge_weight_mut for disk mode: Implemented mutable edge property access for disk graphs, required by add_connections with duplicate edge handling (e.g., blueprint builds with temporal edge properties).

  • Disk graph save_to_dir missing metadata: disk_graph_meta.json and conn_type_index files were only written to data_dir, not to target_dir when saving to a different directory. Fixed save_to_dir to write metadata to the target.

  • N-Triples mapped mode used compact IDs: Mapped mode incorrectly used disk-style compact integer IDs instead of string IDs. Now matches memory mode behavior.

  • enable_columnar() title column mismatch: Columnar nodes with missing titles in old stores got no title pushed, causing title column length < row_count. Save→load then failed with “blob too small”. Fixed by always using node.title as fallback.

  • edge_weight_mut arena offset bug: The flush logic assumed all edge_weight_mut entries were contiguous at the end of the arena, but read-only edge_weight calls interspersed between writes caused wrong offsets. Replaced arena-based tracking with a dedicated edge_mut_cache HashMap.

  • N-Triples mapped mode used compact edge path: use_compact was true for mapped mode, sending it through create_edges_compact() instead of create_edges_strings(). Mapped now uses the memory-mode path for everything.

  • InternedKey hash is now deterministic across processes: InternedKey::from_str previously used DefaultHasher (SipHash with a per-process random seed). Since DiskNodeSlot.node_type persists this as raw u64 on disk and the loader resolves it via the freshly-built interner’s hashes, disk graphs built in one process couldn’t be reliably loaded in another. Replaced with FNV-1a 64-bit (zero-alloc, zero new deps, deterministic). Breaking change for existing disk graphs saved with an older kglite: their node_type u64 values were hashed with a random SipHash seed and will not resolve against the new interner. Rebuild affected disk graphs.

  • Disk mode save/load loses embeddings, timeseries_store, and parent_types: save_disk and load_disk_dir only persisted the FileMetadata struct, which didn’t include parent_types and omitted embeddings/timeseries entirely. Describe() output on reloaded disk graphs was missing the “core vs supporting” tier split and <embeddings> section. Fix: added parent_types to FileMetadata, and save/load embeddings.bin.zst and timeseries.bin.zst alongside the other disk artifacts.

  • describe() non-deterministic across processes: compute_join_candidates iterated node_type_metadata (HashMap) and broke sort_by ties with insertion order. Different HashMap RandomState seeds produced different candidate orderings, making checksums unstable. Property iteration is now sorted by name, and the candidate sort uses (overlap desc, left_type, right_type, left_prop) as a stable key.

  • Disk mode DataFrame/blueprint builds: wrong node titles/properties after multiple types: batch_operations assigned DiskNodeSlot.row_id by slot index (set in add_node) instead of the per-type column store row returned by push_row. Pass 2 tried to fix this via node_weight_mut, but that call materializes into an arena that gets cleared on the next call — so the correction never persisted. Once a second node type was added, slot indices diverged from column store rows, causing n.title/n.id to read wrong rows (and None for out-of-bounds slots). Fix: batch_operations now also calls DiskGraph::update_row_id after each deferred assignment. Raises api_benchmark.py from 38/51 to 49/51 across all 3 modes.

  • Column store schema rebuild drops titles: When batch_operations rebuilds a column store due to schema growth, titles for existing rows could be lost if get_title() returned None. Fixed by always pushing Null fallback.

  • save_disk() now persists type_indices.bin.zst and id_indices.bin.zst: Previously only written by the N-Triples builder, DataFrame/blueprint-built disk graphs now also persist these files for correct and fast reload.

  • write_packed() preserves all schema columns: Empty schema columns are now written with null padding instead of being skipped, ensuring lossless metadata round-trip through save→load cycles.

[0.7.10] - 2026-04-16

Added

  • Connection-type inverted index: Built during CSR construction, maps edge types to source node IDs. Enables instant lookup of “which nodes have P31 outgoing edges” for unanchored edge queries. Cold-cache MATCH (a)-[:P31]->(b) LIMIT 50 improved from 14.5s to 4.6s.

  • madvise hints for edge scans: Sequential/DontNeed advisories on edge_endpoints during full-graph aggregation to reduce page cache pollution.

Changed

  • FusedNodeScanTopK: New fused clause for MATCH (n:Type) RETURN n.prop ORDER BY n.prop LIMIT K — single-pass scan with inline top-K selection, avoids materializing all rows. String sort keys supported.

  • Streaming top-K for FusedMatchReturnAggregate: Iterates group nodes directly from type_indices instead of materializing all PatternMatch objects.

  • Edge-centric aggregation: For untyped group nodes, scans edge_endpoints sequentially with HashMap accumulation instead of per-node iteration.

  • Lightweight peer iteration: expand_from_node skips edge_endpoints reads when edge variable is unnamed (disk-only, reduces I/O by ~50%).

[0.7.9] - 2026-04-16

Changed

  • Zero-allocation edge counting: count() queries on edge patterns use a new fast path that iterates CSR edges without materializing EdgeData. With sorted CSR, uses binary search to narrow to matching edge type. Result: “count instances of City” dropped from 2.3s to 37ms (63x faster).

  • WHERE-MATCH fusion: The executor detects MATCH followed by WHERE and evaluates the WHERE predicate inline during pattern expansion. Non-matching rows are skipped immediately, and expansion stops after finding exactly LIMIT matching rows. Previously stuck queries (>10 min) now complete within timeout.

  • LIMIT push-down through WHERE: Extended push_limit_into_match to handle MATCH WHERE RETURN LIMIT pattern. The executor enforces exact LIMIT during fused WHERE evaluation.

  • Pre-computed edge type counts: Edge type counts are computed during CSR build (zero overhead — counted inline during endpoint materialization). Persisted to metadata so FusedCountEdgesByType is O(1) on reload.

Fixed

  • Wikidata type merge: Q-code types (e.g., “Q5”) now properly merge into human-readable labels (“human”) during N-Triples build. Previously, when both “Q5” and “human” existed as types, the merge was skipped — now indices and column stores are merged correctly.

  • Column store key remapping: Property log entries with old Q-code InternedKeys are remapped to merged label keys during Phase 1b, ensuring column stores have correct data after type merges.

[0.7.8] - 2026-04-15

Added

  • set_default_timeout(timeout_ms): Set a default per-query timeout (milliseconds) applied to all cypher() calls. Per-query timeout_ms overrides it.

  • set_default_max_rows(max_rows): Set a default cap on intermediate result rows. Queries exceeding this return an error with guidance to add LIMIT. Per-query max_rows overrides it.

  • cypher(max_rows=N): Per-query max rows limit parameter.

Changed

  • Cypher LIMIT push-down: Tightened source candidate cap from 10,000× to 100× the LIMIT value. Queries like MATCH (n:Type)-[:EDGE]->(m) RETURN ... LIMIT 10 on large types are now ~100x faster (avoids allocating the full type index).

  • Cypher pattern start-node optimization: Improved selectivity estimation for property-filtered nodes (equality filters now estimate /100 instead of /10). Lowered reversal threshold from 10× to 5×. Queries with filters on the target node (e.g., WHERE b.prop = 'X') are now 2-3× faster.

  • DiskGraph edge iteration: DiskEdges iterator now reads CSR edges lazily from the mmap instead of pre-collecting into a Vec. Eliminates O(degree) allocation per iterator — critical for high-degree nodes at Wikidata scale.

  • DiskGraph direct columnar property access: Property checks in Cypher WHERE clauses and pattern matching now read individual column values directly from the ColumnStore on disk graphs, bypassing full NodeData materialization. Eliminates arena allocation and unnecessary id/title reads — ~3x fewer mmap reads per property check.

  • DiskGraph CSR sorted by connection type: CSR edges are now sorted by (node, connection_type) during build. Edge-type filtering uses binary search instead of linear scan — O(log D + matching) instead of O(D) for high-degree nodes. Metadata flag csr_sorted_by_type ensures backward compatibility with older graphs.

  • Fused aggregation with WHERE clauses: FusedNodeScanAggregate now activates for queries with property filters (e.g., MATCH (n:Entity) WHERE n.pop > 1M RETURN n.continent, count(n)). FusedMatchReturnAggregate now supports property filters on the unbound (counted) node. Both avoid materializing intermediate result rows.

[0.7.7] - 2026-04-15

Added

  • Schema locking: lock_schema() / unlock_schema() enforce the graph’s known schema on Cypher mutations (CREATE, SET, MERGE). Invalid writes return descriptive errors with “did you mean?” suggestions via edit-distance matching. Works on any graph — locks against node_type_metadata and connection_type_metadata.

  • from_blueprint(lock_schema=True): Convenience parameter to lock the schema immediately after blueprint loading.

  • schema_locked property: Check whether the schema is currently locked.

  • describe() schema-locked notice: When schema is locked, describe() includes a <schema-locked> element so agents know writes will be validated.

[0.7.6] - 2026-04-12

Fixed

  • Silent data loss on incremental save/load: Loading a .kgl file, adding or updating nodes, then saving and loading again would silently lose properties for the new/updated nodes. The v3 column writer now always consolidates all node properties (Compact, Map, and Columnar) into column stores before writing.

  • Corrupt .kgl file on re-save: Simply loading and re-saving a .kgl file (with no changes) could produce a corrupt file that failed to load with blob too small for offsets. The v3 column loader was building the ColumnStore schema from node_type_metadata (which includes id/title fields) instead of from the column section metadata (which only has property columns), creating empty placeholder columns that corrupted on write.

  • enable_columnar() dropped Columnar nodes on rebuild: When rebuilding column stores, nodes already using Columnar storage were skipped, then their old stores were replaced — losing their properties. Now reads properties from old Columnar stores during rebuild and preserves mapped-mode id/title columns.

[0.7.5] - 2026-04-10

Added

  • describe() extreme-scale support: Adaptive output for graphs with thousands or millions of types. Four scale tiers: Small (≤15 types, inline detail), Medium (16-200, compact listing), Large (201-5000, top-50 + search hint), Extreme (5001+, statistical summary).

  • describe(type_search='...'): Find types by name with 1-layer neighborhood fan-out. Returns matching types with their connections plus connected types — enables domain discovery in a single call.

  • rebuild_caches(): Force computation of type connectivity, edge type counts, and connection endpoint types in a single O(E) pass. Caches are persisted by save() and restored by load().

  • Type connectivity cache: Pre-computed type-level graph (src_type, conn_type, tgt_type, count) triples. Makes type_search and describe(types=[...]) instant on any scale.

  • Lazy connectivity compute: For Large/Extreme graphs, type connectivity is computed on first type_search call and cached for the session.

Changed

  • describe() on Wikidata: Output reduced from 2.9MB/508s to 3KB/0.15s. type_search with warm cache is sub-millisecond (was 2082s).

  • Performance guards: Sampled neighbor schema for types >50K nodes, skip join candidates for >200 types, bounded error messages for large graphs.

  • Connection overview capping: describe(connections=True) caps at 50 connection types for graphs with >500 connection types.

  • Empty endpoint resolution: Disk-imported graphs with empty connection_type_metadata endpoints get source/target types resolved via bounded edge scan.

  • CSR build: in_edges merge sort: Replaced scatter-write with merge sort for in_edges (1407s → 259s, 5.4× faster). Power-law target distributions caused page cache thrashing with scatter; merge sort uses only sequential I/O.

  • CSR build: zero-fill elimination: mapped_zeroed creates mmap files at full size without writing zeros — OS lazy-fills pages on demand. Saves 13.8 GB of writes.

  • CSR build: edge_endpoints reuse: Steps 3-4 read from edge_endpoints (written in step 1) instead of re-reading pending_edges. Eliminates one redundant 13.8 GB copy.

  • Wikidata build: 93 min → 73 min (Phase 3 CSR: 1842s → 631s).

  • Cypher backtick type names: MATCH (n:\programming language`)` now works. Pattern parser re-adds backticks when reconstructing identifiers with spaces.

  • Q-code type resolution: Post-Phase-1 pass resolves raw Q-code type names (e.g., Q13442814 → “scholarly article”) using the complete label cache. Single sequential scan of node_slots.

  • NTriples type connectivity: Type connectivity triples accumulated inline during edge creation, eliminating separate O(E) rebuild pass for freshly built graphs.

  • get_edge_type_counts() memory-safe: Fallback path uses edge_endpoint_keys() (mmap reads) instead of edge_weights() (which materialized all EdgeData → OOM on disk graphs).

[0.7.4] - 2026-04-08

Changed

  • CsrEdge 16 → 8 bytes: Removed conn_type from CSR edge records. Connection type stored only in EdgeEndpoints. Saves ~14 GB on Wikidata (out_edges + in_edges halved).

  • MergeSortEntry 24 → 12 bytes: Removed conn_type from sort entries. 2× more edges per sort chunk during CSR build.

  • Edge conn_type pre-filter: DiskEdges iterator checks edge_endpoints before materialize_edge(), skipping arena allocation and property HashMap lookup for non-matching edges.

  • Arena clearing at query boundaries: reset_arenas() called at start of every Cypher execution. Prevents unbounded memory growth across queries (was the OOM cause on Wikidata).

  • node_type_of() — zero-materialization type check: Reads directly from mmap’d node_slots (16-byte struct). Used in all Cypher executor fast paths and pattern matching hot loops instead of node_weight().

  • Edge properties fast path: materialize_edge() skips HashMap lookup when edge_properties is empty (common for Wikidata — 862M edges, zero properties).

  • Source node cap with LIMIT: Multi-hop patterns with LIMIT N only allocate PatternMatch objects for N × 10,000 source nodes instead of the full type.

  • expand_from_node limit propagation: Edge expansion stops after collecting enough results instead of eagerly materializing all matching edges.

  • id_indices built on load: Disk graphs build id_indices from column stores during load (no node materialization). Enables O(1) cross-type id lookup.

  • lookup_by_id_normalized trusts id_indices: When id_indices exist for a type, the O(1) lookup result is trusted without falling through to linear scan.

Added

  • DiskGraph::node_type_of() — O(1) node type lookup from mmap’d node_slots.

  • DiskGraph::reset_arenas() — public arena clearing for query boundary use.

  • DiskGraph::edges_directed_filtered_iter() — pre-filtered edge iteration by connection type.

  • GraphBackend::node_type_of(), edges_directed_filtered(), reset_arenas() — backend-agnostic wrappers.

  • DirGraph::build_id_index_from_columns() — builds id_indices directly from mmap’d column stores without node materialization.

  • WHERE id(n) = X pushdown in planner — converts id() function calls to inline {id: X} pattern properties.

  • Cross-type id lookup in find_matching_nodes — untyped {id: X} patterns try all types via id_indices (O(types) × O(1)).

  • estimate_node_selectivity returns 1 for any {id: X} pattern regardless of type.

Fixed

  • Typed edge queries on disk graphs returning 0 rows: has_connection_type() returned false when connection_type_metadata was empty (disk graphs skip O(types²) metadata). Fixed by falling back to interner check.

  • N-Triples build not registering connection type names: Added lightweight connection type metadata registration (names only, no type×type matrix).

[0.7.3] - 2026-04-08

Changed

  • Single-file mmap column storage: Column stores written to a single columns.bin file with mmap-backed reads. Replaces per-type columns/<type>/columns.zst layout. Near-instant load via mmap (no decompression).

  • Property log (disk mode): Phase 1 serializes properties to a zstd-compressed log file instead of building ColumnStores in-memory. Phase 1b replays the log to build columns in bulk — avoids O(n²) column rebuilds.

  • Partitioned CSR build: Default CSR algorithm switched to hash-partitioned (Kuzu pattern). Merge-sort still available via KGLITE_CSR_ALGO=merge_sort.

  • File-backed pending edges: pending_edges buffer uses mmap-backed MmapOrVec instead of heap Vec, avoiding ~14 GB heap allocation at Wikidata scale.

  • Auto-typing from P31: N-Triples loader automatically derives node types from P31 (instance-of) values, resolving Q-codes to labels. Entities without P31 default to “Entity”.

  • Sparse property overflow: Properties with <5% fill rate stored in a compact overflow bag instead of dense columns, reducing file size for wide schemas.

Added

  • MmapColumnStore — mmap-backed column reader for disk mode.

  • BuildColumnStore — direct column writer that streams to the mmap file.

  • PropertyLogWriter/PropertyLogReader — zstd-compressed property spill log for disk builds.

  • BlockPool/BlockColumn — block-allocated typed column storage.

  • TypeBuildMeta — per-type metadata for build-time column schema discovery.

  • MmapOrVec::load_mapped_region(), from_vec(), as_mut_bytes() — new helpers for region-mapped and bulk byte access.

  • DiskGraph::update_row_id() — fix per-type row_id mapping after column conversion.

  • ColumnStore::from_mmap_store(), from_raw_columns() — constructors for mmap-backed and direct-built stores.

  • ColumnStore id/title column accessors now work in disk mode.

Fixed

  • code_tree stack overflow: extract_comment_annotations switched from recursive to iterative traversal, fixing crashes on deeply nested ASTs.

[0.7.2] - 2026-04-07

Fixed

  • code_tree stack overflow: extract_comment_annotations switched from recursive to iterative traversal, fixing crashes on deeply nested ASTs.

[0.7.1] - 2026-04-06

Changed

  • CSR build: external merge sort (DuckDB-inspired). Replaced random-I/O scatter with external merge sort — sort chunks in memory, merge sequentially. All disk I/O is sequential. Phase 3 at Wikidata scale (862M edges, 16 GB RAM): ~16 min vs 90+ min previously.

  • Disk graph auto-persistence: CSR arrays and metadata written directly to graph dir during build. No separate save() step needed. Mutations (add_node, add_edge, etc.) auto-flush metadata.

  • Disk graph raw storage: Save/load uses raw .bin files (direct mmap) instead of zstd compression. Load is near-instant (mmap, no decompression). Legacy .bin.zst files still supported for loading.

  • Mmap-backed edge buffer: N-Triples loader streams edges to mmap during Phase 1 (0 heap for edge buffer). Eliminates 13.8 GB heap allocation at Wikidata scale.

Fixed

  • Memory leak in N-Triples loader: edge_buffer (13.8 GB at Wikidata scale) was kept alive during Phase 3 CSR build, doubling peak memory. Now dropped immediately after Phase 2.

  • Disk thrashing during CSR build: Random writes to mmap caused SSD thrashing. All writes are now sequential.

  • Temp file cleanup: CSR build temp files cleaned up immediately after merge. Drop impl flushes metadata as safety net.

[0.7.0] - 2026-04-05

Added

  • Disk storage mode: KnowledgeGraph(storage="disk", path="./my_graph") — fully disk-backed graph for very large datasets (100M+ nodes, 1B+ edges). Data lives on disk via mmap, using ~10% of equivalent in-memory RAM. The directory IS the graph — no separate save step needed.

  • GraphBackend abstraction: Unified API across InMemory (petgraph), Mapped, and Disk backends. All Cypher queries, fluent API, and graph algorithms work identically across all three storage modes.

  • CSR edge storage: Disk mode uses cache-friendly Compressed Sparse Row format. 3-4x faster than default on WHERE filters, SELECT, and SET operations at 100k scale.

  • zstd N-Triples support: load_ntriples() now accepts .nt.zst files — 30x faster decompression than bz2.

  • enable_disk_mode() method: Convert existing in-memory graph to disk-backed CSR.

  • path parameter on constructor: Required for storage="disk".

Changed

  • Mapped mode: Fixed O(n²) Arc clone bug — 50-300x faster add_nodes in mapped mode.

  • N-Triples loader: 81x faster via bulk columnar conversion, pipeline parallelism, zero-copy parsing, byte-level filtering, and dense Vec edge lookup.

Fixed

  • Schema extension bug in mapped mode incremental add_nodes.

  • add_connections() in disk mode auto-builds CSR so queries work immediately.

[0.6.18] - 2026-03-30

Fixed

  • Cypher LIMIT: 16x faster multi-hop traversals with LIMIT. MATCH (a)-[:R]->(b)-[:R]->(c) RETURN ... LIMIT 20 now pushes the limit into the pattern matcher — early termination at the last hop, overcommit budgets at intermediate hops. Benchmarks show parity with Neo4j on 2-hop queries.

[0.6.17] - 2026-03-30

Added

  • kglite.to_neo4j(graph, uri, ...) — push graph data directly to a Neo4j database using batched UNWIND operations. Supports clear/merge modes, selection export, and verbose progress. Requires the neo4j package (pip install neo4j or pip install kglite[neo4j]).

  • ResultView: Polars-style table display — repr() and print() now show a bordered table with column headers. Large results show first 10 + last 5 rows with separator.

  • ResultView: Improved help(ResultView) with quick-reference cheat sheet and examples on all methods.

Fixed

  • code_tree: Parse output (Found N files) now respects verbose=False — silent by default.

[0.6.16] - 2026-03-30

Changed

  • ResultView: Polars-style table display — repr() and print() now show a bordered table with column headers instead of ResultView(N rows, columns=[...]). Large results show first 10 + last 5 rows with separator.

  • ResultView: Improved help(ResultView) with quick-reference cheat sheet and examples on all methods.

  • code_tree: Parse output (Found N files) now respects verbose=False — silent by default.

[0.6.15] - 2026-03-30

Added

  • kglite.repo_tree(repo) / code_tree.repo_tree(repo) — clone a GitHub repository and build a knowledge graph in one call. Cloned files are cleaned up by default; pass clone_to= to keep them locally. Supports private repos via token= or GITHUB_TOKEN env var.

Fixed

  • code_tree: Auto-create stub nodes for external base classes, enums, and traits referenced in EXTENDS, IMPLEMENTS, and HAS_METHOD edges — eliminates all “rows skipped: node not found” warnings during graph building.

[0.6.12] - 2026-03-30

Fixed

  • BUG-21: Window functions (row_number, rank, dense_rank) crash with “Window function must appear in RETURN/WITH clause” when query has WITH aggregation + ORDER BY + LIMIT. The planner’s fuse_order_by_top_k optimization now skips fusion when RETURN contains window functions.

Changed

  • Extracted window function execution into window.rs module (~240 lines out of executor.rs)

  • Moved is_aggregate_expression / is_window_expression from executor.rs to ast.rs for cross-module reuse

[0.6.11] - 2026-03-29

Fixed

  • 19 Cypher engine bugs resolved — systematic fix of all bugs discovered via legal knowledge graph testing (BUG-01 through BUG-20, except BUG-04 which requires large-graph validation).

Critical — Silent wrong results

  • BUG-01: Equality filter + GROUP BY no longer returns empty results. WHERE clause is now preserved after predicate pushdown to guarantee correctness when fusion fails.

  • BUG-02: ORDER BY + LIMIT preserves integer types. count(), size(), sum() on integers no longer convert to float through the top-K heap path.

  • BUG-03: HAVING clause is now propagated when the planner converts RETURN to WITH in fused optional-match aggregation.

  • BUG-05: RETURN * expands to all bound variables (nodes, edges, paths, projected) instead of returning {'*': 1}.

  • BUG-06: Path variable on explicit multi-hop patterns (p = (a)-[]->(b)-[]->(c)) now captures all intermediate nodes and relationships. length(p), nodes(p), relationships(p) return correct results.

  • BUG-17: MATCH (n) WHERE n.type = 'X' on unlabeled nodes now works. Pattern matcher recognizes type/node_type/label as virtual properties.

  • BUG-18: labels() returns consistent list format in both plain RETURN and GROUP BY contexts. Single-element list comparison (labels(n) = 'Person') now works.

High — Errors on valid syntax

  • BUG-07: stDev() / stdev() recognized as alias for std() aggregate function.

  • BUG-08: datetime('2024-03-15T10:30:00') parses correctly instead of crashing on the time portion.

  • BUG-09: date() returns null on invalid input ('', '2016-00-00', '2016-13-01') instead of crashing.

  • BUG-10: date('...').year, .month, .day property access on function results now works.

  • BUG-11: [:TYPE1|TYPE2|TYPE3] pipe syntax for multiple relationship types in MATCH patterns.

  • BUG-12: XOR logical operator implemented with correct precedence (between OR and AND).

  • BUG-13: % modulo operator implemented for both integer and float operands.

  • BUG-14: head() and last() list functions implemented.

  • BUG-15: IN operator accepts variable references, parameters, and function results — not just literal [...] lists.

Medium — Less common patterns

  • BUG-16: Boolean/comparison expressions (STARTS WITH, CONTAINS, >, =~, etc.) work in RETURN/WITH clauses, evaluating to boolean values.

  • BUG-19: null = null and null <> null return null (Cypher three-valued logic) instead of syntax error.

  • BUG-20: Map all-properties projection n {.*} supported.

Added

  • Expression::PredicateExpr — AST variant bridging the expression/predicate boundary, enabling boolean predicates in RETURN/WITH items.

  • Expression::ExprPropertyAccess — property access on arbitrary expression results (e.g. date().year).

  • Expression::Modulo — modulo arithmetic operator.

  • Predicate::Xor — exclusive-or logical operator.

  • Predicate::InExpression — IN with runtime-evaluated list expressions.

  • MapProjectionItem::AllProperties — wildcard map projection.

  • EdgePattern.connection_types — multi-type edge matching for pipe syntax.

  • Performance benchmark suite (bench/benchmark_bugs.py) — 70 targeted benchmarks covering all affected code paths, with CSV output for version-to-version comparison.

[0.6.10] - 2026-03-29

Fixed

  • Multi-MATCH empty propagation — when the first MATCH in a multi-MATCH query returns 0 rows, subsequent MATCH/OPTIONAL MATCH clauses now correctly return 0 rows instead of matching against the entire graph.

  • Planner fusion guard — MATCH fusion optimizations (FusedNodeScanAggregate, FusedMatchReturnAggregate, FusedMatchWithAggregate) are now restricted to first-clause position, preventing incorrect results when fused clauses ignored pipeline state from prior clauses.

Changed

  • Retired legacy pytest/ test suite — migrated unique test coverage (edge cases, subgraph extraction, pattern matching property filters, connection aggregation, connector API) into the official tests/ suite. Test count grew from 1,573 to 1,609.

[0.6.9] - 2026-03-22

Added

  • 'poincare' distance metric — new metric for vector_search(), text_score(), compare(), and search_text(). Computes hyperbolic distance in the Poincaré ball model, ideal for hierarchical data (taxonomies, ontologies). Based on Nickel & Kiela (2017).

  • embedding_norm() Cypher function — returns the L2 norm of a node’s embedding vector. In Poincaré embeddings, norm encodes hierarchy depth (0 = root/general, ~1 = leaf/specific).

  • Stored metric on embeddingsset_embeddings(..., metric='poincare') stores the intended distance metric alongside vectors. Queries default to the stored metric when no explicit metric= is passed.

[0.6.8] - 2026-03-19

Added

  • compare() method — dedicated API for spatial, semantic, and clustering operations. Replaces the overloaded traverse(..., method=...) pattern with a clearer compare(target_type, method) signature.

  • collect_grouped() method — materialise nodes grouped by parent type as a dict. collect() now always returns a flat ResultView.

  • Agg helper class — discoverable aggregation expression builders for add_properties(): Agg.count(), Agg.sum(prop), Agg.mean(prop), Agg.min(prop), Agg.max(prop), Agg.std(prop), Agg.collect(prop).

  • Spatial helper class — spatial compute expression builders for add_properties(): Spatial.distance(), Spatial.area(), Spatial.perimeter(), Spatial.centroid_lat(), Spatial.centroid_lon().

  • Traversal hierarchy guide — new conceptual documentation explaining levels, property enrichment, and grouped collection.

Breaking

  • traverse() no longer accepts method= — use compare(target_type, method) instead.

  • collect() no longer accepts parent_type, parent_info, flatten_single_parent, or indices — use collect_grouped(group_by) for grouped output. collect() always returns ResultView.

[0.6.7] - 2026-03-18

Performance

  • 31% faster .kgl load — large files are now memory-mapped directly instead of buffered read; small columns (< 256 KB) skip temp file creation and load into heap.

  • 28% faster Cypher queriesPropertyStorage::get_value() returns Value directly, avoiding Cow wrapping/unwrapping overhead on every property access.

  • Zero-alloc string column accessTypedColumn::get_str() returns &str slices into mmap’d data without heap allocation, benefiting all WHERE string comparisons.

  • 23% faster save — reduced overhead from mmap threshold optimizations.

[0.6.6] - 2026-03-18

Breaking

  • .kgl format upgraded to v3 — files saved with older versions (v1/v2) cannot be loaded; rebuild the graph from source data and re-save.

  • save_mmap() and kglite.load_mmap() removed — the v3 .kgl format replaces the mmap directory format with a single shareable file that supports larger-than-RAM loading.

  • save() now leaves the graph in columnar mode after saving (previously restored non-columnar state). This avoids an expensive O(N×P) disable step.

Added

  • v3 unified columnar file formatsave() now writes a single .kgl file with separated topology and per-type columnar sections (zstd-compressed). On load, column sections are decompressed to temp files and memory-mapped, keeping peak memory to topology + one type’s data at a time.

  • save() automatically enables columnar storage if not already active — no need to call enable_columnar() before saving.

  • Loaded v3 files are always columnar (is_columnar returns True).

Fixed

  • Temp directory leak/tmp/kglite_v3_* and /tmp/kglite_spill_* directories created during load() and enable_columnar() are now automatically cleaned up when the graph is dropped.

  • Reduced save-side memory usage by eliminating double buffering in column packing.

Removed

  • save_mmap(path) method — use save(path) instead.

  • kglite.load_mmap(path) function — use kglite.load(path) instead.

  • v1 and v2 .kgl format support (load and save).

  • Dead code: StringInterner::len().

[0.6.5] - 2026-03-18

Added

  • Columnar property storageenable_columnar() / disable_columnar() convert node properties to per-type column stores, reducing memory usage for homogeneous typed columns (int64, float64, string, etc.). is_columnar property reports current storage mode.

  • Memory-mapped directory formatsave_mmap(path) / kglite.load_mmap(path) persist graphs as mmap-backed column files, enabling instant startup and out-of-core (larger-than-RAM) workloads. Directory layout: manifest.json + topology.zst + per-type column files.

  • Automatic memory-pressure spillset_memory_limit(limit_bytes) configures a heap-byte threshold; enable_columnar() automatically spills the largest column stores to disk when the limit is exceeded. graph_info() now reports columnar_heap_bytes, columnar_is_mapped, and memory_limit.

  • unspill() — move mmap-backed columnar data back to heap memory (e.g., after deleting nodes to free space).

  • memmap2 dependency for memory-mapped file I/O.

  • Columnar and mmap benchmarks in test_bench_core.py (5 new benchmarks).

  • Comprehensive test suite for columnar storage and mmap format (28 new Python tests, 30+ new Rust tests).

Fixed

  • vacuum() now rebuilds columnar stores — previously, deleting nodes left orphaned rows in columnar storage that were never reclaimed. Now vacuum() (and auto-vacuum) automatically rebuilds column stores from only live nodes, eliminating the memory leak.

  • graph_info() reports columnar_total_rows and columnar_live_rows for diagnosing columnar fragmentation.

  • Boolean columns now correctly persist in mmap directory format (from_type_str now matches "boolean" in addition to "bool").

Performance

  • 4-11x speedup for columnar/mmap operations: eliminated unnecessary full graph clone in save_mmap(), bulk memcpy in materialize_to_heap(), async flush, aligned pointer reads, direct push in push_row(), and skipped UTF-8 re-validation for string columns.

[0.6.1] - 2026-03-08

Changed

  • describe() default output now shows edge property names in the <connections> section, improving agent discoverability of edge data without requiring describe(connections=True).

  • Improved hint text in describe output to guide agents toward describe(connections=['CONN_TYPE']) for edge property stats.

  • write_connections_overview now reuses pre-computed metadata instead of scanning all edges (performance improvement).

[0.6.0] - 2026-03-07

Added

  • Python linting (ruff) — format + lint enforcement for all Python files. make lint now checks both Rust and Python. make fmt-py auto-fixes.

  • Coverage reporting — pytest-cov + Codecov integration in CI (informational, not blocking). make cov for local reports.

  • Stubtestmypy.stubtest verifies .pyi stubs match the compiled Rust extension. Runs in CI (py3.12). make stubtest for local checks.

  • Property-based testing — Hypothesis tests for graph invariants (node count, filter correctness, index transparency, Cypher-fluent parity, delete consistency, sort correctness, type roundtrip).

  • Historical benchmark tracking — pytest-benchmark with github-action-benchmark for performance regression detection. make bench-save / make bench-compare for local use.

  • Diátaxis documentation — restructured docs into Tutorials, How-to Guides, Explanation, and Reference sections. New architecture and design-decisions explanation pages.

  • GitHub scaffolding — issue templates (YAML forms), PR template, dependabot, security policy, .editorconfig, .codecov.yml.

  • PEP 561 py.typed marker — type checkers now recognize KGLite’s type stubs.

  • connection_types parameter on betweenness_centrality(), pagerank(), degree_centrality() (stub fix — parameter existed at runtime).

  • titles_only parameter on connected_components() (stub fix).

  • timeout_ms parameter on cypher() (stub fix).

Changed

  • Tree-sitter is now an optional dependencypip install kglite[code-tree] for codebase parsing. Core install reduced to just pandas.

  • README rewritten as a keyword-optimized landing page for discoverability.

  • Benchmarks CI job now runs on every push to main (was manual dispatch only).

[0.5.88] - 2026-03-04

Added

  • MCP Servers guide — new docs page covering server setup, core tools, FORMAT CSV export, security, semantic search, and a minimal template

[0.5.87] - 2026-03-04

Added

  • FORMAT CSV Cypher clause — append FORMAT CSV to any query to get results as a CSV string instead of a ResultView. Good for large data transfers and token-efficient output in MCP servers.

[0.5.86] - 2026-03-03

Added

  • add_connections query modeadd_connections(None, ..., query='MATCH ... RETURN ...') creates edges from Cypher query results instead of a DataFrame. extra_properties= stamps static properties onto every edge.

  • 'sum' conflict handling modeconflict_handling='sum' adds numeric edge properties on conflict (Int64+Int64, Float64+Float64, mixed promotes to Float64). Non-numeric properties overwrite like 'update'. For nodes, 'sum' behaves identically to 'update'.

Fixed

  • add_connections query-mode param validationcolumns, skip_columns, and column_types now raise ValueError in query mode (previously silently ignored)

  • describe() incomplete add_connections signature — now shows query, extra_properties, conflict_handling params and query-mode example

[0.5.84] - 2026-03-03

Fixed

  • Cypher edge traversal without ORDER BY — queries like MATCH (a)-[r:REL]->(b) RETURN ... LIMIT N returned wrong row count, NULL target/edge properties, and ignored LIMIT. Root cause: push_limit_into_match pushed LIMIT into the pattern executor for edge patterns, causing early termination before edge expansion. Now only pushes for node-only patterns.

  • create_connections() silently creating 0 edges — two sub-bugs: (1) ConnectionBatchProcessor.flush_chunk used find_edge() which matches ANY edge type, so creating PERSON_AT edges would update existing WORKS_AT edges instead. Now uses type-aware edges_connecting lookup. (2) Parent map in maintain_graph::create_connections used HashMap<NodeIndex, NodeIndex> (single parent per child), losing multi-parent relationships. Now uses Vec<NodeIndex> per child and iterates group parents directly.

  • describe(fluent=['loading']) wrong parameter name — documented properties= for add_connections(), actual parameter is columns=

  • traverse() with method='contains' ignoring target_type= — when spatial method was specified, target_type= keyword was ignored and only the first positional arg was used as target type. Now prefers explicit target_type= over positional arg.

  • geometry_contains_geometry missing combinations — added (MultiPolygon, LineString) and (MultiPolygon, MultiPolygon) match arms that previously fell through to false

[0.5.83] - 2026-03-03

Added

  • fold_or_to_in optimizer pass — folds WHERE n.x = 'A' OR n.x = 'B' OR n.x = 'C' into WHERE n.x IN ['A', 'B', 'C'] for pushdown and index acceleration

  • InLiteralSet AST node — pre-evaluated literal IN with HashSet for O(1) membership testing instead of per-row list evaluation

  • TypeSchema-based fast property key discoveryto_df(), ResultView, and describe() use TypeSchema for O(1) key lookup when all nodes share a type (>50 nodes)

  • Sampled property statsdescribe() and properties() sample large types (>1000 nodes) for faster response

  • StringInterner::try_resolve() — fallible key resolution for TypeSchema-based paths

  • rebuild_type_indices_and_compact metadata fallback — scans nodes to build TypeSchemas when metadata is empty (loaded from file)

Fixed

  • FusedMatchReturnAggregate output columns — built from return clause items instead of reusing pre-existing columns, fixing wrong column names in fused aggregation results

  • FusedMatchReturnAggregate top-k sort order — removed erroneous top.reverse() calls that inverted DESC/ASC order in ORDER BY ... LIMIT queries

  • FusedMatchReturnAggregate zero-count rows — exclude nodes with zero matching edges (MATCH semantics require at least one match)

  • Path binding variable lookup — path assignments now find the correct variable-length edge variable instead of grabbing the first available binding

  • UNWIND null produces zero rowsUNWIND null AS x now correctly produces no rows per Cypher spec instead of emitting a null row

  • InLiteralSet cross-type equalityWHERE n.id IN [1, 2, 3] now matches float values via values_equal fallback

  • NULL = NULL returns false in WHERE — implements Cypher three-valued logic where NULL comparisons are falsy; grouping/DISTINCT unaffected

  • Property push-down no longer overwritesapply_property_to_patterns uses entry().or_insert() to preserve earlier matchers

  • Pattern reversal skips path assignmentsoptimize_pattern_start_node no longer reverses patterns bound to path variables

  • Fuse guard: HAVING clausefuse_match_return_aggregate bails out when HAVING is present

  • Fuse guard: vector score aggregationfuse_vector_score_order_limit bails out when return items contain aggregate functions

  • Fuse guard: bidirectional edge countfuse_count_short_circuits skips undirected patterns that could produce wrong counts

  • Fuse guard: dead SKIP check removedfuse_order_by_top_k no longer checks wrong clause index for SKIP

  • Parallel expansion error propagation — errors in rayon parallel edge expansion are now propagated instead of silently returning empty results

  • Variable-length paths min_hops=0 — source node is now yielded at depth 0 when min_hops=0 (e.g., [*0..2])

  • Parallel distinct target dedup — parallel expansion path now applies distinct_target_var deduplication matching the serial path

  • Unterminated string/backtick detection — tokenizer now returns errors for unclosed string literals and backtick identifiers

  • String reconstruction preserves escapesCypherToken::StringLit re-escapes quotes and backslashes during reconstruction

[0.5.82] - 2026-03-03

Changed

  • zstd compression for save/load — replaced gzip level 3 with zstd level 1; Save 9.5s → 1.1s (8.6×), Load 2.3s → 1.0s (2.2×), file 7% smaller. Backward-compatible: old gzip files load transparently

  • Vectorized pandas series extractionconvert_pandas_series() uses series.tolist() + PyList.get_item() instead of per-cell Series.get_item(), plus batch extract::<Vec<Option<T>>>() for Float64/Boolean/String. Build 24.7s → 19.3s

  • Fast lookup constructorsTypeLookup::from_id_indices() and CombinedTypeLookup::from_id_indices() reuse pre-built DirGraph.id_indices instead of scanning all nodes

  • Skip edge existence check on initial loadConnectionBatchProcessor.skip_existence_check flag bypasses find_edge() when no edges of that type exist yet

  • Pre-interned property keys — intern column name strings once before the row loop, use Vec<(InternedKey, Value)> instead of per-row HashMap<String, Value> for node creation

  • Single-pass load finalizerebuild_type_indices_and_compact() combines type index rebuild + Map→Compact property conversion in one pass, with TypeSchemas built from metadata instead of scanning nodes

  • Zero-alloc InternedKey deserialization — custom serde Visitor hashes borrowed &str from the decompressed buffer, eliminating ~5.6M String allocations per load

  • Remove unnecessary .copy() on first CSV read in blueprint loader

[0.5.81] - 2026-03-02

Added

  • Comparison pushdown into MATCHWHERE n.prop > val (and >=, <, <=) is now pushed from WHERE into MATCH patterns, filtering during node scan instead of post-expansion. Includes range merging (year >= 2015 AND year <= 2022 → single Range matcher). Benchmark: filtered 2-hop query 109ms → 14ms (7.6×), property filter 2.5ms → 0.8ms (3×)

  • Range index acceleration — pushed comparisons now use create_range_index() B-Tree indexes via lookup_range() for O(log N + k) scans instead of O(N) type scans

  • Reverse fused aggregationMATCH (:A)-[:REL]->(b:B) RETURN b.prop, count(*) (group by target node) now fuses into a single pass like source-node grouping. In-degree benchmark: 26ms → 9ms (2.8×)

  • EXISTS/NOT EXISTS fast path — direct edge-existence check for simple EXISTS patterns instead of instantiating a full PatternExecutor per row. NOT EXISTS: 2372ms → 0.3ms (7400×)

  • FusedMatchReturnAggregate top-k — BinaryHeap-based top-k selection during edge counting, avoiding full materialization + sort. In-degree top-20: 10.5ms → 5.0ms

  • FusedOrderByTopK external sort expression — ORDER BY on expressions not in RETURN items now fuses into the top-k heap, projecting only surviving rows. UNION per-arm: 5.4ms → 2.3ms

  • FusedNodeScanAggregate — single-pass node scan with inline accumulators (count/sum/avg/min/max) for MATCH (n:Type) RETURN group_keys, aggs(...), avoiding intermediate ResultRow creation

  • FusedMatchWithAggregate — fuse MATCH...WITH count() into single pass (same as MATCH+RETURN fusion but for pipeline continuation)

  • DISTINCT push-down into MATCH — when RETURN DISTINCT references a single node variable, pre-deduplicate by NodeIndex during pattern matching. Includes intermediate-hop dedup for anonymous nodes. Filtered 2-hop DISTINCT: 15ms → 10ms

  • UNION hash-based dedup — replace HashSet<Vec<Value>> with hash-of-values approach for UNION (non-ALL) deduplication

  • 35-query DuckDB/SQLite comparison benchmark (bench_graph_traversal.py)

[0.5.80] - 2026-03-02

Added

  • closeness_centrality(sample_size=…) — stride-based node sampling for closeness centrality, matching the existing betweenness pattern; reduces O(N²) to O(k×(N+E)) for approximate results on large graphs

  • copy() / __copy__ / __deepcopy__ — deep-copy a KnowledgeGraph in memory without disk I/O, useful for running mutations on an independent copy

Changed

  • compute_property_stats value-set cap — stop cloning values into the uniqueness HashSet once max_values+1 entries are collected, avoiding O(N) clones for high-cardinality properties

  • Closeness centrality Cypher CALLCALL closeness({sample_size: 100}) now supported alongside normalized and connection_types

  • Regex cache in fluent filtering — pre-compile Regex patterns before filter loops (was compiling per-node); fluent_where_regex 302 ms → 1 ms

  • Single-pass property stats — replaced O(N×P) two-pass scan with O(N×avg_props) single-pass accumulator

  • Pre-computed neighbor schemasdescribe() scans all edges once instead of per-type

[0.5.79] - 2026-03-02

Added

  • Window functionsrow_number(), rank(), dense_rank() with OVER (PARTITION BY ... ORDER BY ...) syntax for ranking within result partitions

  • HAVING clause — post-aggregation filtering on RETURN and WITH (RETURN n.type, count(*) AS cnt HAVING cnt > 5)

  • Date arithmetic — DateTime ± Int64 (add/subtract days), DateTime − DateTime (days between), date_diff() function

  • Window function performance — pre-computed column names, constant folding, OVER spec deduplication, rayon parallelism, fast path for unpartitioned windows

[0.5.78] - 2026-03-02

Changed

  • Betweenness BFS inner loop — merged redundant dist[w_idx] loads into cached if/else if branch, eliminating a second memory access per edge in both parallel and sequential paths

  • Pre-intern connection types in algorithms — betweenness, pagerank, degree, closeness, louvain, and label propagation now pre-intern connection type filters once per call instead of hashing per-edge

  • Adjacency list dedup — undirected adjacency lists are now sorted and deduplicated to prevent double-counting from bidirectional edges (A→B + B→A)

  • 3-way traversal benchmark — added DuckDB (columnar/vectorized) alongside SQLite and KGLite with optimized batch queries

[0.5.77] - 2026-03-02

Changed

  • Edge data optimizationEdgeData.connection_type changed from String (24 bytes) to InternedKey (8 bytes), reducing per-edge overhead by 16 bytes

  • Edge properties compactedEdgeData.properties changed from HashMap<InternedKey, Value> (48 bytes) to Vec<(InternedKey, Value)> (24 bytes), saving 24 bytes per edge

  • BFS connection type comparison — pre-intern connection type before edge loops for u64 == u64 comparison instead of string equality

  • Static slice in BFSexpand_from_node changed vec![Direction] heap allocation to &[Direction] static slice

  • Save/load performance — save time -70% (2,253 → 682 ms), load time -93% (1,676 → 119 ms) on 50k node / 150k edge benchmark

  • Deep traversal speedup — 8-20 hop citation queries 16-28% faster from interned comparison and eliminated heap allocations

[0.5.76] - 2026-03-01

Changed

  • BFS traversal optimization — replaced HashSet visited set with Vec<bool> for cache-friendly O(1) lookups during variable-length path expansion

  • Skip redundant node type checks — planner now marks edges where the connection type guarantees the target node type, avoiding unnecessary node_weight() loads during BFS

  • Skip edge data cloning — unnamed edge variables no longer clone connection_type and properties, eliminating thousands of heap allocations per traversal

  • DISTINCT dedup optimization — uses Value hash keys instead of format_value_compact() string allocation per row

Added

  • Graph traversal benchmark suite — SQLite recursive CTE vs KGLite across 15 query types (citation chains, shortest path, reachability, triangles, neighborhood aggregation)

[0.5.75] - 2026-03-01

Added

  • keys() functionkeys(n) / keys(r) returns property names of nodes and relationships as a JSON list

  • Math functionslog/ln, log10, exp, pow/power, pi, rand/random (previously documented but not implemented)

  • datetime() aliasdatetime('2020-01-15') works identically to date()

  • DateTime property accessorsd.year, d.month, d.day on DateTime values (via WITH alias)

  • Scientific notation — tokenizer now parses 1e6, 1.5e-3, 2E+10 as float literals

Fixed

  • String function auto-coercionsubstring, left, right, split, replace, trim, reverse now auto-coerce DateTime/numeric/boolean values to strings instead of returning NULL

  • describe() algorithm hint — fixed misleading YIELD node, score|community|cluster that didn’t mention component; now shows which yield name belongs to which procedure

  • Spatial coordinate order note — added documentation clarifying WKT uses (longitude latitude) while point() uses (latitude, longitude)

[0.5.74] - 2026-03-01

Added

  • Multi-hop traversal benchmarks — scale-free graph benchmarks at 1K/10K/50K/100K nodes with hop depths 1–8, comparable to TuringDB/Neo4j multi-hop benchmarks

  • Blueprint documentation — standalone guide page with step-by-step walkthrough, real CSV examples, and troubleshooting

Changed

  • Variable-length path BFS — global dedup mode skips path tracking when path info isn’t needed (no p = ... assignment, no named edge variable), reducing memory and redundant exploration (~4x faster)

  • WHERE IN predicate pushdownWHERE n.id IN [list] is now pushed into the MATCH pattern and resolved via id-index O(1) lookups instead of post-filtering all nodes (~1,400x faster on 10K 8-hop traversals)

[0.5.73] - 2026-02-27

Changed

  • README — added blueprint loading and code review examples to Quick Start, doc links on each section

  • CLAUDE.md — simplified and consolidated conventions

[0.5.72] - 2026-02-27

Added

  • Documentation site — Sphinx + Furo docs with auto-generated API reference from .pyi stubs, hosted on Read the Docs. Guide pages for Cypher, data loading, querying, semantic search, spatial, timeseries, graph algorithms, import/export, AI agents, and code tree.

[0.5.71] - 2026-02-27

Added

  • traverse() API improvements:

    • target_type parameter — filter targets to specific node type(s): traverse('OF_FIELD', direction='incoming', target_type='ProductionProfile') or target_type=['ProductionProfile', 'FieldReserves']

    • where parameter — alias for filter_target, consistent with the fluent API: traverse('HAS_LICENSEE', where={'title': 'Equinor'})

    • where_connection parameter — alias for filter_connection: traverse('RATED', where_connection={'score': {'>': 4}})

    • help(g.traverse) now shows a comprehensive docstring with args, examples, and usage patterns

  • Temporal awareness — first-class support for time-dependent nodes and connections:

    • Declare temporal columns via column_types={"fldLicenseeFrom": "validFrom", "fldLicenseeTo": "validTo"} on add_nodes() or add_connections() — auto-configures temporal filtering behind the scenes (same pattern as spatial "geometry" / "location.lat")

    • date("2013") sets a temporal context for the entire chain — all subsequent select() and traverse() calls filter to that date instead of today

    • date("2010", "2015") — range mode: include everything valid at any point during the period (overlap check)

    • date("all") — disable temporal filtering entirely (show all records regardless of validity dates)

    • select() auto-filters temporal nodes to “currently valid” (or the date() context). Pass temporal=False to include all historic records

    • traverse() auto-filters temporal connections to “currently valid”. Override with at="2015", during=("2010", "2020"), or temporal=False

    • valid_at() / valid_during() auto-detect field names from temporal config; NULL date_to treated as “still active”

    • Display (sample(), collect()) filters connection summaries to temporally valid edges

    • describe() includes temporal_from/temporal_to attributes on configured types and connections

    • Blueprint loader: use "validFrom" / "validTo" property types to auto-configure temporal filtering

    • set_temporal(type_name, valid_from, valid_to) available as low-level API for manual configuration

    • Temporal configs persist through save()/load() round-trips

  • show(columns, limit=200) — compact display of selected nodes with chosen properties. Single-level shows Type(val1, val2) per line; after traverse() walks the full chain as Type1(vals) -> Type2(vals) -> Type3(vals). Resolves field aliases and truncates long values

[0.5.70] - 2026-02-26

Added

  • to_str(limit=50) — format current selection as a human-readable string with [Type] title (id: x) headers and indented properties

  • print(ResultView) smart formattingResultView.__str__ uses multiline card format (properties + connection arrows) for ≤3 rows, compact one-liner for >3. Connections show direction with as the current node: --WORKS_AT--> Company(id, title) for outgoing, Person(id, title) --WORKS_AT--> for incoming. Long values (WKT geometries, etc.) are truncated with middle ellipsis

  • sample() selection-awaresample() now works on the current selection (graph.select('Person').sample(3)) in addition to the existing sample('Person', 3) form

  • head()/tail() preserve connections — slicing a ResultView carries connection summaries through

[0.5.67] - 2026-02-26

Changed

  • BREAKING: Fluent API method renames — modernized the fluent API surface to match common query DSL conventions:

    • type_filter()select()

    • filter()where()

    • filter_any()where_any()

    • filter_orphans()where_orphans()

    • has_connection()where_connected()

    • max_nodes()limit()

    • get_nodes()collect()

    • node_count()len() (also adds __len__ for len(graph))

    • id_values()ids()

    • max_nodes= parameter → limit= everywhere (select, where, traverse, collect, etc.)

  • BREAKING: Retrieval method renames — dropped inconsistent get_ prefix and shortened verbose methods:

    • get_titles()titles()

    • get_connections()connections()

    • get_degrees()degrees()

    • get_bounds()bounds()

    • get_centroid()centroid()

    • get_selection()selection()

    • get_schema()schema_text()

    • get_schema_definition()schema_definition()

    • get_last_report()last_report()

    • get_operation_index()operation_index()

    • get_report_history()report_history()

    • get_spatial()spatial()

    • get_timeseries()timeseries()

    • get_time_index()time_index()

    • get_timeseries_config()timeseries_config()

    • get_embeddings()embeddings()

    • get_embedding()embedding()

    • get_node_by_id()node()

    • children_properties_to_list()collect_children() (also filter= param → where=)

Removed

  • get_ids() — removed; use ids() for flat ID list or collect() for full node dicts

[0.5.66] - 2026-02-26

Changed

  • Blueprint loader output — quiet by default (only warnings/errors + summary); verbose mode for per-type detail. Warnings from add_connections skips are now tracked in the loader instead of surfacing as raw UserWarnings

  • Blueprint settingsroot renamed to input_root, output split into output_path (optional directory) + output_file (filename or relative path with ../ support). Old keys still accepted for backwards compatibility

Fixed

  • Float→Int ID coercion — FK columns with nullable integers (read as float64 by pandas, e.g. 260.0) are now auto-coerced to int before edge matching. The Rust lookup layer also gained Float64 → Int64/UniqueId fallback as a safety net

  • Timeseries FK edge filtering — FK edges for timeseries node types now apply the same time-component filter as node creation (e.g. dropping month=0 aggregate rows), preventing “source node not found” warnings for carriers that only have aggregate data

[0.5.65] - 2026-02-26

Added

  • FLUENT.md — comprehensive fluent API reference documenting all method-chaining operations: data loading, selection & filtering, spatial, temporal, timeseries, vector search, traversal, algorithms, set operations, indexes, transactions, export, and a fluent-vs-Cypher feature matrix

  • create_connections() — renamed from selection_to_new_connections with new capabilities: properties dict copies node properties onto new edges (e.g. properties={'B': ['score']}), source_type/target_type override which traversal levels to connect (defaults to first→last level)

  • Comparison-based traverse(method=...) — discover relationships without pre-existing edges. Five methods: 'contains' (spatial containment), 'intersects' (geometry overlap), 'distance' (geodesic proximity), 'text_score' (semantic similarity via embeddings), 'cluster' (kmeans/dbscan grouping). method accepts a string shorthand (method='contains') or a dict with settings (method={'type': 'distance', 'max_m': 5000, 'resolve': 'centroid'}). The resolve key controls polygon geometry interpretation: 'centroid' (force geometry centroid), 'closest' (nearest boundary point), 'geometry' (full polygon shape). Produces the same selection hierarchy as edge-based traversal, so all downstream methods work unchanged

  • add_properties() — enrich selected nodes with properties from ancestor nodes in the traversal chain. Supports copy (['name']), copy-all ([]), rename ({'new': 'old'}), aggregate expressions ('count(*)', 'mean(depth)', 'sum(production)', 'min()', 'max()', 'std()', 'collect()'), and spatial compute ('distance', 'area', 'perimeter', 'centroid_lat', 'centroid_lon')

Changed

  • selection_to_new_connectionscreate_connections — renamed for brevity. Now defaults to connecting the top-level ancestor to leaf nodes (was parent→child at last level only)

[0.5.64] - 2026-02-25

Added

  • List quantifier predicatesany(x IN list WHERE pred), all(...), none(...), single(...) for filtering over lists in WHERE, RETURN, and WITH clauses

  • Exploration hints in describe() — inventory views now surface disconnected node types and join candidates (property value overlaps between unconnected type pairs) to suggest enrichment opportunities

  • Temporal Cypher functionsvalid_at(entity, date, 'from_field', 'to_field') and valid_during(entity, start, end, 'from_field', 'to_field') for date-range filtering on both nodes and relationships in WHERE clauses. NULL fields treated as open-ended boundaries

Changed

  • Rewritten examples — new domain examples: legal_graph.py (index-based loading), code_graph.py (code tree parsing), spatial_graph.py (blueprint loading), mcp_server.py (generic MCP server with auto-detected code tools)

[0.5.63] - 2026-02-25

Added

  • export_csv(path) — bulk export to organized CSV directory tree with one file per node type and connection type, sub-node nesting, full properties, and a blueprint.json for round-trip re-import via from_blueprint()

  • Variable binding in MATCH pattern properties — bare variables from WITH/UNWIND can now be used in inline pattern properties: WITH "Oslo" AS city MATCH (n:Person {city: city}) RETURN n

  • Map literals in Cypher expressions{key: expr, key2: expr} syntax in RETURN/WITH for constructing map objects: RETURN {name: n.name, age: n.age} AS m

  • WHERE clause inside EXISTS subqueriesEXISTS { MATCH (n:Type) WHERE n.prop = expr } now supports arbitrary WHERE predicates including cross-scope variable references and regex

Changed

  • Cypher query performance — eliminated type_indices Vec clone on every MATCH (iterate by reference), move-on-last-match optimization to reduce row cloning in joins, pre-allocated result vectors, eliminated unnecessary clone in composite index lookups

  • MERGE index acceleration — MERGE now uses id_indices, property_indices, and composite_indices for O(1) pattern matching instead of linear scan through all nodes of a type. Orders-of-magnitude faster for batch UNWIND + MERGE workloads

  • UNWIND/MERGE clone reduction — UNWIND moves (instead of cloning) the row for the last unwound item; MERGE iterates source rows by value to avoid per-row cloning

[0.5.61] - 2026-02-24

Added

  • PROFILE prefix for Cypher queries — executes query and collects per-clause statistics (rows_in, rows_out, elapsed_us). Access via result.profile

  • Structured EXPLAINEXPLAIN now returns a ResultView with columns [step, operation, estimated_rows] instead of a plain string. Cardinality estimates use type_indices counts

  • Read-only transactionsbegin_read() creates an O(1) Arc-backed snapshot (zero memory overhead). Mutations are rejected

  • Optimistic concurrency controlcommit() detects graph modifications since begin() and raises RuntimeError on conflict

  • Transaction timeoutbegin(timeout_ms=...) and begin_read(timeout_ms=...) set a deadline for all operations within the transaction

  • Transaction.is_read_only property

  • describe(cypher=['EXPLAIN']) and describe(cypher=['PROFILE']) topic detail pages

  • Expanded <limitations> section in describe(cypher=True) with workarounds for unsupported features

  • openCypher compatibility matrix in CYPHER.md

[0.5.60] - 2026-02-24

Added

  • describe(cypher=True) tier 1 hint now highlights KGLite-specific features (||, =~, coalesce, CALL procedures, distance/contains)

  • describe(cypher=True) tier 2 includes <not_supported> section and spatial functions group

  • describe() overview connection map includes count attribute per connection type

  • describe() connections hint only shown when graph has edges

  • describe(cypher=['spatial']) topic with distance, contains, intersects, centroid, area, perimeter docs

[0.5.59] - 2026-02-24

Added

  • bug_report(query, result, expected, description) — file Cypher bug reports to reported_bugs.md. Timestamped, version-tagged entries prepended to top of file. Input sanitised against HTML/code injection

  • KnowledgeGraph.explain_mcp() — static method returning a self-contained XML quickstart for setting up a KGLite MCP server (server template, core/optional tools, Claude registration config)

Fixed

  • collect(node)[0].property now returns the actual property value instead of the node’s title. Previously, WITH f, collect(fr)[0] AS lr RETURN lr.oil would return the node title for every property access. Node identity is now preserved through collect→index→WITH pipelines via internal Value::NodeRef references

[0.5.58] - 2026-02-24

Added

  • CALL cluster() procedure — general-purpose clustering via Cypher. Supports DBSCAN and K-means methods. Reads nodes from preceding MATCH clause. Spatial mode auto-detects lat/lon from set_spatial() config with geometry centroid fallback; property mode clusters on explicit numeric properties with optional normalization. YIELD node, cluster (noise = -1 for DBSCAN)

  • round(x, decimals) — optional second argument for decimal precision (e.g. round(3.14159, 2) → 3.14). Backward compatible: round(x) still rounds to integer

  • || string concatenation operator — concatenates values in expressions (e.g. n.first || ' ' || n.last). Null propagates. Non-string values auto-converted

  • describe(cypher=True) — 3-tier Cypher language reference: compact <cypher hint/> in overview (tier 1), full clause/operator/function/procedure listing with cypher=True (tier 2), detailed docs with params and examples via cypher=['cluster','MATCH',...] (tier 3)

  • describe(connections=True) — connection type progressive disclosure: overview with connections=True (all types, counts, endpoints, property names), deep-dive with connections=['BELONGS_TO'] (per-pair counts, property stats, sample edges)

[0.5.56] - 2026-02-23

Added

  • near_point_m() — geodesic distance filter in meters (SI units), replaces near_point_km() and near_point_km_from_wkt()

  • Geometry centroid fallback: fluent API spatial methods (near_point_m, within_bounds, get_bounds, get_centroid) now fall back to WKT geometry centroid when lat/lon fields are missing but a geometry is configured via set_spatial or column_types

Changed

  • Cypher distance(a, b) returns Null (instead of erroring) when a node has no spatial data, so WHERE distance(a, b) < X simply filters those nodes out

  • Cypher comparison operators (<, <=, >, >=) now follow three-valued logic: comparisons involving Null evaluate to false (previously Null sorted as less-than-everything)

Removed

  • near_point_km() — use near_point_m() with meters instead (e.g. max_distance_m=50_000.0 for 50 km)

  • near_point_km_from_wkt() — subsumed by near_point_m() which auto-falls back to geometry centroid

[0.5.55] - 2026-02-23

Changed

  • Cypher spatial functions now return SI units: distance() → meters, area() → m², perimeter() → meters (were km/km²). Distance uses WGS84 geodesic (Karney algorithm) instead of spherical haversine

Removed

  • agent_describe() — replaced by describe(). Migration: graph.agent_describe()graph.describe(), graph.agent_describe(detail='full')graph.describe() (auto-selects detail level)

[0.5.54] - 2026-02-23

Added

  • describe(types=None) — progressive disclosure schema description for AI agents. Inventory mode returns node types grouped by size with property complexity markers and capability flags, connection map, and Cypher extensions. Focused mode (types=['Field']) returns detailed properties, connections, timeseries/spatial config, and sample nodes. Automatically inlines full detail for graphs with ≤15 types

  • set_parent_type(node_type, parent_type) — declare a node type as a supporting child of a core type. Supporting types are hidden from the describe() inventory and appear in the <supporting> section when the parent is inspected. The from_blueprint() loader auto-sets parent types for sub-nodes

  • Cypher math functions: abs(), ceil() / ceiling(), floor(), round(), sqrt(), sign() — work with Int64 and Float64 values, propagate Null

  • String coercion on + operator: when one operand is a string, the other is automatically converted (e.g. 2024 + '-06''2024-06'). Null still propagates

Changed

  • describe() inventory now uses compact descriptor format TypeName[size,complexity,flags] instead of size bands. Types listed as flat comma-separated list sorted by count descending. Core types with supporting children show +N suffix. Capability flags from supporting types bubble up to their parent descriptor

  • describe() now shows a <read-only> notice listing unsupported Cypher write commands (CREATE, SET, DELETE, REMOVE, MERGE) when the graph is in read-only mode

[0.5.53] - 2026-02-23

Added

  • from_blueprint() — build a complete KnowledgeGraph from a JSON blueprint and CSV files. Supports core nodes, sub-nodes, FK edges, junction edges, timeseries, geometry conversion, filters, manual nodes (from FK values), and auto-generated IDs

  • Cypher date() function — converts date strings to DateTime values: date('2020-01-15')

  • property_types on blueprint junction edges for automatic type conversion (e.g. epoch millis → DateTime)

  • Temporal join support: ts_*() functions accept DateTime edge properties and null values as date range arguments

  • Cypher IS NULL / IS NOT NULL now supported as expressions in RETURN/WITH (e.g. RETURN x IS NULL AS flag)

  • agent_describe(detail, include_fluent) — optional detail level adapts output to graph complexity. Graphs with >15 types auto-select compact mode (~5-8x smaller output). Fluent API docs excluded by default (opt-in via include_fluent=True)

Changed

  • Performance: agent_describe() 27x faster (1.3s → 48ms) via property index fast path and scan capping

  • Performance: MATCH (n) RETURN count(n) short-circuits to O(1) via FusedCountAll (was ~266ms, now sub-ms)

  • Performance: MATCH (n) RETURN n.type, count(n) short-circuits to O(types) via FusedCountByType (was ~727ms, now sub-ms)

  • Performance: MATCH ()-[r]->() RETURN type(r), count(*) short-circuits to O(E) single-pass via FusedCountEdgesByType (was ~822ms, now ~3ms)

  • Performance: MATCH (n:Type) RETURN count(n) short-circuits to O(1) via FusedCountTypedNode (reads type index length directly)

  • Performance: MATCH ()-[r:Type]->() RETURN count(*) short-circuits via FusedCountTypedEdge (single-pass edge filter)

  • Performance: Edge type counts cached in DirGraph with lazy invalidation on mutations

  • Performance: Multi-hop fused aggregation for 5-element patterns (e.g. MATCH (a)-[]->(b)<-[]-(c) RETURN a.x, count(*)) traverses without materializing intermediate rows

  • Performance: Regex =~ operator caches compiled patterns per query execution (compile once, match many)

  • Performance: PageRank uses pull-based iteration with rayon parallelization for large graphs (3-4x speedup)

  • Performance: Louvain community detection precomputes loop-invariant division terms

  • Timeseries keys stored as NaiveDate instead of composite integer arrays (Vec<Vec<i64>>)

  • set_time_index() now accepts date strings (['2020-01', '2020-02']) in addition to integer lists

  • get_time_index() returns ISO date strings (['2020-01-01', '2020-02-01']) instead of integer lists

  • get_timeseries() keys returned as ISO date strings

  • ts_series() output uses ISO date strings for time keys (e.g. "2020-01-01" instead of [2020, 1])

  • Null date arguments to ts_*() treated as open-ended ranges (no bound)

  • Timeseries data format bumped (v2); legacy files skip timeseries loading with a warning

Fixed

  • MATCH (a)-[]->(b) RETURN count(*) with all-aggregate RETURN (no group keys) now correctly returns a single row instead of per-node rows

  • ORDER BY on DateTime properties with LIMIT now returns correct results (FusedOrderByTopK optimization extended to handle DateTime, UniqueId, and Boolean sort keys)

  • ORDER BY on String/Point properties with LIMIT now falls back to standard sort instead of returning empty results

[0.5.52] - 2026-02-22

Added

  • add_nodes() now accepts a timeseries parameter for inline timeseries loading from flat DataFrames — automatically deduplicates rows per ID and attaches time-indexed channels

  • Timeseries resolution extended to support hour (depth 4) and minute (depth 5) granularity

  • parse_date_string now handles 'yyyy-mm-dd hh:mm' and ISO 'yyyy-mm-ddThh:mm' formats

  • Timeseries support: per-node time-indexed data channels with resolution-aware date-string queries

  • set_timeseries() with resolution (“year”, “month”, “day”), units, and bin_type metadata

  • set_time_index() / add_ts_channel() for per-node timeseries construction

  • add_timeseries() for bulk DataFrame ingestion with FK-based node matching and resolution validation

  • get_timeseries() / get_time_index() for data extraction with date-string range filters

  • Cypher ts_*() functions with date-string arguments: ts_sum(f.oil, '2020'), ts_avg(f.oil, '2020-2', '2020-6'), etc.

  • Query precision validation: errors when query detail exceeds data resolution (e.g. '2020-2-15' on month data)

  • Channel units (e.g. “MSm3”, “°C”) and bin type (“total”, “mean”, “sample”) metadata

  • Timeseries data persisted as a separate section in .kgl files (backward compatible)

  • agent_describe() includes timeseries metadata, resolution, units, and function reference

  • Cypher range(start, end [, step]) function — generates integer lists for use with UNWIND

[0.5.51] - 2026-02-21

Added

  • Fluent API: filter() now supports regex (or =~) operator for pattern matching, e.g. filter({'name': {'regex': '^A.*'}})

  • Fluent API: filter() now supports negated operators: not_contains, not_starts_with, not_ends_with, not_in, not_regex

  • Fluent API: filter_any() method for OR logic — keeps nodes matching any of the provided condition sets

  • Fluent API: offset(n) method for pagination — combine with max_nodes() for page-based queries

  • Fluent API: has_connection(type, direction) method — filter nodes by edge existence without changing the selection target

  • Fluent API: count(group_by='prop') and statistics('prop', group_by='prop') — group by arbitrary property instead of parent hierarchy

[0.5.50] - 2026-02-21

Added

  • Shapely/geopandas integration for spatial methods — intersects_geometry() and wkt_centroid() now accept shapely geometry objects as input in addition to WKT strings

  • as_shapely=True parameter on get_centroid(), get_bounds(), and wkt_centroid() to return shapely geometry objects instead of dicts

  • ResultView.to_gdf() — converts lazy results to a geopandas GeoDataFrame, parsing a WKT column into shapely geometries with optional CRS

  • Spatial type system via column_types in add_nodes() — declare location.lat/location.lon, geometry, point.<name>.lat/.lon, and shape.<name> types for auto-resolution in Cypher and fluent API methods

  • set_spatial() / get_spatial() for retroactive spatial configuration

  • Cypher distance(a, b) now auto-resolves via spatial config (location preferred, geometry centroid fallback)

  • Virtual spatial properties in Cypher: n.location → Point, n.geometry → WKT, n.<point_name> → Point, n.<shape_name> → WKT

  • Spatial methods (within_bounds, near_point_km, get_bounds, get_centroid, etc.) auto-resolve field names from spatial config when not explicitly provided

  • Node-aware spatial Cypher functions: contains(a, b), intersects(a, b), centroid(n), area(n), perimeter(n) — auto-resolve geometry via spatial config, also accept WKT strings

  • Geometry-aware distance()distance(a.geometry, b.geometry) returns 0 if touching; distance(point(...), n.geometry) returns 0 if inside, closest boundary distance otherwise

Removed

  • Cypher functions wkt_contains(), wkt_intersects(), wkt_centroid() — replaced by node-aware contains(), intersects(), centroid() which also accept raw WKT strings

Fixed

  • Betweenness centrality now uses undirected BFS — previously only traversed outgoing edges, causing nodes bridging communities via incoming edges to get zero scores

Performance

  • RETURN ... ORDER BY expr LIMIT k fused into single-pass top-k heap — O(n log k) instead of O(n log n) sort + O(n) full projection. 5.4x speedup on distance() ORDER BY LIMIT queries (1M pairs: 2627ms → 486ms)

  • WHERE contains(a, b) fast path (ContainsFilterSpec) — extracts contains() patterns and evaluates directly from spatial cache, bypassing expression evaluator chain

  • Spatial Cypher functions 6-8x faster for contains/intersects via per-node spatial cache + bounding box pre-filter:

    • Per-node cache (NodeSpatialData): resolves each node’s spatial data once per query, cached for all cross-product rows (N×M → N+M lookups)

    • Bounding box pre-filter: computes geo::Rect alongside cached geometry; rejects non-overlapping pairs in O(1) before expensive polygon tests

    • resolve_spatial() skips redundant expression evaluation for Variable/PropertyAccess — goes directly to cached node data

  • Spatial resolution uses WKT geometry cache for centroid fallback path — previously re-parsed WKT on every row

  • intersects() and centroid() avoid deep-cloning Arc<Geometry> — use references directly

  • geometry_contains_geometry() uses geo::Contains trait instead of point-by-point boundary check

[0.5.49] - 2026-02-20

Added

  • Python type stub (.pyi) files now included in code graph — enables graph coverage of stub-only packages, compiled extensions, and authoritative type contracts

Fixed

  • Cypher parser now accepts reserved words (e.g. optional, match, type) as alias names after AS — previously failed with “Expected alias name after AS”

  • Betweenness centrality sample_size now uses stride-based sampling across the full node range — previously sampled only the first k nodes, which could be non-participating node types (Module/Class) yielding all-zero scores

[0.5.46] - 2026-02-20

Fixed

  • Decorator property stored as JSON array instead of comma-separated string — fixes fragmentation of decorators with comma-containing arguments (e.g. @functools.wraps(func, assigned=(...)))

  • is_test, is_async, is_method boolean properties now explicitly false on non-matching entities instead of null — enables WHERE f.is_test = false queries

  • Dynamic project versions (setuptools-scm etc.) now stored as "dynamic" instead of null on the Project node

  • CALLS edges now scope-aware — calls inside nested functions, lambdas, and closures are no longer attributed to the enclosing function (fixes over-counted fan-out in all 7 language parsers)

  • collect(x)[0..N], count(x) + 1 and other aggregate-wrapping expressions in RETURN now work — previously errored with “Aggregate function cannot be used outside of RETURN/WITH”

  • size(collect(...)) and other non-aggregate functions wrapping aggregates now evaluate correctly — previously silently returned null because the expression was misclassified as non-aggregate

[0.5.43] - 2026-02-20

Added

  • List slicing in Cypher: expr[start..end], expr[..end], expr[start..] — works on collect() results and list literals, supports negative indices

Fixed

  • size() and length() functions on lists now return element count instead of JSON string length — e.g. size(collect(n.name)) returns 5 instead of 29

  • Duplicate nodes when test directory overlaps with source root (e.g. root/tests/ inside root/) — test roots already covered by a parent source root are now skipped, with is_test flags applied to the existing entities instead

  • Duplicate Dependency ID collision when same package appears in multiple optional groups — IDs now include the group name (e.g. matplotlib::viz)

[0.5.42] - 2026-02-19

Added

  • connection_types parameter for louvain and label_propagation procedures — filter edges by type, matching the existing support in centrality algorithms

Fixed

  • CALL pagerank({connection_types: ['CALLS']}) list literal syntax now works correctly — was silently serialized as JSON string causing zero edge matches and uniform scores

  • Document list comprehension patterns as unsupported in Cypher reference

[0.5.41] - 2026-02-19

Added

  • Cypher string functions: split(str, delim), replace(str, search, repl), substring(str, start [, len]), left(str, n), right(str, n), trim(str), ltrim(str), rtrim(str), reverse(str)

Fixed

  • Duplicate File nodes when source and test roots overlap in code_tree (e.g. xarray/ source root containing xarray/tests/ + separate test root)

  • Empty Module.path properties for declared submodules in code_tree — now resolved from parsed files or inferred from parent directory

  • Boolean properties (is_test, is_abstract, is_async, etc.) stored as string 'True' instead of actual booleans — improved pandas object dtype detection to recognize boolean-only columns

[0.5.39] - 2026-02-19

Added

  • read_only(True/False) method to disable Cypher mutations (CREATE, SET, DELETE, REMOVE, MERGE). When enabled, agent_describe() omits mutation documentation, simplifying the agent interface for read-only use cases

[0.5.38] - 2026-02-19

Added

  • Cypher CALL procedure({params}) YIELD columns for graph algorithms: pagerank, betweenness, degree, closeness, louvain, label_propagation, connected_components. YIELD node is a node binding enabling node.title, node.type etc. in downstream WHERE/RETURN/ORDER BY clauses

  • Inline pattern predicates in WHERE clauses — WHERE (a)-[:REL]->(b) and WHERE NOT (a)-[:REL]->(b) now work as shorthand for EXISTS { ... }, matching standard Cypher behavior

  • CALL list_procedures() YIELD name, description, yield_columns — introspection procedure listing all available graph algorithm procedures with their parameters and descriptions

Changed

  • build() now includes test directories by default (include_tests=True)

  • CALL procedure error message now hints at the correct map syntax when keyword arguments are used instead of {key: value} maps

Fixed

  • CALLS edge resolution in code_tree now uses tiered scope-aware matching (same owner > same file > same language > global) instead of flat bare-name lookup — eliminates false cross-class and cross-language edges

  • Rust parser now detects test files at the File level (_test.rs, test_*, tests/, benches/ conventions) — previously only function-level #[test] attributes were detected, leaving File nodes untagged

[0.5.36] - 2026-02-18

Changed

  • Split mod.rs (6,742 LOC) into 5 thematic #[pymethods] files: algorithms, export, indexes, spatial, vector — mod.rs reduced to 4,005 LOC

  • Enabled PyO3 multiple-pymethods feature for multi-file #[pymethods] blocks

  • Documented transaction isolation semantics (snapshot isolation, last-writer-wins)

Fixed

  • [n IN nodes(p) | n.name] now correctly extracts node properties in list comprehensions over path functions — previously returned serialized JSON fragments instead of property values

  • parse_list_value is now brace-aware — splits at top-level commas only, preserving JSON objects and nested structures

  • EXISTS { MATCH (pattern) } syntax now accepted — the optional MATCH keyword inside EXISTS braces is silently skipped, matching standard Cypher behavior

0.5.35 - 2026-02-18

Added

  • CALLS edges now carry call_lines and call_count properties — line numbers where each call occurs in the caller function

  • Comment annotation extraction (TODO/FIXME/HACK/NOTE/etc.) for all non-Rust parsers (Python, TypeScript, JavaScript, Java, Go, C, C++, C#)

  • Test file detection (is_test) for all parsers based on language naming conventions

  • Generic/type parameter extraction for Go 1.18+ and Python 3.12+ (PEP 695) parsers

0.5.34 - 2026-02-18

Added

  • toc(file_path) method: get a table of contents for any source file — all code entities sorted by line number with a type summary

  • find() now accepts match_type parameter: "exact" (default), "contains" (case-insensitive substring), "starts_with" (case-insensitive prefix)

  • file_toc MCP tool in examples/mcp_server.py for file-level exploration

  • find_entity MCP tool now supports match_type parameter

  • Qualified name format documented in agent_describe() output (Rust: crate::module::Type::method, Python: package.module.Class.method)

  • Block doc comment support (/** */) in Rust parser — previously only /// line comments were captured

  • call_trace MCP tool in examples/mcp_server.py for tracing function call chains (outgoing/incoming, configurable depth)

  • Call trace Cypher pattern documented in agent_describe() output

  • CHANGELOG.md, CONTRIBUTING.md, and CLAUDE.md for project governance

Changed

  • Doc comments added to all critical Rust structs (KnowledgeGraph, DirGraph, CypherExecutor, PatternExecutor, CypherParser, and 15+ supporting types)

  • Rust parser now captures all use declarations, not just crate:: prefixed imports

  • MCP tool descriptions improved with workflow guidance (graph_overview says “ALWAYS call this first”, cypher_query mentions label-optional MATCH, etc.)

  • GitHub Release workflow now uses CHANGELOG.md content instead of auto-generated notes

0.5.31 - 2025-05-15

Added

  • find(name, node_type=None) method: search code entities by name across all types

  • source(name) method: resolve entity names to file paths and line ranges (supports single string or list)

  • context(name, hops=None) method: get full neighborhood of a code entity grouped by relationship type

  • find_entity, read_source, entity_context MCP tools in examples/mcp_server.py

  • Label-optional MATCH documented in agent_describe()MATCH (n {name: 'x'}) searches all node types

Changed

  • Code entity helpers (find, context) moved from Python (kglite/code_tree/helpers.py) to native Rust methods for performance

  • agent_describe() now conditionally shows code entity methods and notes when code entities are present in the graph

Removed

  • kglite/code_tree/helpers.py — replaced by native Rust methods on KnowledgeGraph

0.5.28 - 2025-05-10

Added

  • Manifest-based building: build(".") auto-detects pyproject.toml / Cargo.toml and reads project metadata (name, version, dependencies)

  • Project and Dependency node types with DEPENDS_ON and HAS_SOURCE edges

  • USES_TYPE edges: Function → type references in signatures

  • EXPOSES edges: FFI boundary tracking (PyO3 modules → exposed items)

Fixed

  • Various code tree parser fixes for Rust trait implementations and method resolution

0.5.22 - 2025-04-28

Added

  • kglite.code_tree module: parse multi-language codebases into knowledge graphs using tree-sitter

  • Supported languages: Rust, Python, TypeScript, JavaScript, Go, Java, C++, C#

  • Node types: File, Module, Function, Struct, Class, Enum, Trait, Protocol, Interface, Constant

  • Edge types: DEFINES, CALLS, HAS_METHOD, HAS_SUBMODULE, IMPLEMENTS, EXTENDS, IMPORTS

  • Embedding export support


For versions prior to 0.5.22, see GitHub Releases.