Repository navigation
v0.3.0 #44
milliondreams
announced in
Announcements
v0.3.0
#44
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.
[0.3.0] - 2026-09-26
Breaking
ModalityExtractor::extractis replaced byprepare+apply_in_tx,with a new defaulted
finish_post_commit. Attachments now commit inside thesame transaction as the turns carrying them, which requires uniko to own that
transaction. A
txparameter onextractwould not be enough: a unit mustknow every
content_idit will merge before opening the transaction, so itcan 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
extractwouldhave 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 the
implblock instead.Post-commit work — such as
Artifact.image_embedding, which has no embeddingconfig and is written through a self-committing update — moves to
finish_post_commit. Python bindings are unaffected: Python-authoredextractors were never exposed.
Session::summarizereturnsSummarizeReportrather thanOption<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):IngestSource::with_revision/Turn::revisiondeclare an immutablerevision 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 with
IdConflict, becausea revision id is a promise about the content.
A new revision of a source stamps
superseded_aton the previous one andrecords
SUPERSEDESfrom new to old.Agent::retire_sourcestampsSource.retired_at. Ordinary recall excludes both, so replaced orretired evidence cannot ground a current answer, while
Scope::include_supersededreaches it so an older result staysattributable 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 retiringone source cannot affect another that merely shares identical bytes — those
share an
:ArtifactContentrow but not a:Source.SUPERSEDESwas already declaredRule → Rule; uni-db permits onedeclaration 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):A caller can record a record class and a stable logical source without
placing either in searchable prose. A new
:Sourcenode holds the logicalidentity, with
FROM_SOURCEfrom the Message or Artifact;categoryandsource_idare denormalised onto Message, Artifact, Chunk, Observation andFact 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 the
coveragescore describe thepermitted 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. Every
RecallItemreports its own
categoryandsource_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 afilter 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):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 separate
observecallscannot 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 cannotdrift. 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 its
NEXTedge.
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_rowsordered 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 observationchunk, with nothing to explain why. Since
FinalizeReport::rebuiltORs thetranscript 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 anunreadable chunk surface look absent, and defaulted unreadable text to
"", making a readable chunk look changed;session_transcript_rowssubstituted
"unknown"for an unreadable speaker name, rewriting thetranscript 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, theprovenance 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 of
2*pages + 4*blocks + chunksseparate commits into a single one.
PDF provenance edges could vanish silently.
link_pdf_contextskipped anunresolvable reference with no output at all — not even the debug line the
artifact path emits — wrote
ATTACHED_TOwithoutattached_at, and had noPRODUCEDleg.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:
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 whenthere 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.
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 theinverted index caches that loop in the shared session, while
similar_todrove 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 in4.1.1, verified here against the LoCoMo bench, which previously hung three
questions in and now completes.
Fixed
The learned-sparse and ColBERT retrieval channels never ran. Both
produced their query vector through the
embed/hybridalias, but a sparsequery needs a
SparseEmbeddingModeland a ColBERT query aMultiVectorEmbeddingModel— anEmbedHybridalias supplies neither, soevery query failed. The write path tolerates the same mismatch (it falls back
to the hybrid model's matching head), so ingest populated
sparse_embeddingand
colbert_embeddingnormally and the failure was query-only. Failures wereswallowed at
debug(sparse) andwarn(ColBERT), leaving an enabled channelindistinguishable 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 one
embed/hybridalias serves the document pass and both query sides.The interim workaround — a dedicated
EmbedSparse/EmbedMultiVectoraliasper 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 thenlabels(node), which fails once the call actually executes. This wasunreachable 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-retriable
UnikoError::IdConflict(
uniko.IdConflictErrorin Python), which is deliberately not the retriableConflictthe 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_artifactmatchedon the content hash before the caller's
artifact_id, returned the firstartifact's node while echoing back the second id, and skipped
link_artifact_context— so the second session got noATTACHED_TOedge andrecalled 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, andreusing one id for different bytes raises
UnikoError::IdConflict. Anauto-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, viaensure_session_and_sender— solink_artifact_contextfound no session toattach to and silently skipped the
ATTACHED_TOedge. Session-scopedrecall's
Artifact -ATTACHED_TO-> Sessionarm was correct all along; theedge 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 to
auto_flush_interval, 5s) in which a crash strandedcommitted, fsynced writes. Opening a persistent knowledge base now publishes
a baseline snapshot before any caller write can reach it. Tracked upstream
as
rustic-ai/uni-db#275.Phase 1 of the recall cascade contributed nothing on any facade-ingested
knowledge base.
phase1_strategydefaults to"boost", which scoressession-level chunks reached via
Fact -SUPPORTED_BY-> Observation -OBSERVED_IN-> Message -IN_SESSION-> Session -HAS_CHUNK-> Chunk. That walk returned nothing, for two independentreasons:
fact_session_chunk_idstraversedSUPPORTED_BYinbound from theFact, but the schema registers it
Fact → Observation, so the patterncould never match.
chunk_sessionandchunk_session_observationswere only ever called byuniko-bench; nofacade path invoked them.
Session-scoped recall and the observation → entity
ABOUTbridge were darkfor the same reason.
Agent::delete_sessionorphaned session-anchored chunks, leaving deletedcontent live in the vector and full-text indexes and still recallable.
Consolidation never triggered itself, so
Facts,Procedures andTopicswere never derived for any library consumer.
ObservationsReady— thesignal 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, and
Agent::consolidate()provides an explicit path that needs no streaming pipeline.
Added
Session::finalize— builds (or refreshes) a session's transcript andobservation chunk surfaces, returning a
FinalizeReport. Cheap andidempotent 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 thefix without a code change.
Agent::finalize_sessionandAgent::unfinalized_session_ids— thebackfill path for knowledge bases ingested before the above fix. No automatic
migration runs at
open(); backfill is an explicit, resumable loop.ChunkModeon the session chunkers:Oncekeeps the previousbuild-once semantics,
Refreshrebuilds a grown session by deleting the oldgeneration and writing the replacement in a single transaction.
Agent::consolidate— run one consolidation cycle on demand, returningCycleStats. Always available; no streaming pipeline required.Sessionis a context manager in Python:async with agent.session(id)(or a plain
with) finalizes on exit, best-effort, without suppressing anin-flight exception.
FinalizeReport.ended_at—finalizenow stamps the Session'sended_atfrom its latest message. Note a Session counts as open while
ended_atisnull, so a finalized Session is skipped by the inactivity auto-close sweep.
Session.finalize,Agent.finalize_session,Agent.unfinalized_session_ids,Agent.consolidate, plus*_synctwins,FinalizeReport,CycleStats,stubs, and the
uniko.modelsmirror).Removed
UnikoConfig::phase1_coverage_threshold. It was defaulted and range-validatedbut never read — recall uses the hardcoded
COVERAGE_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 edgetypes; the tracked snapshot had drifted to 22/48).
config/catalog_minilm.jsonregenerated: itsnlp/defaultalias still usedthe retired
task: "raw"shape and would not load.Refreshof the session chunks is now incremental — chunks are comparedindex by index and only the suffix from the first mismatch is rebuilt, so
appending turns re-embeds the tail rather than the whole transcript.
docs/BLACKBOOK.md, thewebsite,
README.md, andbindings/uniko-py/README.md— most consequentiallythe install story, which claimed uniko was unpublished and had no prebuilt
wheels. Also fixed the wrong
query_variantsdoc-comments inuniko-store::configanduniko-memory::recall(an empty vec selects thesingle
keywordsvariant, not all four), which the website had copiedverbatim.
This discussion was created from the release v0.3.0.
All reactions