Repository navigation
[0.3.0] - 2026-09-26
Breaking
-
ModalityExtractor::extractis replaced byprepare+apply_in_tx,
with a new defaultedfinish_post_commit. Attachments now commit inside the
same transaction as the turns carrying them, which requires uniko to own that
transaction. Atxparameter onextractwould not be enough: a unit must
know everycontent_idit will merge before opening the transaction, so it
can take the striped locks first, and a single call gives it no point at
which to ask. The split also keeps decoding and model inference outside the
transaction, where a retry does not re-pay them.No default body can soften this. One forwarding to the old
extractwould
have to open its own transaction — exactly the non-atomicity this removes —
and would silently degrade a host that believed it had migrated. Implementors
get a hard error at theimplblock instead.// before async fn extract(&self, kb: &KnowledgeBase, src: &IngestSource) -> Result<ArtifactIngestResult, UnikoError> { let caption = self.vlm.caption(src).await?; // model let nid = kb.create_node("Artifact", &props).await?; // commits Ok(ArtifactIngestResult { artifact_node_id: nid, .. }) } // after async fn prepare(&self, kb: &KnowledgeBase, src: &IngestSource) -> Result<Box<dyn ModalityPrepared>, UnikoError> { let caption = self.vlm.caption(src).await?; // model, no tx open let put = kb.put_blob(&hash, &bytes).await?; // blob I/O Ok(Box::new(MyPrep { hash, size, caption, put })) } async fn apply_in_tx( &self, kb: &KnowledgeBase, tx: &Transaction, prep: &dyn ModalityPrepared, ctx: ArtifactContextNids, seen: &mut UnitArtifactSeen, ) -> Result<ArtifactIngestResult, UnikoError> { let prep = prep.as_any().downcast_ref::<MyPrep>().expect("own prep"); let nid = kb.create_node_in_tx(tx, "Artifact", &props).await?; // ctx.message_nid is already resolved — do NOT look it up by id. Ok(ArtifactIngestResult { artifact_node_id: nid, .. }) }
Post-commit work — such as
Artifact.image_embedding, which has no embedding
config and is written through a self-committing update — moves to
finish_post_commit. Python bindings are unaffected: Python-authored
extractors were never exposed. -
Session::summarizereturnsSummarizeReportrather than
Option<NodeId>; the Python binding returns(summary_id, finalize_error).
The chunk refresh it performs is still best-effort — failing summary
generation over a transient conflict would regress existing callers — but its
outcome is now reported instead of only logged, so a caller can tell that a
summary was built from stale chunks.
Added
-
Source revisions and retirement (
rustic-ai/uniko#41):// A newer revision of the same source replaces the previous one. session.ingest( IngestSource::text(page_v2).with_source("wiki-summit").with_revision("rev-b"), ).await?; agent.recall("summit elevation").await?; // rev-b only agent.recall_in("summit elevation", Scope::default().include_superseded()).await?; // history too agent.retire_source("wiki-summit").await?; // neither
IngestSource::with_revision/Turn::revisiondeclare an immutable
revision identity. Together with the existing content fingerprint it
decides idempotency: the same revision with identical bytes is a no-op, and
the same revision with changed bytes is rejected withIdConflict, because
a revision id is a promise about the content.A new revision of a source stamps
superseded_aton the previous one and
recordsSUPERSEDESfrom new to old.Agent::retire_sourcestamps
Source.retired_at. Ordinary recall excludes both, so replaced or
retired evidence cannot ground a current answer, while
Scope::include_supersededreaches it so an older result stays
attributable to the revision that grounded it. Nothing is deleted.Landing this does not change anyone's results on its own: the exclusion set
is resolved first, and when nothing has been retired or superseded it comes
back empty and no predicate is emitted at all. Results shrink only once
something has actually been retired or replaced.Retirement is recorded on the
:Source, never on the content, so retiring
one source cannot affect another that merely shares identical bytes — those
share an:ArtifactContentrow but not a:Source.SUPERSEDESwas already declaredRule → Rule; uni-db permits one
declaration per edge type, so both endpoint pairs are now declared
together. Schema node and edge counts are unchanged. -
Typed record provenance and pre-ranking recall filters
(rustic-ai/uniko#39):session.observe( Turn::new("agent", "the query returned 42 rows") .category("executed_result") .source("warehouse-prod"), ).await?; let scope = Scope::default().categories(["executed_result"]); let bundle = agent.recall_in("row counts", scope).await?;
A caller can record a record class and a stable logical source without
placing either in searchable prose. A new:Sourcenode holds the logical
identity, withFROM_SOURCEfrom the Message or Artifact;categoryand
source_idare denormalised onto Message, Artifact, Chunk, Observation and
Fact so the filter is a property predicate rather than a traversal.The filters resolve into the allow-set that candidate generation itself
uses, so ranking, the result limit and thecoveragescore describe the
permitted evidence — not items that ranked well and were discarded after
Uniko had already scored them. A filter with no eligible matches returns
empty rather than being padded with other categories. EveryRecallItem
reports its owncategoryandsource_id;Nonemeans "none recorded" or
"no single traceable source", not "filtered out".Episodecarries no provenance, so a provenance filter excludes it,
matching the documented rule that a node which cannot anchor a dimension
goes dark under a filter on it.ArtifactandFactbecome reachable by a
filter for the first time: neither anchors session, participant or time, so
the older dimensions had no arm for them.Schema is now 26 node types, 55 edge types.
-
Atomic, idempotent multi-turn units (
rustic-ai/uniko#40):session.unit() .turn(Turn::new("alice", "what's the plan?").id("m-1")) .turn(Turn::new("bob", "ship it friday").id("m-2")) .commit().await?;
Every message, its edges and chunks, the unit's merged entity upsert, each
turn's mentions and observations, and every attachment are written in ONE
transaction that commits once. A related pair is either wholly visible to
later recall or wholly absent — the guarantee two separateobservecalls
cannot give, where an interruption leaves a question with no answer.Re-committing a unit whose ids are all present with identical content is a
no-op. Reusing an id with different content, repeating an id within one unit,
or submitting a unit that is only partly recorded is rejected: a correct
caller cannot produce a partial unit, and both alternatives — ingesting only
the absent turns, or treating the whole unit as a no-op — silently corrupt.Session::observeis now a one-turn unit, so the two entry points cannot
drift. Two behaviour changes follow: attachments commit with their
message rather than in separate transactions afterwards, and an idempotent
replay advances the chain head so the following turn still gets itsNEXT
edge. -
Agent::finalize_sessionquiesces the streaming pipeline before reporting,
so it cannot claim completion while ingest for that session is still in
flight. The barrier is instance-wide rather than per-session: broader than
needed, but it never waits too little.
Fixed
-
Observation ordering was not a total order.
session_observation_rows
ordered by the message's timestamp, which every observation from that
message shares, so their relative order was arbitrary. The observation chunk
surface concatenates those rows, so a reordering changed the chunk text and
finalize()rebuilt an unchanged session — re-embedding every observation
chunk, with nothing to explain why. SinceFinalizeReport::rebuiltORs the
transcript and observation surfaces, this surfaced as a finalize claiming to
have rewritten a session nobody had touched. -
Session chunking no longer degrades failed reads into wrong data.
session_chunk_rowsdropped a row whose id failed to decode, making an
unreadable chunk surface look absent, and defaulted unreadable text to
"", making a readable chunk look changed;session_transcript_rows
substituted"unknown"for an unreadable speaker name, rewriting the
transcript that the idempotency check compares. All three now propagate. A
genuinely NULL speaker still reads as"unknown"— that case is legitimate. -
Artifact ingest is atomic. The node, its
HAS_CONTENTedge, the
provenance edges and the chunks were written in four-plus separate commits,
so a failure between them left a committed Artifact with no chunks. Both the
text and PDF paths now write in one transaction; for the tiered PDF path this
also collapses what was on the order of2*pages + 4*blocks + chunks
separate commits into a single one. -
PDF provenance edges could vanish silently.
link_pdf_contextskipped an
unresolvable reference with no output at all — not even the debug line the
artifact path emits — wroteATTACHED_TOwithoutattached_at, and had no
PRODUCEDleg.
Changed
-
uni-db 3.4.0 → 4.1.1, uni-xervo 0.17.0 → 0.18.1. No API changes were
needed in uniko. The upgrade retires two workarounds:- The baseline snapshot published on every persistent open, which existed
because a crash inside the first-flush window left a store that could
never be reopened (rustic-ai/uni-db#275). 4.0.0 replays the WAL when
there is no L1 data to collide with, so the flush is gone and the repro
is now a live regression guard rather than an expected failure. - The per-channel narrow embed aliases (see Fixed), retired by uni-xervo
0.18.1.
4.0.0 alone was not adoptable: re-applying an unchanged schema to a populated
label was rejected, so no persistent store could be reopened after its first
write (rustic-ai/uni-db#286). Fixed in 4.1.0; the repro stays as a guard.4.1.0 was not adoptable either: full-text recall deadlocked. Lance's
ScanSchedulerbinds its I/O loop to whichever runtime is current and the
inverted index caches that loop in the shared session, whilesimilar_to
drove its FTS lookup on a per-call runtime it then dropped — so once a query
loaded the index, every later query for an uncached term waited on a dead
loop. A repeated term was served from cache, which is why it presented as
"the second different query hangs" (rustic-ai/uni-db#290). Fixed in
4.1.1, verified here against the LoCoMo bench, which previously hung three
questions in and now completes. - The baseline snapshot published on every persistent open, which existed
Fixed
-
The learned-sparse and ColBERT retrieval channels never ran. Both
produced their query vector through theembed/hybridalias, but a sparse
query needs aSparseEmbeddingModeland a ColBERT query a
MultiVectorEmbeddingModel— anEmbedHybridalias supplies neither, so
every query failed. The write path tolerates the same mismatch (it falls back
to the hybrid model's matching head), so ingest populatedsparse_embedding
andcolbert_embeddingnormally and the failure was query-only. Failures were
swallowed atdebug(sparse) andwarn(ColBERT), leaving an enabled channel
indistinguishable from an unhelpful one while still costing a query per
variant. Fixed upstream in uni-xervo 0.18.1, which adapts a hybrid handle to
each narrow facade (hybrid_adapter::HybridAsSparse/HybridAsMultiVector),
so oneembed/hybridalias serves the document pass and both query sides.
The interim workaround — a dedicatedEmbedSparse/EmbedMultiVectoralias
per channel, which cost the sparse head its place in the single forward pass —
is removed with the 4.1.0 bump. -
Sparse and ColBERT recall projections asked for a column the procedures do
not yield.uni.sparse.queryanduni.vector.queryyieldvid / score / rerank_score, but both call sites wroteYIELD node, scoreand then
labels(node), which fails once the call actually executes. This was
unreachable until the alias fix above, which is why it had never surfaced.
Both now bind the vertex explicitly from the vid. Still required on uni-query
4.1.0 —fts_query_yields()is unchanged. -
A reused
message_idsilently kept the original turn.observetreated
"this id exists" as "this is a replay" without comparing content, so a second
call under the same id returned the first record and discarded the new turn —
with no error. Identical content stays idempotent; a genuine content
disagreement now raises the new non-retriableUnikoError::IdConflict
(uniko.IdConflictErrorin Python), which is deliberately not the retriable
Conflictthe ingest retry loop spins on. -
Identical bytes ingested under two caller-chosen ids collapsed onto one
artifact, and the second id resolved to nothing.ingest_artifactmatched
on the content hash before the caller'sartifact_id, returned the first
artifact's node while echoing back the second id, and skipped
link_artifact_context— so the second session got noATTACHED_TOedge and
recalled nothing from a document it had just ingested. Deduplication was also
store-global, so this crossed agents, not just sessions.A caller-supplied id is now the artifact's identity: two ids over identical
bytes are two artifacts, sharing one stored copy of the content on
:ArtifactContentwhere the data model always said deduplication lived, and
reusing one id for different bytes raisesUnikoError::IdConflict. An
auto-generated id expresses no identity, so identical bytes still dedup
onto the existing artifact — re-running a corpus load does not duplicate
every document. Either way a dedup hit now wires the current call's
session/message provenance before returning, and reports the id the artifact
actually lives under rather than a throwaway UUID that resolves to nothing.
Both the text and PDF ingest paths were affected. -
A session that only ingested documents could never recall them.
Session::ingestnever created the:Sessionrow — onlyobservedid, via
ensure_session_and_sender— solink_artifact_contextfound no session to
attach to and silently skipped theATTACHED_TOedge. Session-scoped
recall'sArtifact -ATTACHED_TO-> Sessionarm was correct all along; the
edge was simply never written. Ingest now materializes the session first. -
A crash before a new store's first flush left it permanently unopenable.
uni-db refuses to open a store with WAL segments and no snapshot manifest,
and the manifest is written only by a flush — so every new store had a
window (up toauto_flush_interval, 5s) in which a crash stranded
committed, fsynced writes. Opening a persistent knowledge base now publishes
a baseline snapshot before any caller write can reach it. Tracked upstream
asrustic-ai/uni-db#275. -
Phase 1 of the recall cascade contributed nothing on any facade-ingested
knowledge base.phase1_strategydefaults to"boost", which scores
session-level chunks reached via
Fact -SUPPORTED_BY-> Observation -OBSERVED_IN-> Message -IN_SESSION-> Session -HAS_CHUNK-> Chunk. That walk returned nothing, for two independent
reasons:fact_session_chunk_idstraversedSUPPORTED_BYinbound from the
Fact, but the schema registers itFact → Observation, so the pattern
could never match.- No session-level chunks existed.
chunk_sessionand
chunk_session_observationswere only ever called byuniko-bench; no
facade path invoked them.
Session-scoped recall and the observation → entityABOUTbridge were dark
for the same reason.
-
Agent::delete_sessionorphaned session-anchored chunks, leaving deleted
content live in the vector and full-text indexes and still recallable. -
Consolidation never triggered itself, so
Facts,Procedures andTopics
were never derived for any library consumer.ObservationsReady— the
signal that advances the consolidation worker's per-agent counter — was
defined, re-exported and received, but nothing produced it, so neither the
threshold nor the periodic timer (which only sweeps agents with a non-zero
counter) ever fired. The ingest path now emits it, andAgent::consolidate()
provides an explicit path that needs no streaming pipeline.
Added
Session::finalize— builds (or refreshes) a session's transcript and
observation chunk surfaces, returning aFinalizeReport. Cheap and
idempotent when the session has not grown: nothing is rewritten and nothing
is re-embedded. Awaits the streaming pipeline first when streaming is on.
Session::summarizenow calls it best-effort, so existing callers get the
fix without a code change.Agent::finalize_sessionandAgent::unfinalized_session_ids— the
backfill path for knowledge bases ingested before the above fix. No automatic
migration runs atopen(); backfill is an explicit, resumable loop.ChunkModeon the session chunkers:Oncekeeps the previous
build-once semantics,Refreshrebuilds a grown session by deleting the old
generation and writing the replacement in a single transaction.Agent::consolidate— run one consolidation cycle on demand, returning
CycleStats. Always available; no streaming pipeline required.Sessionis a context manager in Python:async with agent.session(id)
(or a plainwith) finalizes on exit, best-effort, without suppressing an
in-flight exception.FinalizeReport.ended_at—finalizenow stamps the Session'sended_at
from its latest message. Note a Session counts as open whileended_atis
null, so a finalized Session is skipped by the inactivity auto-close sweep.- Python parity for all of the above (
Session.finalize,
Agent.finalize_session,Agent.unfinalized_session_ids,
Agent.consolidate, plus*_synctwins,FinalizeReport,CycleStats,
stubs, and theuniko.modelsmirror).
Removed
UnikoConfig::phase1_coverage_threshold. It was defaulted and range-validated
but never read — recall uses the hardcodedCOVERAGE_GATE_PHASE1constant —
so setting it did nothing. Deserializing a config that still carries the key
is unaffected (unknown fields are ignored).
Changed
config/schema.jsonregenerated from the live schema (25 labels, 54 edge
types; the tracked snapshot had drifted to 22/48).config/catalog_minilm.jsonregenerated: itsnlp/defaultalias still used
the retiredtask: "raw"shape and would not load.- A
Refreshof the session chunks is now incremental — chunks are compared
index by index and only the suffix from the first mismatch is rebuilt, so
appending turns re-embeds the tail rather than the whole transcript. - Documentation corrected against source across
docs/BLACKBOOK.md, the
website,README.md, andbindings/uniko-py/README.md— most consequentially
the install story, which claimed uniko was unpublished and had no prebuilt
wheels. Also fixed the wrongquery_variantsdoc-comments in
uniko-store::configanduniko-memory::recall(an empty vec selects the
singlekeywordsvariant, not all four), which the website had copied
verbatim.
What's Changed
- fix(store): give session setup its own lock domain by @milliondreams in #37
- Fixies and update to uni-db 4.1.1 by @milliondreams in #42
- Prep release 0.3.0 by @milliondreams in #43
Full Changelog: v0.2.1...v0.3.0
What's Changed
- fix(store): give session setup its own lock domain by @milliondreams in #37
- Fixies and update to uni-db 4.1.1 by @milliondreams in #42
- Prep release 0.3.0 by @milliondreams in #43
Full Changelog: v0.2.1...v0.3.0