Repository navigation
v3.2.0 #156
milliondreams
announced in
Announcements
v3.2.0
#156
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Release focus: making a guest algorithm a first-class citizen, and paying down the honesty debt
underneath it. The headline is the Plugin Compute ABI — a guest algorithm can now choose and
shape the graph it sees, grow structure that does not exist in the store through a metered
mutable arena, and be told at a typed boundary when it mixes two graphs up. Underneath it, two
large sweeps: lancedb is gone (the backend is built directly on Lance, and
lance::Datasetnolonger appears anywhere outside
uni-store), and a structuredFilterExprreplacesstring-SQL predicates from the planner to the backend — which deleted a live wrong-answer bug on
the way. A 15-item correctness sweep closed seven silent-wrong-answer / data-loss defects, and
the published Python wheel dropped from 536 MiB to 238 MiB.
This release covers everything since 3.0.0: 150 commits, ~47,500 insertions across 304
files. Version bumped across the Rust workspace and the Python packages (
uni-db,uni-pydantic)to 3.2.0. The intermediate
3.0.2and3.1.0version bumps were never tagged, so everythingthey carried is folded in here.
Gates at the release commit: the 6,174-test workspace suite green, plus
fmt,clippy -D warningsandrustdoc -D warnings. Conformance gates reported across the sweep:openCypher TCK 3925/3925, Locy TCK 502/502,
uni-dbPython 923 passed. The releasecommit additionally built the
uni-dbwheel through therelease-wheels.ymlrecipe(manylinux_2_28,
--profile dist) and round-tripped aCREATE/MATCHin a clean venv, confirmingthe dynamic-version chain end to end.
Packaging — published wheels no longer carry the WASM loaders
To fit the wheel under PyPI's 100 MB limit,
bindings/uni-dbno longer enables the WASM loaders bydefault. Published wheels therefore no longer expose
Database.load_wasm_componentorDatabase.load_wasm_extism.Both loaders had to go together:
extismdepends onwasmtimeon its own path, independent ofuni-plugin-wasm, so dropping onlywasm-pluginsreclaims nothing. With both off,wasmtime,cranelift,wasi-commonandextismleave the dependency graph entirely. No first-party pluginuses either loader.
Migration: build from source with
--features wasm-plugins,extism-plugins. The Rhai and PyO3loaders are unaffected and remain in every published wheel.
Also in this change:
strip = "symbols"on[profile.release]means panic backtraces fromrelease builds are unsymbolicated. Panic messages still reach Python through PyO3 — only frame
names are lost.
GraphCompute — projections must be scoped, and values carry an index space
Six breaking commits harden the GraphCompute contract. Together they convert a class of plausible
wrong answers into typed errors.
b4cda5ca3). A guest must namenodeLabels/edgeTypes, or opt in explicitly with{projectAll: true}.projectAllitself now fails loudnaming any undeclared schemaless label rather than quietly projecting a partial graph. The
first-party
uni.algo.gc*providers opt in automatically.6d279ff75).ProjectionBuilder::buildrejects a detached L0tier on a live storage view — pass the query's
L0Manager, or opt in withallow_detached_l0(true). In the same change, property and weight names that resolve nowhere inscope are rejected instead of yielding NaN columns or silently defaulted
1.0weights.68e873aee,774a26bcf,c4d4ddac8,40e806923). Every value tracks anOrigin—Graph(handle),Arena(handle), orUntracked(which unifies with anything). Mixing spaces raises0x862. Concretely:ewise/segmented_reducerequire matching shape and index space;scatterrejects an[E]map; set algebra requires equal capacities and same-projection operands;
expand*and maskedreductions reject foreign operands;
emitrejects a column whose projection differs from theoutput keying;
topk/arg_extremereturn ids from the tensor's own projection rather thanthe first-bound one;
emit_walks/emit_pairslabel rows with the originating projection'svids. Previously each of these returned a wrong answer or aborted the host process.
Single-projection algorithms are unaffected.
rekey(value, g)is the one verified way to cross an index space.Migration: add
nodeLabels/edgeTypes(orprojectAll: true) to your projection config; callrekeywhere you deliberately move a value between graphs. A single-graph algorithm needs nochanges.
4f33cf0cais labelled breaking but is strictly permissive relative to 3.1.0:rekeynowaccepts arena-keyed values, which unblocks arena columns that
emithad made unreturnable.Query limits and cancellation now produce a distinct error type
b63deba8a— an elapsed deadline raisesUniError::Timeout { timeout_ms }on every queryterminal, where several paths previously reported a generic query error (or nothing at all).
UniTimeoutErrorinstead ofUniQueryError.CursorTimeoutErrorisdeleted.
UniError::Timeoutinstead of matchingUniError::Queryagainst a"Query timed out"message.See Query engine: limits that were decorative below for what this fixed.
Locy —
previnside a comparison is refused at compile timee58a29714—ALONG ok = prev.h > 5now fails to compile where it previously appeared to compile.The comparison builder re-parses the source span with
CypherParser, wherePREVis not areserved word, so
prev.hdegraded to an ordinary property access on a variable namedprevandthe
LocyExpr::PrevRefmarker was lost. The binding was wrongly accepted in a base case andsilently stripped in a recursive rule where it should have worked. A recursive scan now refuses
prevat any depth beneath a comparison, at compile time.Migration: rewrite as arithmetic (
prev.h + 1style). A variable genuinely namedprevkeepsthe backtick escape documented in
locy.pest.Locy — registered and reloaded rules compile against the plugin registry
ae635b8af— a recursiveFOLDover a plugin-registered aggregate, or overCOLLECT/COUNT,now compiles where it was previously rejected. That is the correct answer from the lattice
metadata (
monotone_join: true), but it changes what compiles.Because
Semilattice::COUNT/COLLECThavehas_top: false, such a rule can iterate tomax_iterationsrather than converging — the iteration cap is the backstop. No migration isrequired; if you were relying on the compiler to reject those programs, add your own guard.
Internally,
Uni::buildis reordered so the plugin registry is constructed before persisted Locyrules are recompiled, and
rebuild_registry_from_sources,build_locy_registry_from_persisted,register_rules_on_registryandcompile_defined_namestake&PluginRegistryexplicitly insteadof reading a task-local.
Plugins — two ABI corrections
55c103d31— an algorithm manifest declaring a yield type the emit path cannot build (e.g.yields=["label:string"]) now fails to load, instead of registering cleanly and then failingon every
CALLwith0x862 unsupported emit column type. The emit channel isVec<f64>percolumn and assembles only Int64/Float64. The new
algorithm_yield_datatypeadmits exactly thosespellings and is routed only through the pyo3 and rhai loaders' yields — the general
type_name_to_datatypeis deliberately untouched, sinceUtf8/Booleanare fine for scalar UDFsand procedures. In practice inert: every algorithm registration in the tree already uses
int/float; no fixture changed.
1f05f5190— plugin procedure output now decodes theCypherValuetransport.arrow_scalar_to_valuemappedLargeBinarystraight toValue::Bytes, butLargeBinaryishow the framework transports a
CypherValue— so a plugin yielding a CypherValue handed thecaller its wire bytes, and the same procedure returned a decoded value on the DataFusion path
and opaque bytes on the row-fallback path. Migration: a procedure yielding genuine opaque
bytes must stamp
uni_raw_byteson the yield field.Documented residual hazard: a raw payload whose first byte is a valid codec tag and whose
remainder is valid msgpack decodes silently and wrongly.
uni_raw_bytesis the only escape and noloader stamps it yet. This trades a guaranteed wrong answer for every CypherValue yield against a
rare one for colliding byte payloads.
768b924a1— one wire-manifest schema for both loaders.WireArgTypemoves touni_plugin::wire_manifest(re-exported by both loaders, souni_plugin_extism::WireArgTypestays put) and gains
VectorandVariadic; exhaustive matches must be updated. This fixed areal divergence: a manifest with
kind: "vector"or"variadic"loaded under Extism and failedto parse under the Component-Model loader.
Pydantic OGM —
eager_load()returns models, not dicts55a65a721—eager_load()now returnslist[Model]/Modelwhere it returnedlist[dict]/dict. Before this,user.posts[0].titleraisedAttributeErrorafter.eager_load("posts")and worked without it. It was worst on the async session, where
_load_relationshipraises andtells you to use
eager_load()— there the broken path was the only path.Migration: drop any
["key"]subscripting on eager-loaded relationships and use attributeaccess. To-many relationships now correctly return lists where they previously looked to-one.
Storage — schemaless edge property reads take a version bound
fc099ef21—MainEdgeDataset::find_props_by_eidgains aversion: Option<u64>parameter. Adefaulted shim was deliberately rejected, because passing
Noneis the bug.Migration: pass the pinned version. The signature now matches
find_props_by_vid.Rust embedder API removals
These affect code that builds against
uni-store/uni-query/uni-plugininternals. If youonly use
Uni/Session/Transactionand Cypher, nothing here reaches you.073a574a7uni_store::lancedb,LanceDbStore, thelancedbdependency and itslance-backendfeature entry;fork::recovery::join_uri_withrecover_forkstakesOption<&dyn ForkBranching>(was a URI-building closure);fork::index_builder::build_fork_local_indextakes&dyn ForkBranching(was a base URI)df3332885FilterExpr::{Sql, None}and theirFrom<&str>impls;UidIndex::openis now privateStorageBackend::{count_rows, delete_rows}takeFilterExpr;StorageManager's five engine-generated scan methods takeOption<&FilterExpr>;ScanRequest::with_filtertakesFilterExpr. Opaque predicate text must be spelledFilterExpr::Raw— which keeps it greppable. Useresolve_uids/resolve_all_vidsinstead ofUidIndex::open1134fb166LanceDbBackend::fork_branchlance_branchdirectly;dataset_uriduplication folded intoLanceDirectory::dataset_urib9c46421foverlay_vertex_batch,overlay_edge_batchvisit_l0_buffers/find_in_l0_bufferse85fbdb2bMainVertexDataset/MainEdgeDatasetmethods (find_all_vids,find_vids_by_label_name,find_vids_by_labels,scan_vids,find_by_eid,find_all_eids,find_eids_by_type_name, singularfind_edges_by_type_name,find_type_by_eid,extract_eids,extract_edges_from_batch)with_version_bound,find_by_ext_id,find_labels_by_vid,find_batch_props_by_vids,find_props_by_vid,find_batch_labels_by_vids,find_props_by_eid, pluralfind_edges_by_type_names246f04708GraphScanExec::new_edge_scanand its 6 private helpersGraphTraverseExec. EXPLAIN output is byte-identical0cd1e44feapply_anti_join,apply_prob_complement;InMemoryExecand 3TestMemoryExeccopies;ProbabilityConfig_compositevariants; use DataFusion'sMemorySourceConfig::try_new_exec4fcfc9ab5uni_db::api::query_buildersession.query_with(..)→api::session::QueryBuilder1693a6a55PluginRegistrar::pending_len;PluginRegistry::{iter_procedures, iter_locy_aggregates, iter_locy_generators, virtual_edge_type_by_name}uni_plugin::hexc75b8384aTemporalValueaccessors (millisecond,microsecond,nanosecond,quarter,week,week_year,ordinal_day,day_of_week,day_of_quarter,timezone,offset,offset_minutes,offset_seconds_value,epoch_seconds,epoch_millis)uni-query-functions/datetime.rs— they genuinely diverged (div_euclidvstruncate).year..second,to_date,to_time,temporal_typeare keptCypher scalar functions — seven divergences resolved against the TCK
83a33581e— uni-db had two Cypher scalar-function evaluators that disagreed: theexpr_evalinterpreter and the registered DataFusion UDFs. Which answer you got depended on the plan shape the
optimizer picked. Resolved against the openCypher TCK (the interpreter was wrong on five of seven):
toString(1.0)"1""1.0"toString(list / map / node / rel / path)toInteger('3.7')null3(truncate)toBoolean(1)truesubstring(s, -1, n),substring(s, 0, -1)split('a', null)['a']nullAlso fixed:
cypher_value_cmphad noValue::Temporalarm and fell through to_ => None, socomparing a list of dates came back incomparable on the UDF path only.
Highlights
🔹 The Plugin Compute ABI — guests that shape and grow their own graph
In 3.0.0, GraphCompute could express exactly one thing: deterministic propagation over a fixed
graph. A guest drove coarse native kernels over an immutable projection it did not choose. The
Plugin Compute ABI (
docs/proposals/plugin_compute_abi_2026-07-13.md) removes that envelope. Aplugin author in Rhai, Python, WASM (Component Model) or Extism can now:
tensors, Cypher-defined and named graph refs,
includeReverse, and several graphs at once.population — through a metered mutable arena, then freeze it into an ordinary graph handle so
the whole read-only kernel library applies to it.
plausible wrong answer.
The "conductor, not worker" model from 3.0.0 is unchanged: the guest runs only the
O(iterations) control loop, native code does all O(V+E) work, and only opaque handles and scalars
cross the plugin boundary.
The kernel catalogue is now 72 kernels, single-sourced in
kernel_id.rsthrough akernels!macro that generates
KernelId::ALL/op_name/reach/from_op_name. The dispatcher matcheswith no wildcard arm, so an undispatched kernel fails to compile.
graph,graph_named,rekey,interp,edge_from_nodes,vertex_count,edge_count,degrees,vertex_ids,node_property,edge_property,edge_weights,edges_allfrontier,set_to_map,map_to_set,set_union,set_diff,set_intersect,set_len,is_emptyewise,compare,zero_map,scatter,map_apply,recip,scale,normalize,reduce_sum,reduce_sum_masked,arg_extreme,topk,l1_diff, pluszero-charge
work_budget/work_spent/work_remainingexpand,expand_masked,expand_sampled,reach_fixpoint,spmv,spmv_masked,next_bucketedge_set_len,edge_mask_window,edge_intersect,edge_union,sample_edges,sample_edges_undirected,segmented_reducerandom_walks,walk_visit_counts,emit_walks,sample,neighborhood_overlap,all_pairs_overlap,emit_pairsemit,freeOp-string vocabularies are now parsed from one source (
op_parse.rs) instead of being hand-matchedin three loaders:
MapOprecip|scale|log|sqrt|exp|affine|floor|mod|normalize_l1|normalize_l2;EwiseOpadd|mul|min|max|axpy|div|mod;CmpOpgt|ge|lt|le|eq|ne;EndpointOpsrc|dst|sub|absdiff|add|min|max;Predicateis_zero|gt|lt|eq; plus norms, semirings and overlapmetrics.
🔹 The mutable graph arena (
graph-arena@1)A guest can allocate and link vertices that have no counterpart in the store, run kernels over them,
and freeze the result into a normal graph handle:
arena_new(capacity, branching),arena_alloc,arena_expand(parents, fanout),arena_link,arena_column,arena_candidates,arena_gather,arena_scatter,arena_backup(value_col, leaves, deltas),arena_descend(score_col, visit_col, maximize, vloss),arena_freeze,arena_seed(a, g).arena_seedseeds an arena from a projection, so newly allocated vertices inherit realneighbours. A graph frozen from an arena canonicalizes back to that arena's origin, so
arena_freeze→spmvcomposes. Arena memory is capped by theGraphComputeArenaBytesquota, anda frozen graph now correctly releases the budget it reserved (
bbe8e838e— a freeze/grow looppreviously died at 14 iterations against a 16-handle cap despite freeing everything).
WASM guests reach the arena through a typed, additive WIT interface
(
uni:plugin/host-arena@0.1.0, handles asu64) at roughly 134 ns per call versus ~2 µs for theJSON crossing.
🔹 Named scopes — one algorithm over several graphs
An algorithm can pre-declare named scopes and be handed all of them at once:
The guest reaches them with
gc.graph()/gc.graph_named("agg")(Rhai, Python); sandboxed guestsreceive
{"session": 3, "graph": …, "args": [], "graphs": {"agg": …}}. Before7e33e3eb8,scopeswas accepted and silently ignored by first-party providers, and the WASM/Extismgraphsmap was write-only.Full config vocabulary (
GraphProjectionSpec::CONFIG_KEYS):nodeLabels,edgeTypes,relationshipTypes,weightProperty,includeReverse,nodeProperties,edgeProperties,projectAll,nodeQuery,edgeQuery,weightColumn,name,scopes.🔹
interp— the System Dynamics table functionPiecewise-linear table lookup following the Vensim/Stella
WITH LOOKUPconvention: map each elementthrough
(xs[i], ys[i])breakpoints, interpolating between them and clamping outside the range.Clamping is what makes a table safe to evaluate on an unbounded state variable.
This one is worth explaining, because "it is already composable" was the wrong answer here. It
genuinely is composable — a per-segment ramp
max(min(slope*(x-xi), rise), 0)gated oncompareand summed, which needs no upper-bound comparison and gets end-clamping for free (which matters,
since
Predicatehas noGe/Le). But the prototype also measured it: 112 work units for 3breakpoints over V=4, roughly 14 passes over
[V]per segment. A ten-point table runs ~126× thework of a single pass, the meter charges all of it, and an SD model evaluates several tables per
tick. Expressible but not affordable is exactly the case a kernel slot is for.
interpchargesO(V) independent of breakpoint count, and the test asserts that rather than assuming it.
It fails loud on a curve it cannot evaluate — fewer than two breakpoints, mismatched lengths, a NaN,
or x-breakpoints that do not strictly increase (naming the index where ordering breaks; strict
rather than merely sorted, because a repeated x makes the segment slope infinite and the caller
almost certainly meant a step function). Shape- and provenance-preserving, so an
[E]curve stays[E].🔹 Kernel rejections now publish an executable recipe
When a guest reaches for an op or a method that does not exist, the error carries a composition
recipe keyed by
(name, family)—RECIPESfor op strings,METHOD_RECIPESfor missing methods(surfaced through Rhai's
on_missing_functionand a PyO3__getattr__raising anAttributeErrorsubclass). Every published recipe is proven against a scalar oracle in
composition_recipes.rs, so the suggestion cannot rot.lookup/table/with_lookup/piecewiseare published as hints pointing atinterp;gc.graph(scope)answers with the named-scope feature that replaced it.
🔹
Session::scratch()— a cheap write-isolated transactioncrates/uni/src/api/session.rs:793. A transient transaction that costs roughly 1 ms, refuses tocommit, and is discarded on drop — the substrate for rollout-style algorithms that need to mutate a
graph and throw the mutation away. Reached from every loader.
The older
ScratchGraph,ScratchRegistry,ScratchRequest/Response,LoaderClassandrequire_compiled_bodyare#[deprecated], naming their arena replacements.🔹 Budgets, epochs and tracing — all fail closed
min(10_000 * (|V| + |E| + 1), 1_000_000_000)by default, summed overevery bound projection. A
GraphComputeWorkgrant replaces it in both directions (it canlower as well as raise). Guests can read it:
work_budget/work_spent/work_remainingcharge nothing.
0x86Binstead of wrapping.UNI_GC_TRACE— a bounded ring of handle resolutions attached to handle errors and aborttags. Always compiled, inert when unset; CI runs a
UNI_GC_TRACE=1job. Beforeefec80f32thetrace covered 2 of ~15 paths and aborts carried none.
Error block
0x860–0x87F:0x860stale handle ·0x861kind mismatch ·0x862shape orindex-space mismatch ·
0x863epoch mismatch ·0x864arena cap ·0x865budget exhausted ·0x866iteration limit ·0x867timeout ·0x868seed not in projection ·0x869emit schemamismatch ·
0x86Aslice version mismatch ·0x86Bwrap fail-closed ·0x86Ccapability denied ·0x86Earg validation ·0x804scope-grant refusal ·0x820unresolvablegraphRef.🔹 Lance, directly —
lancedbis goneuni-storehad two implementations of one storage surface:backend/lance.rson lancedb, andbackend/lance_branch.rson rawlance::Dataset(written because lancedb does not expose Lance'sbranch API). They had drifted — the branch path disabled scalar-index pushdown (issue #106), the
primary path did not — so the same scan resolved through two different plans depending on which side
asked. Every "correct on primary, wrong on a fork" bug had that shape available to it.
lancedb's only real contribution was URI/table-name mapping and directory enumeration. That becomes
LanceDirectory(~271 lines), and everything else drops tolance::Dataset. The layout is now anexplicit compatibility contract: a table lives at
{base_uri}/{name}.lance. The equivalence testlance_directory_listing_matches_lancedbwas written while lancedb was still a dependency, toassert listing /
dataset_uri/ open equivalence against lancedb itself — no oracle existsafterwards.
Three parity details were read out of lancedb's source, each of which degrades results silently
rather than erroring:
Scanner::distance_metric— Lance uses it todecide whether an index is usable at all, and the old branch path never passed one;
prefilter(true)matches lancedb'sonly_ifdefault — postfilter would let excluded rowsconsume top-k slots and return fewer than k;
replace_table_atomicwith no batches staysdelete("true")— an empty Overwrite would dropthe schema.
Fork branching moves behind a new
ForkBranchingtrait onStorageBackend, socrates/uninolonger knows Lance exists.
BranchedBackendexplicitly forwards the capability rather thaninheriting the trait default — without that override every fork-local index build would fail.
Verified:
lance::Datasetnow appears nowhere outsidecrates/uni-store/src/, andlancedbappears in no
Cargo.tomlin the repository.🔹 Structured
FilterExpr— and the wrong answer it deletedFilterExprwasenum { Sql(String), None }— the last place the storage abstraction spoke SQL,and the blocker for any non-Lance backend (an in-memory or WASM implementation would have needed a
SQL parser). It is now a tree:
Literal · And · Or · Not · Compare · In · ArrayContains · StringMatch · IsNull · IsNotNull · Rawwith two lowerings:
to_sql(), the single place quoting, escaping and parenthesisation happen(nine hand-rolled
replace('\'', "''")copies collapsed into it), andeval(), a nativethree-valued (Kleene) evaluator.
Rawis permanent, not transitional — the user-facingfilterargument on
uni.vector.queryand friends is opaque text nothing in the engine parses. A backendthat cannot evaluate
Rawmust fail loudly: treating it as match-all would return rows thecaller asked to exclude, which on a filtered query is a data leak, not a degraded result.
This deleted a live wrong-answer bug. The range-fusion path emitted
"col" >= L AND "col" <= U.In Lance's dialect a double-quoted name is a string literal, not a quoted identifier — so this
was the constant
'col' >= L AND 'col' <= U, false for every row. A nonexistent column does noteven error. Triggering it required all three of: a Hash scalar index on the column, an
=/INpredicate on it, and a two-sided inclusive range on the same column. Then:
returned
[]instead of the row withcreatedAt = 3— and the residualFilterExeccannot recoverrows the scan never emitted. The structured form deletes the fusion outright (~45 lines, including a
HashSet<*const Expr>pointer-identity set); it was a no-op transform whose only effect was the badstring. Regression test:
crates/uni/tests/common/bugs/hash_index_range_quoting.rs.Three further predicates interpolated unescaped strings —
type = '…',array_contains(labels, '…')in the schemaless scan, and a label filter — so a backtick-quoted label or edge type containing an
apostrophe produced a malformed predicate. All now travel as typed
Scalar::Str. Separately,ee4a9a566fixes the same class inJsonPathIndex::get_vids, whereO'Brienproduced anunterminated string literal.
ScalarcarriesUInt(u64)separately fromInt(i64)becauseVid::INVALID == u64::MAXwouldround-trip through
i64as-1.Acceptance test
backend::eval::eval_agrees_with_lance_sqldifferentially compareseval()againstLance SQL on a real dataset over random expressions, and is proven by five deliberate mutations.
🔹 The wheel: 536 MiB → 238 MiB
Measured on
lib_uni_db.so, x86_64 Linux, reproduced across two builds:+ strip = "symbols"+ dist profile, no WASM loadersThe shipping wheel is 89.4 MiB — under PyPI's 100 MB default limit even without a per-project
increase.
The split is counter-intuitive. A release build carries only ~30 MiB of debug info but a ~132 MiB
symbol table, a consequence of monomorphizing across 500+ dependency crates — so
strip = "debuginfo"reclaims almost nothing and only"symbols"pays. In the new[profile.dist](ThinLTO,
codegen-units = 1; both profiles live in.cargo/config.toml),codegen-unitsis the lever, not the LTO mode: 16 → 1 is worth~130 MiB, while fat-vs-thin LTO is worth 19 MiB. Fat LTO is deliberately rejected — it pushes peak
rustc RSS past 15.8 GiB and would OOM the macOS runner for that 19 MiB. Peak RSS floors around
11 GiB for the ThinLTO link regardless of codegen-units, so raising the latter buys size back
without buying memory. (The profile is named
distbecause cargo reservespublish.)build-metalmoves frommacos-latest(7 GB) tomacos-latest-xlarge(14 GB, Apple Silicon —-largeis Intel, the wrong host foraarch64-apple-darwinplus Metal); the lower codegen-unitswould OOM the 7 GB default. Release builds take roughly 25% longer (19m26s vs 15m40s locally for
-p uni-python).Correctness — data loss and silent wrong answers
Seven defects in this release returned wrong results or lost data with no error, no warning and no
log entry. These are the headline fixes.
A read-only open silently rolled back committed transactions —
a06e7b299Reopening a partially-flushed database read-only returned 2 of 4 nodes. A plain
MATCHmissedcommitted rows, with no error.
WAL replay restores the committed-but-unflushed suffix into the writer's
L0Manager. A read-onlyopen then dropped the writer — and with it the only handle to that tier — so reads saw flushed L1
only. The
l0_managerArcis now lifted out before the writer is dropped (no write capability isgranted; writes stay gated on
UniInner.writer, pinned by a test). Two adjacent defects in the samechange: a writer-less
get_contextwas droppingpending_flush_l0s, and read-only opens werespawning auto-flush and index-rebuild tasks that wrote L1.
Edge properties vanished after ~300 write transactions —
c56340a07From the first
compact_adjacencyonward,get_batch_edge_propsreturned nothing for everyEID and never recovered.
The blast radius is worse than it sounds: a missing property becomes NaN, and
edge_mask_window'sv >= lo && v <= hifails under any window — so every masked traversal silently returned zerorows, and weighted algorithms degraded to unit weights. An external adopter's run matched a
reference implementation for ~150 ticks and then diverged.
Compaction deletes incorporated delta rows on the invariant that edge properties survive in
main_edges. Two sibling readers fall back toMainEdgeDatasetcorrectly; this one — the one aGraphCompute projection calls — did not.
properties()came back empty on an unlabelled multi-label endpoint —d0b948e24MATCH (a:Author)-[:WROTE]->(b) RETURN properties(b)returned{}wheneverWROTEdeclared morethan one label on an endpoint. The row was found and
labels(b)was correct — only the propertieswere missing.
The planner collapses a multi-label destination to
Noneand takes the label-agnostic branch, whichfiltered out the
_all_propswildcard and then skipped the storage read entirely._all_propsisnot an internal name to strip — it is what
get_batch_vertex_propsunderstands. The sentinel nowpasses through at both sites: single-hop and
hydrate_vlp_target_properties, which was brokenidentically. (This is the issue-#135 fix finally reaching the copy it missed.)
A NULL-bearing
INlist gave different rows pushed-down vs. native —60ef82f6bNOT ((i IN (NULL)) AND (i IN (0)))— the native Kleene evaluator returned the correct 80 rows,Lance returned all 120. Lance rewrites two
InListpredicates over the same column in a way thatloses the unknown.
to_sqlnow states it outright: an all-NULL list rendersCAST(NULL AS BOOLEAN), a mixed list renders(col IN (non_nulls) OR CAST(NULL AS BOOLEAN)). Thecast is load-bearing — a bare
NULLis untyped and foldable. NULL-free lists keep the plainrendering.
The Pydantic OGM silently dropped rows during hydration —
ce8ba6381Model.query().all()returned fewer rows than matched, with no log, warning or counter.len(q.all()) != q.count(), and.one()raised "Query returned no results" for a row thatdemonstrably exists.
Two defects. First,
except Exception: return Nonecould not distinguish "the stored data isinvalid" from "the mapping layer is broken" — a validator
RuntimeError, or a typo in_convert_db_values, silently removed a row. It is narrowed toValidationError, which now warnsvia
_warn_unhydratablenaming the label and vid. Second,if instance:is a truthiness test — amodel defining
__bool__or__len__was dropped after hydrating perfectly. All nine guards arenow
is not None. It warns rather than logs because the package has no logging configuration, so alogger would be silent by default.
Row-path procedure hosts saw flushed storage only —
b7e55e944A mode-dependent wrong answer: a label-scoped projection saw committed-but-unflushed rows, while a
Cypher-scoped one on the same call silently saw none. This reached
execute_plugin_procedure—every row-path plugin procedure, including
uni.graph.project's Cypher mode and everything routedthrough
CypherProcedureSynthesizer. Fixed with awith_l0_contextbuilder mirroringwith_writer.It is reachable only on the row path, which is why an earlier
uni.algo.gcpagerankprobe came backfalsely clean.
A stringly-typed "not found" check handed out already-live VIDs —
eb35fe6dbIdAllocator::newclassified errors withe.to_string().contains("not found"). A proxy 404 body,or a wrapped S3 "bucket not found", started the allocator from a defaulted manifest against a
populated database — and it then handed out VIDs that were already in use. It now uses the typed
store_utils::is_not_found. Two sibling loaders had been migrated long ago; this one was neverconverted.
Schemaless edge property reads escaped snapshot isolation —
fc099ef21Bidirectionally wrong. The L1 main-table fallback filtered on
_eidonly and took the highest_versionrow with no snapshot bound, while the delta scan and the L0 overlay were both bounded.Because schemaless and overflow edge properties live only in
props_jsonand never in deltacolumns, that fallback fired on every such read. A pinned view saw post-snapshot values (observed
Int(2)past the pin), and a post-pin delete wrote a tombstone that won the version race — makingan edge that existed at the snapshot vanish entirely.
The version bound is added as a conjunct, so the tombstone-winner rule from issue #53 runs
unchanged over the survivors.
exists_by_eidis deliberately left unbounded (it is acompaction dual-write invariant check). In practice the leak bites when the
PropertyManagerwasbuilt over pinned storage — i.e.
UniInner::at_snapshottime-travel reads — not ordinaryread-write transactions. Still open and filed:
find_edges_by_type_namesis also unbounded, butthat is topology-level and needs its own design.
Edge-type and label counts returned a confident zero —
a5154a35fget_edge_type_infointerpolated the type name into Cypher unquoted and swallowed failures withErr(_) => 0. Any name outside[A-Za-z_][A-Za-z0-9_]*— hyphens, dots (documented as supportedfor qualified names), leading digits, anything non-ASCII — produced a parse error reported as 0
edges inside an otherwise-correct
EdgeTypeInfo. Separately,get_label_infocounted viabackend.count_rows, which is L0-blind, so unflushed rows also counted as 0.Names are now backtick-quoted, errors propagate, and a name containing a backtick is refused
explicitly (the quoted grammar has no escape rule). A Python assertion that had been weakened to
tolerate the zero is restored. Callers that relied on
0never erroring will now see errors.The CHECK evaluator diverged between bulk and transactional writes —
33601464auni-bulk::BulkWriteranduni-store::Writereach carried a ~100-line copy of the CHECK evaluator.The bulk one routed numeric
=/!=throughcompare_values(becauseValue'sPartialEqistype-strict with no Int/Float arm); the writer used bare
==. SoCHECK (score = 5)against astored
Float(5.0)passed viaBulkWriter::insert_verticesand failed viatx.execute("CREATE ..."). Both now calluni_common::core::check_constraint::evaluate.Transactional CHECK equality now coerces Int/Float; warnings move from
logtotracing.map_variablesdropped tenExprvariants —7cdc811e3The hand-written match ended in
other => other, silently skippingExists,CountSubquery,CollectSubquery,Quantifier,Reduce,ListComprehension,PatternComprehension,ValidAt,MapProjectionandLabelCheck— all reachable from Locy, sincelocy_yield_itemandfold_expressiontake an arbitrary Cypher expression. AnALONGbinding in one of those positionsstayed a bare
Variableand resolved against a schema without that field. Both this and itsarm-for-arm duplicate
rewrite_is_ref_colsnow delegate toExpr::map_children_in_scope(chosenover
map_childrenso comprehension binders are not captured). Net ~120 lines deleted, andfold-output detection widens correctly, so such expressions are now deferred post-fold instead of
failing at plan time.
Four
Exprwalkers silently swallowed four variants —9f8dd7993MapProjection,ValidAt,LabelCheckandPatternComprehensionfell through_ => {}. Reachable:MATCH (n:P) RETURN n{.name, c: count(*)}built no aggregation at all and died withDataFusion planning failed: Schema error: No field named "n.name"— physical-planner internalssurfacing as a user-facing error. A new
Expr::for_each_child_in_scopefixes the walkers; the querynow reports
AmbiguousAggregationExpression. It still does not succeed where the flatRETURN n.name, count(*)does — that limitation is pinned by a named test.Query engine: limits that were decorative
f18fb7170andb63deba8aclosed a set of guarantees that were advertised and not delivered:QueryBuilder::cursoraccepted.timeout(),.max_memory()and.cancellation_token()andhonoured none of them. A self-join whose
fetch_allwas rejected on memory grounds streamedall 1,999,000 rows through
.cursor().Session::cancel()did nothing on the plainquery()path —execute_cachedhard-codedNoneatall three sites.
Transaction::cancel()was inert.max_query_memorywas inert across the whole transaction surface..fuse()isload-bearing).
Every path now flows through one
build_guarded_cursortail and takes a requiredCancelScope—making it required is what let the compiler enumerate the droppers, including
PreparedQuery. Theguard races the stream against the token, because the executor token is only an optimisation: no
scan, join or traverse plan actually reaches
GraphContext::check_timeout. Python'sTxQueryBuildergainscancellation_tokenacross sync, async and the stub.f2f776d45closes the same shape in Locy:LocyBuilder::cancellation_tokenwas write-only —the setter existed on the session and transaction builders and the Python bindings called it, but
LocyEnginecarried no cancellation state at all. Andexecute_ast_internal_with_tx_l0had noscope parameter, so
Transaction::cancel()was inert forTransaction::apply/replay_factsandtx-bound Locy. Crucially,
evaluate_compiled_capturingnow races the whole fixpoint evaluation —statement-level plumbing alone would have left the common case, a long-running fixpoint, unguarded.
VERSION AS OFpanicked on three of four terminals —ef0d863a9explain,profileandcursorpanicked on a time-travel query; onlyfetch_allworked.Across PyO3 it surfaced as a
pyo3_runtime.PanicExceptionfrom ablock_on. Reachable from Rust,the CLI REPL and Python.
split_time_travelis now one named function with seven call sites, andthe three terminals resolve through a shared
pinned_atand re-dispatch. The fix deliberately doesnot soften the
unreachable!, and does not merely strip the version spec — answering againstthe live version silently would be worse than the panic.
classify_verbpanicked on non-ASCII statements —5ac4e5ab8s[..s.len().min(32)]used byte length where the comment said chars, so any statement with amulti-byte codepoint straddling byte 32 panicked with
end byte index 32 is not a char boundary.Every write routes through this function, and across PyO3 it surfaced as a process abort rather than
an exception. It now iterates by
char; the workspace was swept for the same shape and no other&strslice sites are at risk.Locy
c251415f0). Parenthesisingan otherwise-identical pattern turned a legal rule into
WardednessViolation. Since a quantifiedsub-pattern must be parenthesised,
MATCH ((a)-[:R]->(b)){1,3}could never be warded.PatternElement::Parenthesizedwas an empty arm — the only variant carrying a nestedPathPattern, so every variable bound inside parens was invisible; every other consumer alreadyrecursed. The arm stays explicit so a future binding variant fails to compile.
ASSUMEbody can reference the rules around it (86d377b28). Previously this never worked,with or without
MODULE, across two independent layers: a compile-time namespace mismatch (thebody compiled with
module: None, soadultvs. cataloguedm.adult→UndefinedRule), and aruntime gap where planning the body's strata saw only the body's catalog while body commands
dispatched against the parent's.
evaluate_assumenow builds one merged catalog — parent rules,body shadowing on collision, module-qualified parents also exposed bare — while each construction
keeps its own
strata. Merging catalogs without merging strata is what makes it safe.FOLD(e54c97629,ae635b8af). SeeBreaking changes above. Previously
check_non_monotonic_in_recursionconsulted a hardcodedsix-name oracle, so an aggregate registered with
monotone_join: truewas rejected at compiletime — while the planner's guard did consult the registry, meaning compile and plan disagreed
about the same program. Both now route through
locy_fold::locy_monotonicity_verdict. The oracleis threaded through a fifth, previously unnoticed call site inside
compile_with_context'sASSUME-body recursion, so a host oracle no longer stops at the ASSUME boundary. The TCK's
when_compilestep now compiles through the same composed verdict — it previously called bareuni_locy::compileand could never exercise the registry.BEST BYno longer consumes its monotonicity oracle inverted (23de3997f). Behaviour isunchanged for in-tree callers, but a naive registry-backed oracle swap would have rejected valid
programs like
FOLD peak = MAX(a.cost) BEST BY peak ASC. The doc comment is corrected too — itclaimed an
M-prefix convention that never existed; it is an exact six-name list, soMFOOanswers
None.Storage & concurrency
36b05898f,4bd219be3).AdjacencyManager::warmpushed a
ShadowEdgefor everyop == 1row scanned out of the L1 delta and — unlikewarm_coalesced— had nohas_csrshort-circuit, so each warm of the same(edge_type, direction)re-pushed the entire delete history. Growth was unbounded in the number of warms, not
deletes. It could never trip
max_bytes, becausememory_usageread onlycurrent_bytes. Readsdedupe by
Eiddownstream, which is why it surfaced as memory rather than duplicated neighbours.add_deleted_edgenow ignores a repeat of anEidit already holds (deduping there rather thanshort-circuiting
warm, which would also skip deletions since the last warm and lose adjacencycorrectness). New accessors:
AdjacencyManager::shadow_entry_count()exposes retentiondirectly, and
memory_usage()folds inShadowCsr::approx_bytes().4bd219be3then actually reclaims —ShadowCsr::gcexisted, was tested, and was called fromnowhere. The bound was the hard part:
StorageManager::pinned()andat_forkeach build a freshAdjacencyManagerwith an emptyShadowCsr, so onlypinned_at_version(the path everyread-write transaction takes) shares the live instance. A new refcounted
PinnedVersionsregistrytracks in-flight pins — several transactions routinely pin the same version — and
pinned_at_versionreturns aPinGuardthat releases on drop.AdjacencyManager::gc_shadow(current_version)computes the floor and sweeps after CSRcompaction.
14ef0876d). The samestringly-typed defect as
eb35fe6db, one layer up:ResilientObjectStore::retryclassified bylowercased text, so a transient
Error::Genericthat happened to read like a 404 was abandoned —and the classifier ran after the attempt-budget check. Separately,
put_optscounted everyfailure against the circuit breaker with no classifier, so five OCC losers opened the breaker
and took the whole store offline for 30 seconds. Both now use the typed
store_utils::is_terminal, whose wildcard answers retryable.f382561ec). The flushfinalizer's spawned task outlives the
Unihandle (Droponly signals), so concurrenttruncators raced list-then-delete and the loser failed its entire flush with
Object at location .../wal/… not found.replay_sincehad the same list-then-get exposure. OnlyNotFoundis tolerated, anddeleted_countstays an honest count.42f27c7d8, docs-only). An audit read three non-recordingL0 accessors (
get_vertex_properties,get_edge_properties,edge_exists_in_l0) as a write-skewhole. That is refuted: the read set is item-granular (
HashSet<Vid>/HashSet<Eid>) and everycaller records the id first. Adding the recording would double read-set lock acquisitions on the
per-row materialisation path.
1746fcc8c). Two of ten deleted methods werebroken paths:
EdgeDataset::newbuilt{base}/edges_{type}andDeltaDataset::newbuilt{base}/deltas/{type}_{dir}against a canonical layout of{base}/edges_{type}.lance/{base}/deltas_{type}_{dir}.lance, andDeltaDataset::scan_allswallowed the open error andreturned
Ok(vec![])— a silent empty delta table in a compaction path, had it ever been called.Python / PyO3
9581eadc6next_row_asyncreturnedResult<_, String>, sofetch_one/fetch_many/__anext__all raised a barePyRuntimeError._retry.RETRIABLE_EXCEPTIONSmatches by class — so a retriable failure reached viaasync forwas unrecognisable and a retryable transaction failed permanently. The sync twin was correct, andfetch_allbypasses the path, which showed false parity. Now routed throughuni_error_to_pyerr0821205ddUniSync::shutdownended inmem::forget(self), which suppressed onlyRuntime::drop(theDropguard was alreadyNoneafterinner.take()). 48 → 232 OS threads over 8 cycles, ~23 leaked per callfee97302dAsyncUni.__aexit__open-codedflush()instead of delegating toshutdown, soasync with await AsyncUni.in_memory()— the idiomatic path — still stranded a runtime. All four teardown paths now covered: 0 stranded across 60 cycles each0847283efvalue_to_py's wildcard returnedpy.None()— the mechanism that shipped two releases silently droppingSparseVectorandBinaryVectorproperties. Now raisesPyValueErrornaming the variant. The arm is unreachable today (all 15 variants handled) but#[non_exhaustive]means the wildcard cannot be deleted0ca8efb50PyForkStatus::from_rustmapped unknown states toActive. Non-Activeis precisely the "mid-lifecycle, unsafe" signal — sodb.list_forks()reported such a fork healthy and callers opened sessions against it. NewUnknownvariant fails closedc56340a07(D12)in_memory()+shutdown()stranded its scratch directory (8/300 leaked → 0/300). Pythonshutdown()only flushed;Uni::shutdown_in_placenow gives anArc<Uni>a real shutdown and reaps the directory, warning on failurePydantic OGM
Beyond the row-dropping fix above,
55a65a721fixed two prerequisite defects that were invisibleuntil
ce8ba6381stopped the swallow:descriptor.is_listwas alwaysFalseandtarget_typealwaysNone, because the metaclassdeletes a relationship's annotation before the descriptor loop re-reads it. The shape is now
captured before deletion.
triggered the engine's multi-label
properties()bug (d0b948e24) — so every relationship targetloaded propertyless and failed validation. Endpoints now derive from the declaration, honouring
directionand unioning across shared edge types. Quoted annotations ("Bio | None","list[Book]") resolve by identifier match, with a permissive fallback rather than a guess.Entities with no related rows now get an explicit empty cache entry; previously they fell through to
the lazy path, which on the async session raises.
Plugins & loaders
algorithms_registeredchannel (0882bc168,463e8025a) —LoadOutcomenow reports whichalgorithms a plugin registered, across all four loaders, surfaced in the Python dicts and in
plugin install.denied_capabilitiesis fixed viaCapabilitySet::denied_against(effective).AlgorithmandGraphComputeare grantable (f24db798e, fixes Python: can't load/invoke a guest GraphCompute algorithm plugin (Algorithm/GraphCompute grants unavailable via load_rhai_plugin) #150). AGraphComputegrant string could not be parsed at all, so a guest algorithm loaded via
load_rhai_pluginfailedwith
ProcedureNotFound. There is now a singleCapability::parse_grant/grant_namepair(accepting PascalCase or kebab-case) used by the Python bindings and the CLI.
383f6ce41).f82cf6441) — arity and type checked,defaults filled at call time.
456565163). Internal capabilities(
Auth,Authz,Cdc,Catalog,PluginDeclare) and the phantomConnectorare removed fromthe grantable list;
GraphComputeandLocyGeneratorare added;BackgroundJobmoves to quotasand
PluginStorageto extension surfaces. Backed byGRANTABLE_NAMES/QUOTA_NAMES/INTERNAL_NAMESinuni_plugin::Capability.uni.http.*policy is shared between loaders (3aa2854be). This security-relevant policy —allow-list match, egress requirement,
WallClockMillisPerCalltimeout with a 10 s default, 8 MiBresponse cap,
>= 400rejection — existed in two independent copies. Consolidated intouni_plugin::host_services::http_request; guest-facing error codes (0xC20/0xC21/0xC23) andRhai error strings are byte-identical.
Performance
2f0479579— plugin instantiation was serialized behind a pointless mutex.InstancePoolstoredits factory as
Mutex<Box<dyn Fn() -> Result<T, E> + Send + Sync>>and called(self.factory.lock())(). The parking_lot guard lives to end-of-statement, so the lock was heldacross the entire instance construction: a pool configured
max_instances = 4advertisedfour-way concurrency and delivered one-way. The mutex bought nothing — the closure was already
Send + Sync.The serialized segment was
InstancePre::instantiateplusfresh_store(not a full compile;build_poolcaches theInstancePre). The evidence is a two-threadBarriertest that deadlocksto a 10 s timeout under the old code and passes in ~0 s after.
Simplification & dead-code removal
Driven by an explicit audit (
85aa17df1, 56 findings, including a deliberate do-not-do list of fourconsolidations that look right and are not). Beyond the removals listed in Breaking changes:
LogicalPlan::input_mut/map_input(3eb5261d7) replace 19-field destructure-and-rebuilddances where a forgotten field is dropped silently.
plan_childrenis exhaustive overLogicalPlan(e2c4b4da2). Its_ => vec![]treatedFusedIndexScanWrappedand fiveLocy*operators as leaves. It gatesis_ddl_or_admin(DataFusionvs. row-fallback routing) and
contains_write_operations. Behaviour is unchanged today — thosearms are unreachable from a root plan — but the row-fallback Locy arms are
unreachable!(), so afuture misclassification would have panicked.
cypher_scalar_udf!macro, with all 49name()stringsand 60
create_*_udfconstructors byte-identical (83a33581e).IndexDefinition::{name, label, metadata, metadata_mut}generated from onefor_each_index_variant!table (c75b8384a); the PythonDiffpyclasses generated from one macro(
9b1c3b436).4c8aee781), the empty-expansion prologuein traverse batch builders (
30182c9e8), andarg_string_listinuni-algo(c061e43a9).Testing & CI
5ec5b76b2), with the rule codified(
02282bc11). Cargo builds one test binary per.rsfile directly undertests/, each staticallylinking the crate's full transitive dep set (datafusion, lance, candle), so loose files inflate
build and link time and the process count nextest spawns. Per crate: uni-cypher 9→1,
uni-plugin-host 6→1, uni-locy 5→1, uni-query-functions 4→1, uni-query 4→1 (three drifted
standalones folded back), uni-store 5→2 —
occ_modelstays standalone because it is theloom/shuttle model-check target and merging would drag real-
Writertests into the loom build,which panics. Test counts are identical before and after (105/61/121/266/757/625).
New project rule: a hard cap of 3 integration-test binaries per crate — add a
modtotests/integration.rsrather than a newtests/*.rs. Documented indocs/test_layout.mdandAGENTS.md, so it is seen when adding a test rather than at review.9a6b7714f) — every loader (rhai / pyo3 /wasm-component / extism) × every extension kind, with each granted behaviour paired with a denied
one, so each row proves the capability actually gates. That pairing is the hole that let Python: can't load/invoke a guest GraphCompute algorithm plugin (Algorithm/GraphCompute grants unavailable via load_rhai_plugin) #150
ship. Covers scalar/aggregate/procedure/algorithm/host-fn registration and invocation, native
uni.algo.gcpagerankparity for all three guest graph algorithms, thealgorithms_registeredchannel, and strict grant-string rejection of internal and quota names.
python-wasm-testslane (9634e7613, moved post-merge by745038451). When the wheel'sdefault features were emptied for size, 12 loader tests silently broke. Tests are now
feature-gated, and the lane builds a
wasm-plugins,extism-pluginswheel on its own runner —fixtures built before the wheel (otherwise vacuously green), and the job asserts the loaders
are present before pytest, so a feature build that silently failed cannot pass on skips.
Accepted tradeoff: it runs post-merge, so a broken WASM/Extism loader now fails on main rather
than on the PR.
gatestill requires it, so loaders are verified before a tag can publish.a9a0c0496,041b13e10).uni-plugin-pyo3isentirely
#![cfg(feature = "pyo3")]with no default features, and theuni-python*crates areexcluded for linker reasons — so its tests compiled in no CI invocation whatsoever. They now
run in the Python job, the only one with a pyarrow venv (
auto-initializelinks system Python,whose
sys.pathexcludes the project venv). Every job inpr.yml/ci.yml/nightly.yml/release.ymlwas audited for the missing-nextest shape; none remain.7526fa514).uv run --project bindings/uni-dbwas re-syncing the environment — uninstalling the built wheel and restoring theeditable install — so six notebooks had been validating a
maturin developbuild while reportingthey validated the release wheel (reproduced: wheel 3.1.0 vs. 3.5.0 editable). Every
uv runnowpasses
--no-sync,dist/*.whlis cleaned before build, and a guard asserts the installeddistribution matches the workspace version before any notebook runs.
47fc03463). Three heavy issue-get_edges latency scales with graph size, not out-degree #55get_edgesprobes each saturated themachine, timing out against the default 180 s ceiling (60 s period × 3). Fixed by grouping them at
max-threads = 1and raising that ceiling to 360 s. Both halves are load-bearing, and theconfig records the measurements proving it: raising the ceiling for the heaviest test alone just
moved the failure to the other two; raising it to 360 s for all three without grouping was still
red (the suite went 367 s with 1 failed + 1 timed out, and it also starved an unrelated
uni-plugin-rhaithroughput-floor test). Grouped and raised: green at 6126/6126, with the threetaking 245 s / 210 s / 56 s. Serialising within the group does not hand them an idle box — ~6,000
other tests keep running alongside. No assertion was relaxed, the global 3-minute backstop is
untouched elsewhere, and both rejected approaches are recorded in
.config/nextest.tomlso they are not retried blindly.26ac83be6) — six red#[ignore]d repros runnable via--run-ignored all -E 'test(repro_)', plus two green probes thatcorrect the analysis (forks project identically to the parent; Cypher-mode
graphRefdoes seecommitted-unflushed rows).
68e105381coversUniBuilder::build_sync, which had no in-repo caller; the test drives the handlefrom its own runtime, proving the database outlives the runtime
build_syncopened and dropped.3e9a47355) picks up the two missing steps (UNI_GC_TRACE=1, and--features pyo3with its PYTHONPATH incantation) plus four gotchas: a stale wheel indist/,maturin developneeding an existingTMPDIR(andtailmasking its exit status),LD_PRELOADbeing venv-scoped rather than global, and
wasm-toolsbeing needed only forwasm32-unknown-unknownfixtures.Documentation
New in
docs/proposals/: the Plugin Compute ABI design (plugin_compute_abi_2026-07-13.md), gueststateful compute (
guest_stateful_compute_2026-07-20.md), the 12-gap pluggability verification(
graphcompute_pluggability_gaps_verification_2026-07-22.md), projection parity(
graphcompute_projection_parity_2026-07-19.md), the floor/mod analysis, the stepped-dynamicsrequirements response, the duplication / dead-code audit (
dup_dead_simplify_audit_2026-07-30.md),the cleanup sweep (
cleanup_sweep_2026-07-31.md), and the Python plugin ABI gap plan.docs/UNI_BLACK_BOOK.mdgains a full GraphCompute part — the guest surface, kernel catalogue andhandles, determinism/budgets/safety, capability gating, the scoped-or-loud L0-consistent projection
contract, first-party dogfooding providers, and loader surfaces — plus a
ShadowCsrsection.website/docs/plugins/graph-algorithms.md(676 lines) is the user-facing counterpart, and theuni-dbskill reference gains a graph-algorithms reference.A design proposal that deliberately landed nothing
docs/proposals/simulation_first_class_2026-07-23.md(42f549d38) answers "should MCTS befirst-class in uni-db?" with no — and it already correctly isn't, living as
graph-arena@1kernels driven by a guest
AlgorithmProvider.Its substantive finding is that the pieces of a simulation engine already exist as four
disconnected islands that cannot compose: Locy
ASSUME/ABDUCE(real semantics, but a coldfull-fixpoint recompute per hypothetical, single-ply),
Session::scratch()(a ~1 ms ephemeral world,but a non-branchable leaf that pins primary),
graph-arena@1(18.5 M rollouts/s, but over asynthetic in-arena tree with a guest-supplied score), and Lance forks (durable, but ~10 ms
create/drop). You can have fast-but-synthetic or real-but-slow, never "fast tree search whose
rollouts mutate the real graph and are scored by a real Locy rule."
The proposal makes the environment first-class rather than the search, and names incremental
fixpoint maintenance as the keystone capability host code fundamentally cannot supply.
Status: design only — nothing landed.
Dependencies
lancedbremoved entirely from the workspace dependency table and from thelance-backendfeature.
uni-storebuilds directly onlance.uni-dbwheels no longer linkwasmtime,cranelift,wasi-commonorextism.Migration guide — upgrading from 3.0.0 to 3.2.0
If you use uni-db through Cypher,
Uni/Session/Transaction, or the PythonDatabaseAPI, thechanges that can reach you are:
Database.load_wasm_componentandDatabase.load_wasm_extismare gone. Build from source with--features wasm-plugins,extism-plugins, or use the Rhai / PyO3 loaders.UniTimeoutError(Python) or matchUniError::Timeout(Rust).CursorTimeoutErroris deleted.toString(1.0),toInteger('3.7'),toBoolean(1),split(s, null), or negative-argumentsubstring. See thetable in Breaking changes.
eager_load()— drop["key"]subscripting; use attribute access. To-manyrelationships now correctly return lists.
previn a comparison — rewriteALONG ok = prev.h > 5as arithmetic.If you author GraphCompute plugins:
nodeLabels/edgeTypes, or{projectAll: true}.rekey— single-projection algorithms need no change; multi-graphones must be explicit.
yieldsmust be Int64/Float64 spellings; a string or bool yield now fails at loadrather than at call.
uni_raw_byteson the yield field.If you build against
uni-store/uni-query/uni-plugininternals, see the Rust embedderAPI removals table. The two changes most likely to reach you are
FilterExpr(string predicatesmust become
FilterExpr::Rawor, better, a structured tree) andfind_props_by_eidgaining aversionparameter.Upgrade notes (behaviour changes to re-validate)
FOLDoverCOLLECT/COUNTnow compiles. It can iterate tomax_iterationsrather than converging, since those semilattices have
has_top: false. If you were relying on thecompiler to reject such programs, add your own guard.
CHECKequality now coerces Int/Float, matching what the bulk path always did.CHECK (score = 5)now passes against a storedFloat(5.0)viatx.executeas well as viaBulkWriter.0. A name that cannot bequoted (one containing a backtick) is refused explicitly.
Session::cancel(), onTransaction::cancel(), and on Locy fixpoint evaluation. Work thatpreviously ran to completion despite a cancel, or streamed past a memory limit, will now stop.
strip = "symbols"). Panic messages stillreach Python through PyO3.
[profile.dist].gatestill requires it before a tag can publish.
Closed issues
(
Algorithm/GraphComputegrants unavailable viaload_rhai_plugin)weights (
edge_weightsreturned1.0)Also carried fixes touching #53, #55, #106, #115, #131 and #135.
This discussion was created from the release v3.2.0.
All reactions