mixle.substrate.core module¶
Core substrate store and item model.
The substrate is a filesystem-backed, queryable surface for typed items with
provenance, scope, freshness metadata, tags, and links. Raw data, documents,
model artifacts, traces, simulation outputs, ontology triples, and context
packets can all be represented as SubstrateItem records.
Text and document items rank by cosine similarity over a learned embedding when available. Structured records and other items fall back to lexical, tag, and provenance matching. Higher-level retrieval and context assembly build on this single local store.
- class SubstrateItem(kind, text='', payload=<factory>, provenance=<factory>, scope='local', tags=<factory>, links=<factory>, derived_from=<factory>, id=<factory>, created_at=<factory>)[source]
Bases:
objectOne typed, provenanced, scoped item in the substrate.
Edge typing (MXR-080-0261):
linksandderived_fromare both lists of item ids, but they are NOT interchangeable.linksis the generic, untyped KG-relation surface – “related to”, “mentions”, “co-occurs with”, anythingkg_rag/multihopwant to associate two items by.derived_fromis narrower and load-bearing: it is the ONLY place genuine provenance/ancestry (“this item was derived from that one”) is recorded, and it is the only fieldverify_lineage()ever traverses. Before this field existed, lineage verification walkedlinksitself, so a merely-related entity was indistinguishable from a true derivation parent – putting a citation-worthy relation intolinkssilently made it certifiable ancestry. Keeping the two lists separate (rather than tagging each entry with a type) is a deliberate minimal-diff choice: every existing reader oflinks(kg_rag,multihop,freshness,context,governance,spaces) keeps reading exactly the same untyped list it always has, with no change to its shape or meaning.- Parameters:
- class Substrate(root=None, *, secret_policy='redact')[source]
Bases:
objectA local shard of the knowledge substrate: a filesystem-backed store with typed retrieval.
put/get/remove/allmanage items;searchretrieves thekmost relevant items for a query, filtered by kind and scope, ranking text items semantically (a learned embedding over the current text corpus) and everything else lexically.save/loadpersist the shard as oneitems.jsonlunderroot.Immutability contract: every item that crosses the store boundary is a defensive copy.
putcopies the object you hand it before storing it;get/all(and thereforesearch) return copies of what is stored. Mutating an object you obtained from – or are about to pass to – this store never affects it: the store’s own state, and everything derived from it (the semantic index), is independent of what any caller does with its own reference afterward. The only way to change a stored item isput()(replace wholesale) orupdate()(change named fields); both revalidate the result and correctly invalidate the semantic index, exactly like a fresh write.Scope isolation contract:
search(..., scope=X)ranks using an embedding index fit ONLY on scopeX’s own text items (seereindex()) – a scoped query’s results, and the model that produced them, never depend on any other scope’s content, so the presence or content of items in a scope you cannot pass never leaks through a scope you can.scope=None(the default) is the one deliberate exception: it asks for every item across every scope, so its index is fit over everything, same as asking for all of it directly throughall()/get()would show you.Secret-handling contract (MXR-080-0262): every write –
put(), andupdate(), which routes through it – scansitem.text,item.payload(recursively), anditem.tagsfor known secret shapes (mixle.substrate.security) using exactly the surface_lexical_score()serializes for search, BEFORE anything reachesself._itemsor either index. This is unconditional: there is no path into the store that skips it, so the redact-before-store guardsafe_text()always offered is no longer opt-in.secret_policy(set at construction, or overridden per call) picks what happens when a secret is found:"redact"(the default) masks it in place so only the masked form is ever stored, embedded, or served;"reject"raisesSecretPolicyErrorand stores nothing.- Parameters:
root (str | None)
secret_policy (SecretPolicy)
- put(item, *, secret_policy=None)[source]
Add or replace an item; returns its id and schedules semantic-index rebuilds for text items.
Stores a defensive copy of
item(see the class docstring’s immutability contract) – the caller’s own object is never aliased into the store, so mutating it after this call has no effect. Dirty whenever this put could change what_text_items()(the embedding index’s real inclusion rule – ANY kind with a truthy.text, not just a fixed subset) would return: the new item itself carries text, OR it replaces an item that used to. That second half matters as much as the first – clearing an existing item’s text (or replacing it with a no-text item) shrinks the indexed corpus exactly as much as adding text grows it, and without it the stale embedding for the old text would keep matching queries after the text is gone.Secret-handling (MXR-080-0262, see the class docstring’s secret-handling contract): before anything is stored or indexed, this scans
item.text,item.payload(recursively), anditem.tags– the same surface_lexical_score()serializes for search – for known secret shapes, and acts persecret_policy(this call’s override, elseself.secret_policy, default"redact"):"redact"masks detected secrets in place before storing;"reject"raisesSecretPolicyErrorand stores nothing. This is unconditional – there is no way to reachself._itemsthat skips it.
- add(kind, text='', **kw)[source]
Convenience: build a
SubstrateItemandput()it.
- get(item_id)[source]
Return a defensive copy of the item stored as
item_id, orNonewhen it is absent.Mutating the returned object does not change what is stored – call
update()(orput()a full replacement) to make a change land, so index invalidation stays correct.- Parameters:
item_id (str)
- Return type:
SubstrateItem | None
- remove(item_id)[source]
Remove an item by id and return whether anything was deleted.
- all(*, kind=None, scope=None)[source]
Return defensive copies of stored items, optionally filtered by kind and scope.
As with
get(), mutating a returned item never affects the store; useupdate().
- update(item_id, *, secret_policy=None, **fields)[source]
Change named fields on the stored item
item_id, the supported way to edit in place.Mutating an object returned by
get()/all()never reaches the store – both return defensive copies, so the store’s own state (and anything derived from it, like the semantic index) is untouched by that.updateis how a change actually lands: it rebuilds the stored item withfieldsapplied, re-runsSubstrateItem’s own validation (__post_init__, e.g. rejecting an unknownkind) viadataclasses.replace(), and routes the result back throughput()’s storage path so the same dirty/index-invalidation bookkeeping AND the same secret-handling guard (see the class docstring’s secret-handling contract;secret_policyhere overridesself.secret_policyfor this call only) fire as they would for any other write – a change that affects what_text_items()covers (new text, cleared text, a different scope for a text item) correctly schedules a reindex instead of leaving the semantic index silently stale, and a change that introduces a secret into any field is masked (or rejected) exactly like a freshput()would.Returns a defensive copy of the item as actually stored (post-redaction, consistent with
get()/all()– NOT the raw pre-policyfields-applied object).- Raises:
KeyError –
item_idis not stored.ValueError –
fieldstries to changeid– update changes an item’s content, not its identity;puta new item andremovethe old one for that.SecretPolicyError –
secret_policy="reject"(orself.secret_policyis) and the update introduces a secret anywhere in the surface – nothing is changed.
- Parameters:
- Return type:
SubstrateItem
- reindex()[source]
(Re)fit one embedding index PER visibility domain. Idempotent, lazy-called.
MXR-080-0237: a single embedder fit over every scope’s text pooled together makes a scoped query’s transform and ranking depend on inaccessible scopes’ content – a membership side channel (whether some distinctive text exists in a scope you can never read is inferable from how it shifts your OWN scoped query’s scores). Fixed by fitting one embedder per distinct scope value present, each ONLY on that scope’s own text items, plus one unrestricted index (key
None) fit over everything for callers that passscope=None– that caller is asking for every scope by construction, so pooling there asserts no isolation boundary to violate.search()picks the index matching its ownscopeargument, so a scope’s results and the model that produced them never see a single byte of another scope’s text.- Return type:
None
- search(query, k=5, *, kind=None, scope=None)[source]
The
kmost relevant items toqueryas(item, score), filtered by kind/scope.Text-bearing items rank by cosine similarity in the learned embedding space fit for THIS
scopealone (seereindex()); when there are too few same-scope items to learn one (or for a non-text query), ranking falls back to a lexical token overlap. Structured items with no text always rank lexically over their serialized payload + tags.kmust be an exact, non-negativeint(MXR-080-0236, see_require_count()): “return up tokresults,” never a negative-indexing slice trick. Checked beforereindex()(a potentially expensive rebuild) ever runs, so an invalidkfails fast.retrieve()’s diversified and flat merge paths, andtop(), all validate through this same function, so a result count means exactly the same thing – and an invalid one is rejected exactly the same way – at every entry point across the retrieval surface.
- save(root=None)[source]
Persist the shard to
{root}/items.jsonl(one item per line), atomically.The full snapshot is written to a sibling temp file in
targetfirst, fsynced, then published with a singleos.replace– mirrorsmixle.task.artifact._atomic_json_dump()andmixle.system.registry.Registry._write_index()’s temp-file-plus-os.replaceconvention. A plainopen(path, "w")truncatesitems.jsonlBEFORE the new content is written, so any failure partway through this method (a crash, a disk-full write, a non-serializable item further down the store) would destroy the last good shard instead of just failing the save; staging the new snapshot off to the side first means the write is all-or-nothing and the previousitems.jsonlis untouched by a failed attempt.
- load(root=None)[source]
Load items from
{root}/items.jsonlinto this shard, all-or-nothing.Every row is parsed into a private, freshly-built dict first; only once the ENTIRE file has parsed and validated cleanly does that dict become
self._items. A malformed or invalid row anywhere in the file raises – naming the file, the 1-based line number, and a preview of the offending row – and leaves whatever this shard held before the call completely untouched: never a partial store holding only the rows that happened to precede the bad one.- Parameters:
root (str | None)
- Return type:
None