Python API
Every method of mushroomdb.GraphDb, with its signature and its docstring, as the type stub
gives them. Install it with pip install mushroomdb.
import mushroomdb
db = mushroomdb.GraphDb.open("./memory")
db.insert_node("Person", "ada", {"name": "Ada", "skills": ["rust", "graphs"]})
print(db.query("MATCH (p:Person) RETURN p.name AS name"))
db.close()
This page is the reference. For worked examples — rules, vector search, scoped handles, concurrency — see the Python sections of the Python guide.
A handle is also a context manager: with mushroomdb.GraphDb.open(path) as db: closes it on
exit.
Types
Scalar
Scalar = int | float | str | bool | list[Any] | dict[str, Any]
A value the store can hold: int, float, str, bool, list, or dict.
Params
Params = dict[str, Scalar] | Sequence[tuple[str, Scalar]] | None
Query parameters: a name→value dict, a list of (name, value) tuples, or None.
Row
Row = dict[str, Any]
One result row, keyed by RETURN alias.
Opening and closing
open
@staticmethod
def open(path: str | PathLike[str], read_only: bool = False) -> GraphDb
Open (creating if needed) the database rooted at path.
A read-write handle holds the store’s cross-process write lock for as
long as it is open, so only one exists at a time across all processes.
If another is already open this polls for two seconds and then raises
MushroomBusy — at open, not at the first write. Nothing was
written, so retrying later is always safe.
Pass read_only=True for a handle that never writes and never takes
the lock: it opens immediately even while another process is writing,
every mutation raises ReadOnly, and refresh() still follows the
writer’s commits.
The supported multi-process arrangements are in
docs/site/multiprocess.md. Keep a store several processes open on a
local disk: advisory locks over a network filesystem are not supported.
scoped
def scoped(
self,
role: str | None = None,
namespace: str | None = None,
keys: Sequence[str] | None = None,
) -> GraphDb
A child handle that scopes every read and refuses every write.
It shares this handle’s store and mutex — not a second open, no second
lock, one small allocation — so close() on either name closes both.
At least one leg is required; none raises ValueError. Legs intersect,
so scoped() on a scoped handle narrows further and never widens, and
keys=[] narrows to nothing. An unknown role raises here, not on the
first read.
Every read obeys one contract: the subject is checked first, so a key
outside the scope is indistinguishable from a key that does not exist —
node_info answers None, node_edges raises KeyNotFound,
node_history is empty. Then every other node the answer would mention
— neighbour, endpoint, candidate, evidence — is filtered to the scope,
so degree counts only visible neighbours and find_similar,
pairwise_similar and search_hybrid score only visible candidates.
The scope resolves per read, so a handle held across a write is never
stale. refresh() is permitted. has_vector_rule and
is_index_enabled answer unscoped: they are schema, not node data.
A read that answers about the whole store takes no mask, so it is
refused on a scoped handle with ValueError rather than narrowed:
pagerank, connected_components, degree_centrality, communities,
search, fulltext_pairs, rules, suggest_rules, recall,
schema_report, identity_clusters and roles. is_fulltext_enabled
answers: it is a schema fact about a pair you named.
s = db.scoped(role="reader-a")
t = db.scoped(namespace="tenant-a", keys=visible_ids)
refresh
def refresh(self) -> int
Apply other processes’ commits; return how many were applied.
A handle does not poll the store, so another process’s writes stay invisible until you call this. Rules fire and derived edges appear exactly as they would on a fresh open.
Writes nothing, so a read_only=True handle and a scoped() one may
both call it.
A commit another process is still writing is left for the next call: a partial trailing frame is a wait, not an error, and the return value counts only the complete frames applied.
A refresh that finds nothing new costs two filesystem metadata calls
and an integer compare — it reads no file contents at all — so polling
on an interval is cheap. See docs/site/multiprocess.md.
snapshot
def snapshot(self) -> None
Write a durable snapshot and truncate the WAL tail.
The next GraphDb.open() on the same path then loads the snapshot
directly and skips WAL replay, which is what makes reopening a large
store quick.
This discards history: the commits before the snapshot can no longer
be read with query_at, edges_at or was_linked. They keep their
numbers. stats()["history_floor"] moves to where they ended, what is
written next is numbered above it, and a read at a discarded commit
raises CommitOutOfRange.
A snapshot needs the store’s write lock, because it replaces the WAL a
peer may be appending to. A handle that does not hold the lock raises
MushroomBusy rather than snapshotting around another process’s
not-yet-durable bytes.
restore
@staticmethod
def restore(src: str | PathLike[str], dst: str | PathLike[str]) -> dict[str, Any]
Seed the store directory dst from the backup src, and say what it did.
src is a backup directory or a directory of them, where latest wins
outright and otherwise the newest by mtime does. The copy is staged
inside dst and opened there before anything is moved into place, so a
backup that does not open leaves dst as it was found.
Returns {outcome, from, files, bytes}. outcome is "restored",
"already_present" — dst already holds a store, which is refused,
not merged — or "empty", meaning nothing under src looks like a
store. A caller that requires a fresh restore must read it; a sidecar
rebuilding on boot can call this every time and ignore it.
Raises IoError when a copy, an install or the staged open failed; the
message names both directories.
close
def close(self) -> None
Close the handle and release the store.
A scoped() child shares the one store, so either name closes both.
Writing
insert_node
def insert_node(
self,
label: str,
key: str,
props: dict[str, Scalar],
namespace: str | None = None,
) -> None
Insert a new node; raises if key is already live.
namespace is the namespace the node is created in — set at insert and
immutable. Omitted means the default namespace.
upsert_node
def upsert_node(
self, label: str, key: str, props: dict[str, Scalar]
) -> Literal["inserted", "updated"]
Insert key if absent, otherwise update it in place.
Returns "inserted" or "updated". On update, only the fields present
in props whose value differs from the stored one are written — fields
you do not pass are left untouched, and unchanged fields produce no WAL
record, so rules do not re-fire needlessly. Changed fields go in one
set_props call, so a mid-list refusal leaves the node untouched.
Raises ValueError if key already exists under a different label:
relabelling a node is not an upsert, and ignoring the mismatch would
hide a caller bug.
set_prop
def set_prop(self, key: str, field: str, value: Scalar | None) -> None
Set or overwrite a single property.
value=None removes the field, exactly as remove_prop does: Python
has no distinct “null property” and the store has no null value, so
None means absent rather than stored-as-null.
set_props_many
def set_props_many(
self, rows: Sequence[tuple[str, dict[str, Scalar | None]]]
) -> dict[str, int]
Set properties on many existing nodes in one commit.
rows is a sequence of (key, props) tuples. Only values that differ
from what is stored are written; a None value removes the property.
Returns {"nodes", "props_set", "props_removed"} — what was written
after the comparison, so a call that changed nothing returns zeros and
writes nothing at all.
Existing nodes only. An unknown key raises KeyNotFound and
nothing is written, even when that key’s row would have changed
nothing. Use ingest_batch to create nodes.
One commit, and rules fire in it. Every change is one WAL frame.
Rules re-fire on each changed property inside that commit; when one
derives or retracts an edge the engine appends one more frame of
history markers, so wal_total_commits() moves by 1, or by 2 — never
by the number of nodes.
The comparison and the write happen under one lock acquisition, so no other writer can come between them.
A key that appears twice is a ValueError: merge its dicts first.
1 and 1.0 are different values. A view-owned property, or ns set
to a different namespace, refuses the whole call. Keep a call under
about 10,000 rows — it is one frame and one fsync.
This is a raw property write, like set_prop. On an entity node,
name and the two alias lists are maintained by upsert_entity and
remember: a name written here is not reflected in aliases until
the next describing write.
db.set_props_many([("alice", {"score": 3}), ("bob", {"score": 5, "old": None})])
# {"nodes": 2, "props_set": 2, "props_removed": 1}
remove_prop
def remove_prop(self, key: str, field: str) -> bool
Remove a property.
True if the field was present and removed, False if it was already
absent. Raises KeyNotFound for an unknown or deleted key.
Removing a field a rule watches retracts the edges that field derived.
delete_node
def delete_node(self, key: str) -> dict[str, int]
Delete a live node and every edge incident on it.
Returns {"manual_edges": N, "derived_edges": M}, counting the
user-inserted and rule-derived edges removed. Raises KeyNotFound for
an unknown or already-deleted key.
rename_node
def rename_node(self, old: str, new: str) -> None
Rename a node’s key.
The dense id — edges, history, last-change — is unchanged, so nothing the old key was party to is lost.
A stored id property that equals old is rewritten to new in the
same commit; an id holding anything else is left alone.
Raises KeyNotFound if old is unknown, or DuplicateKey if new is
already live.
insert_edge
def insert_edge(self, edge_type: str, src: str, dst: str) -> bool
Insert a user-owned edge; False if it already existed.
insert_edge_upsert
def insert_edge_upsert(
self, edge_type: str, src: str, dst: str, placeholder_label: str
) -> dict[str, Any]
Insert an edge, auto-creating any missing endpoint.
Each missing endpoint is created as a plain node with label
placeholder_label and no properties. Rules fire and last-change is
updated for each auto-created node.
Returns {"nodes_created": N, "edge_inserted": bool}.
delete_edge
def delete_edge(self, edge_type: str, src: str, dst: str) -> bool
Delete a user-owned edge; raises for a rule-derived edge.
ingest_batch
def ingest_batch(
self,
nodes: Sequence[dict[str, Any]],
edges: Sequence[dict[str, str]] | None = None,
on_conflict: Literal["error", "skip", "replace"] = "error",
) -> dict[str, Any]
Atomically ingest nodes and edges in a single WAL commit.
on_conflict says what a node key that is already taken means:
"error" (the default) rejects the whole frame with DuplicateKey;
"skip" leaves the stored node untouched and counts it in skipped;
"replace" makes its properties exactly the supplied props — fields
absent from them are removed — and counts it in replaced. A label
that differs from the stored one, and an ns that would move the node,
are row errors under "replace", not silent rewrites.
Two properties sit outside “exactly”, because neither is the caller’s
to supply: ns, which is immutable, and any property a view owns,
which is kept rather than removed (supplying one is a row error, so
omitting it is not a request to delete it). Each field kept that way is
counted in kept_view_owned, while the row still counts in replaced
and raises no row error.
Edges take the argument too, but it changes nothing for them: adjacency is a set, so a duplicate edge is already a silent no-op under every policy, and these edge dicts carry no properties to replace.
The report is {inserted, edges_inserted, skipped, replaced, kept_view_owned, row_errors, rules_created, skipped_fk_fields}, where
row_errors is a list of (index into nodes, why). edges_inserted
counts only newly written edges; a duplicate edge is a silent no-op and
is not counted.
For large datasets keep each call to 10,000 nodes or fewer. One call with 100,000+ nodes serialises a single giant WAL frame whose fsync dominates and negates the batching. Chunk at the call site.
batch_edges
def batch_edges(
self,
inserts: Sequence[dict[str, str]] | None = None,
deletes: Sequence[dict[str, str]] | None = None,
) -> dict[str, int]
Atomically apply edge inserts and deletes in a single WAL commit.
inserts and deletes are each a list of {edge_type, src, dst}
dicts naming user-owned edges. All of them commit in one fsync, which
is what this API is for: the maintenance pattern where one property
update causes many retractions and additions would otherwise serialise
one WAL fsync per insert_edge / delete_edge call.
Returns {"edges_inserted": N, "edges_deleted": M}.
Querying
query
def query(
self,
cypher: str,
params: Params = None,
role: str | None = None,
namespace: str | None = None,
) -> list[Row]
Execute a read query and return one dict per row, keyed by RETURN alias.
Values must be int, float, str, bool, list or dict.
Parameters are bound, never interpolated, so string values are safe
against injection.
role answers as one of the store’s roles (from roles.json) and
namespace from one namespace only. They intersect — a namespace
can only narrow what a role already allows, so a role bound to
tenant-a asked for tenant-b answers with nothing — and either one
makes the call a read, so a write statement raises MaskedReadOnly.
Prefer scoped() when the scope belongs to the caller rather than to
this one call; these arguments are its per-call form.
query_with_params
def query_with_params(
self, cypher: str, params: Sequence[tuple[str, Scalar]]
) -> list[Row]
Back-compat alias for query(cypher, params=[...]) with a tuple list.
Each element of params is a (name, value) tuple. Values must be
int, float, str, bool, or a list of those. Prefer query,
which accepts the same tuple list and a dict besides.
query_write
def query_write(self, cypher: str, params: Params = None) -> list[Row]
Execute a Cypher write statement.
CREATE / MATCH…SET / MATCH…DELETE / MATCH…DETACH DELETE / MERGE.
Returns a one-row result dict with keys created, properties_set and
deleted, unless the statement carries its own RETURN projection, in
which case the projected rows are returned instead.
params takes the same shapes as query: None, a dict, or a list of
(name, value) tuples.
query_at
def query_at(
self,
commit: int,
cypher: str,
params: Params = None,
role: str | None = None,
namespace: str | None = None,
) -> list[Row]
Time-travel read: run cypher against the graph as of commit.
role and namespace intersect the same way as live query.
neighbors
def neighbors(self, key: str, edge_type: str, direction: str) -> list[str]
One-hop neighbour keys along edge_type.
direction is "out" or "in". This API is one directed hop, so
"both" raises ValueError — unlike degree, which accepts it.
node_info
def node_info(self, key: str) -> dict[str, Any] | None
{key, label, props} for a live node, or None for an unknown key.
Contrast node_edges, which raises KeyNotFound for the same miss.
The asymmetry mirrors the Rust API — Option versus Result — and is
deliberate, not a Python invention.
node_edges
def node_edges(self, key: str) -> list[Row]
Edges incident on key. Raises KeyNotFound for an unknown key.
Each dict is {edge_type, src_key, dst_key, derived}.
node_info answers None on the same miss, because Rust returns
Option there and Result here. The asymmetry is the core API.
get_edge_prop
def get_edge_prop(
self, edge_type: str, src_key: str, dst_key: str, field: str
) -> Any | None
Read a single property from an edge — a rule’s score weight, say.
None when the edge does not exist, when the field is absent, or when
any key cannot be resolved. The three are not distinguished, which is
also what makes this safe on a scoped() handle: a hidden endpoint
answers as an unresolvable key does.
degree
def degree(
self,
key: str,
edge_type: str | None = None,
direction: str = "both",
multiplicity: bool = False,
) -> int
Unique directed degree of key, or its insert count.
direction is "out", "in" or "both" — the sum of unique
out-neighbours and unique in-neighbours, so a reciprocal pair counts 2
at each endpoint, not the size of the undirected neighbour set.
Adjacency is a set, so the default is a unique-neighbour count, not a
row count of duplicate pairs. An unknown edge_type yields 0; an
unknown key raises KeyNotFound.
multiplicity=True sums the persisted per-pair insert count instead,
so a pair inserted three times contributes 3. It answers the unique
count on a store that never called enable_multiplicity — the argument
is a readout preference, not a demand the store cannot meet. On a scoped
handle it sums visible pairs only, for the same reason the unique count
does: an unfiltered total discloses hidden neighbours by arithmetic.
degrees
def degrees(
self,
keys: Sequence[str] | None = None,
label: str | None = None,
where: dict | None = None,
edge_type: str | None = None,
direction: str = "both",
limit: int | None = None,
multiplicity: bool = False,
) -> list[tuple[str, int]]
Unique directed degree for a key subset or a label scan.
The universe is keys if given, else label, else every live node.
where is the same dict shape as find_similar and intersects.
limit applies after sorting degree descending, key ascending.
Unknown keys are omitted rather than raising, and keys=[] returns
[] — the contrast with degree, which raises for an unknown key.
stats
def stats(self) -> dict[str, Any]
Node and edge counts, history_floor, namespaces, and per-rule figures.
namespaces lists every namespace with at least one live node and its
count. Each rule reports its provenance size, trip latch and fire
counter. The shape matches the HTTP /stats JSON response.
On a scoped() handle the counts stay store-wide; only the namespace
roster narrows to the scope.
roles
def roles(self) -> list[dict[str, Any]]
The roles roles.json defines: name, labels, keys, namespaces, visible_where.
namespaces is None for a role bound to no namespace, which means
every one. visible_where is None or {"field", "eq", "in"}.
[] when no roles are defined, and raises Corrupt when roles.json
was corrupt at open — an unrestricted store answers [] too, so a
poisoned sidecar must not read as “nothing is restricted here”. Refused
on a scoped() handle: a role definition names node keys, namespaces
and the other roles in the store.
That refusal is a plain ValueError, not a MushroomError — a
refused read is the one kind of refusal here that is not a typed engine
error, and the whole-store reads (pagerank, search, rules and the
others that say so) raise the same. ReadOnly means a
scoped handle never writes, and roles() is a read; calling it on a
scoped handle is caller misuse, the same kind of thing as scoped()’s
empty-scope ValueError. So a sidecar wrapping its boot-time role
check in except MushroomError will not catch this one: catch
ValueError too, or call roles() before narrowing the handle.
Rules
create_rule
def create_rule(self, rule: dict[str, Any], if_not_exists: bool = False) -> bool
Register a linking rule; False when if_not_exists skips a duplicate.
A "namespace" key scopes the rule to one namespace; omitted is global.
rules
def rules(self) -> list[dict[str, Any]]
Every rule the store holds, as a list of dicts sorted by name.
Each dict is a rule definition in the shape create_rule accepts —
name, src_label, dst_label, predicate (the externally-tagged
form, {"FieldEqual": {"field": "team"}}), edge_type,
weight_prop, max_edges, approximate, via_label, via_edge,
via_dir, namespace — so a listed rule can be deleted and
recreated from its own listing. Unset fields are None.
Refused on a scoped() handle with ValueError: a rule names labels,
fields and a namespace the scope may hide. has_vector_rule answers
about one field.
delete_rule
def delete_rule(self, name: str) -> None
Delete a rule and retract every edge it derived.
Raises RuleNotFound (with .name) when no rule has that name.
rebuild_rule
def rebuild_rule(self, name: str) -> None
Re-derive every edge of one rule from the store as it is now.
The only way out of a tripped rule: stats() reports tripped when a
rule hit its max_edges cap and stopped deriving.
Raises RuleNotFound (with .name) when no rule has that name.
suggest_rules
def suggest_rules(self) -> dict[str, Any]
What rules the store’s own data suggests. Creates nothing.
Returns {"suggestions": [...], "total": int, "bookkeeping_hidden": int, "truncated": bool}. Each suggestion has name, src_label,
dst_label, edge_type, predicate (one clause of text),
est_edges, examples ([src, dst, score] lists), rationale, and
create_rule_args: pass that dict to create_rule unchanged. It
carries an explicit weight_prop, so the rule it creates here is the
rule the MCP create_rule tool creates from the same suggestion.
Proposals over fields the store writes for itself — ns, kind,
ts, source, provisional, id, aliases, alias_keys — are
dropped and counted in bookkeeping_hidden. The list is not capped
(the MCP tool shows five); truncated means the engine’s 5-second
budget ran out and a second call may find more.
Every proposal is a global rule: it links across namespaces.
Refused on a scoped() handle with ValueError.
explain
def explain(self, a: str, b: str) -> list[Row]
Why are a and b linked? One dict per derived edge between them.
Each is {rule, edge_type, src_key, dst_key, weight, predicate}.
predicate is the snake_case summary shape, which create_rule
accepts verbatim — an explanation round-trips into a new rule.
On a scoped() handle: either endpoint hidden raises KeyNotFound,
and an explanation whose evidence path crosses a hidden node is omitted
entirely rather than redacted.
has_vector_rule
def has_vector_rule(self, field: str) -> bool
Whether an approximate (HNSW) VectorSimilar rule covers field.
A capability probe to call before find_similar: True means the
native ANN index is active and find_similar will use it unless you
pass exact=True or a where= predicate; False means no such rule
covers field, so find_similar is an O(n) brute-force scan — which
is exact by construction.
Unscoped on a scoped() handle: this is a schema fact, not node data.
History
node_history
def node_history(self, key: str) -> Row
Per-node change history: {key, history, total_commits, horizon}.
horizon is the oldest commit still retained; events before it were
pruned and are not in history.
edge_history
def edge_history(self, a: str, b: str) -> Row
Per-edge change history between a and b.
Returns {a, b, events, total_commits, horizon}, where each event is
{edge_type, commit, event, rule}. event is "Added" or
"Retracted"; rule is the rule name for a derived edge and None
for a manually written one; horizon is the oldest commit still
retained — events before it were pruned and are not in events.
was_linked
def was_linked(
self, a: str, b: str, edge_type: str, at_commit: int | str
) -> bool
Whether a and b were linked by edge_type at or before at_commit.
at_commit takes a 0-based frame index, or an RFC 3339 date string —
"2026-06-19", "2026-06-19T12:00:00Z", offsets accepted — which
resolves to the last commit at or before that instant. A bool is
rejected rather than read as int. Raises NoRecordedTime when the
store records no times and TimeBeforeFloor when the date predates the
oldest it has.
edges_at
def edges_at(self, key: str, commit: int | str) -> list[Row]
Every edge incident on key at WAL commit, from one WAL scan.
Returns {edge_type, src, dst, derived, rule} dicts sorted by
(edge_type, src, dst). Raises for a commit outside the horizon.
commit takes a 0-based frame index, or an RFC 3339 date string —
"2026-06-19", "2026-06-19T12:00:00Z" — which resolves to the last
commit at or before that instant. Prefer the date when the question
names one: guessing an index for a date is how a plausible wrong graph
gets returned.
what_if_set_prop
def what_if_set_prop(self, key: str, field: str, value: Any) -> dict[str, list[Row]]
Derived edges a set_prop(key, field, value) would change.
Returns {"lost": [...], "gained": [...]}, each entry shaped like an
edges_at row. Writes nothing.
wal_total_commits
def wal_total_commits(self) -> int
Total number of committed WAL frames visible in the current horizon.
record_commits_at
def record_commits_at(self, unix_ms: int | None) -> None
Record subsequent commits as having happened at unix_ms.
None goes back to the system clock. For backfilled history: a
mirror importing rows that already carry their own timestamps, or a
replay of events from months ago. Without this every imported commit is
stamped “now”, so a store holding a year of history answers every date
question with TimeBeforeFloor — the data is there and no date reaches
it.
Sticky until changed or cleared, because a day of backfilled rows
genuinely shares one instant. Import in chronological order: an instant
earlier than anything already recorded raises
CommitTimeNotMonotonic.
Not available over HTTP or MCP. Asserting when a commit happened rewrites the store’s apparent history, which is not something a role token models.
resolve_date
def resolve_date(self, date: str) -> int
The 0-based frame index a date resolves to.
The last commit at or before date, ready to hand to edges_at or
was_linked. Raises ValueError-shaped QueryError when date will
not parse, NoRecordedTime on a store that records none, and
TimeBeforeFloor when it predates the oldest recorded.
commit_time_ms
def commit_time_ms(self, commit: int) -> int | None
The recorded wall-clock time of a 0-based frame index, in unix ms.
None when the store records no time for it — a pre-0.6.11 store, a
frame below the horizon, or a damaged sidecar.
Search and indexes
find_similar
def find_similar(
self,
field: str,
vector: Sequence[float],
label: str | None = None,
k: int = 10,
min: float = 0.8,
mask: Sequence[str] | None = None,
where: dict | None = None,
exact: bool = False,
) -> list[tuple[str, float]]
The k nearest nodes to vector by cosine similarity on field.
Returns (node_key, similarity) tuples sorted score-descending, kept
when score >= min. Scores are cosine similarity in [-1, 1]; a
distance of 1 - sim is the caller’s conversion — the engine does not
speak distance.
Which arguments make the answer exact. exact=True and a where=
predicate each force a GEMM brute-force over the candidate set. A
mask= allow-list — and a scoped() handle — do not: they narrow
which nodes may be returned without changing which kernel runs, so the
answer stays approximate when an HNSW rule covers field. Under a mask
the beam widens until it has k visible hits, so the result is never
short while more visible hits exist, but it is still not guaranteed to
be the true top k. If you need an exhaustive answer over the visible
set, pass exact=True alongside the mask.
When no approximate VectorSimilar rule covers field (check with
has_vector_rule), every call is already an exact brute-force scan.
label=None searches every label. where is {"field": …, "eq": …}
or {"field": …, "in": [...]} — exactly one of eq/in; anything
else raises ValueError before reaching the engine. It uses the
property index only when label is also set and
enable_index(label, where["field"]) is on; otherwise it is a
correct-but-slower scan.
Candidates are label ∩ mask ∩ where, intersected further by the
handle’s scope. A candidate whose embedding is missing, zero-norm or a
different length than the query is skipped; a zero-norm query returns
[].
min defaults to 0.8, the same floor the MCP find_similar tool
and HTTP POST /find_similar apply. It was 0.0 here through 0.6, so
a call that never named min now drops every hit below 0.8 and
nothing raises. Pass min=0.0 for the old behaviour.
pairwise_similar
def pairwise_similar(
self,
keys: Sequence[str],
field: str,
k: int = 10,
min: float = 0.0,
) -> list[tuple[str, list[tuple[str, float]]]]
Exact per-key cosine top-k among keys. Self excluded. Never HNSW.
Each key in keys is scored only against that same set, so this is a
closed comparison, not a search of the store. Writes no edges.
Scores are cosine similarity in [-1, 1], kept when score >= min —
the same unit and inequality as find_similar. A distance of
1 - sim is the caller’s conversion here too; convert after the call
rather than baking a distance threshold into min.
Treat the result as a map keyed by source: outer order is first-seen
packed keys, not a zip with the input. A source with no neighbour above
min still appears as (src, []), so “present, nothing similar” stays
distinct from “omitted”.
Unknown keys, missing embeddings, zero-norm and wrong-dimension vectors
are omitted as both query and candidate. Duplicate keys collapse to
first-seen order. Empty keys returns []. More than 8192 unique
resolved keys raises.
On a scoped() handle hidden keys are dropped from the input set
before the matmul, so a hidden vector can neither influence a score
nor appear as a neighbour.
search
def search(self, field: str, query: str, k: int = 0) -> list[tuple[str, float]]
Full-text search on field. Returns [(key, score), …], BM25,
highest first, ties by key.
The query grammar: space-separated terms are ANDed; OR between
terms; "a phrase"; -term excludes; prefix* matches a prefix.
Keyed by field alone. If two labels are indexed on the same field name, both are searched; filter the keys yourself.
k=0 (the default) returns every hit; a positive k stops at the
best k.
Refused on a scoped() handle with ValueError: the index takes no
mask. search_hybrid is the scoped way to search text.
search_hybrid
def search_hybrid(
self,
text_field: str,
query_text: str,
vector_field: str,
vector: Sequence[float],
label: str | None = None,
k: int = 10,
) -> list[tuple[str, float]]
Reciprocal-rank fusion over fulltext and vector similarity.
Fuses up to 4*k fulltext hits on text_field for query_text with
up to 4*k vector hits on vector_field for vector, using
Reciprocal Rank Fusion (constant 60). An empty vector skips the
vector leg and answers from the text leg alone.
Returns [(node_key, fused_score)] sorted score-descending, ties by
key. The fused score is a rank-fusion number, not a similarity: it is
not comparable with a find_similar score.
This call takes no exactness argument, so its vector leg is the
approximate one whenever an HNSW rule covers vector_field. To fuse an
exact vector leg, run find_similar(..., exact=True) yourself and fuse
it with a text search at the call site.
On a scoped() handle both legs are filtered before fusion, so the
ranks are the ranks of the visible corpus and k is honoured.
enable_fulltext
def enable_fulltext(self, label: str, field: str, if_not_exists: bool = False) -> bool
Declare a full-text index on (label, field). Returns True when it
was newly enabled.
The engine’s call is not idempotent: enabling a pair that is already
enabled raises RuleInvalid. With if_not_exists=True it returns
False instead, so a caller that declares its schema at boot can call
this every time.
The declaration is logged. A snapshot carries the index, and an open reads it from there; a pair declared since the last snapshot is indexed again at each open until the next one, which takes time in proportion to that pair’s text.
db.enable_fulltext("Doc", "body", if_not_exists=True)
disable_fulltext
def disable_fulltext(self, label: str, field: str) -> None
Drop the full-text index on (label, field) and its postings.
Raises RuleNotFound when the pair is not enabled; its .name is
"fulltext(label,field)".
is_fulltext_enabled
def is_fulltext_enabled(self, label: str, field: str) -> bool
Whether (label, field) has a full-text index.
A schema fact about a pair you named, so it answers on a scoped()
handle too, as is_index_enabled does.
fulltext_pairs
def fulltext_pairs(self) -> list[tuple[str, str]]
Every (label, field) pair with a full-text index, sorted.
Refused on a scoped() handle with ValueError: it enumerates labels
the scope may hide. is_fulltext_enabled answers about one pair.
enable_index
def enable_index(self, label: str, field: str) -> None
Enable an equality index on (label, field).
disable_index
def disable_index(self, label: str, field: str) -> None
Disable the equality index on (label, field).
is_index_enabled
def is_index_enabled(self, label: str, field: str) -> bool
Whether (label, field) currently has an equality index.
enable_multiplicity
def enable_multiplicity(self) -> None
Start recording a per-pair insert count on this store.
Opt-in, and one way: it writes a declaration record the previous release’s decoder cannot read. Left alone, a 0.6.9 binary would not refuse such a store — it would treat the first such record as a corrupt WAL tail, replay only the commits before it, and persist that truncation.
So the call also takes a snapshot, at a format version 0.6.9 does not
know, and it takes it before writing the record. A 0.6.9 binary
stops at snapshot: unsupported version 10 and leaves the WAL exactly
as it found it. The cost is one full snapshot write; the snapshot keeps
the WAL, so every commit open_at could reach before the call it can
still reach after it.
One thing the snapshot does move: a store that archives its WAL decides
at its first archive whether open_at may reach into archives, and
it says no whenever a snapshot it cannot vouch for already exists. A
snapshot this handle took itself, keeping the WAL, is one it can vouch
for — so opting in and taking that first archive in the same session
keeps that reach. Across sessions it does not, which is the answer any
store with a prior snapshot gets.
A store that never calls this contains no such record, keeps writing the old snapshot version, and stays readable by 0.6.9 indefinitely. Refused on a scoped handle.
Not atomic. If this raises, the store may be opted in anyway: the
record can already be in the log with only its fsync having failed, or
the snapshot alone can be enough for the next open to finish the job.
The snapshot is written first in every case, so an older binary refuses
such a store by name rather than truncating it — nothing is lost. But
the exception means “outcome unknown”, not “nothing happened”: reopen
and call is_multiplicity_enabled() to find out where the store stands.
There is no call that opts it back out.
is_multiplicity_enabled
def is_multiplicity_enabled(self) -> bool
Whether this store records insert-count multiplicity.
A store-wide flag naming no node, so it answers on a scoped handle.
Graph algorithms
pagerank
def pagerank(
self,
damping: float = 0.85,
max_iters: int = 50,
tol: float = 1e-06,
edge_type: str | None = None,
direction: Literal["out", "in", "both"] = "out",
budget_ms: int = 0,
weight_prop: str | None = None,
min_weight: float | None = None,
) -> dict[str, Any]
PageRank over the whole store.
Returns {"scores": [(key, score), …], "converged": bool}, one row
per live node, highest first, ties by key.
direction is "out" (rank flows along each edge, the default),
"in" or "both". edge_type=None follows every edge type.
weight_prop names an edge property to weight by, and min_weight
drops edges below it. damping is a probability: a value that is not
a number from 0 to 1 is a ValueError.
budget_ms defaults to 0 — no time limit. The engine’s own
default is a 5-second wall-clock budget, under which a loaded machine
returns a different answer for the same store; 0 means the same
store always gives the same scores. Pass a budget to bound the call,
and read converged.
Refused on a scoped() handle with ValueError: no algorithm takes a
mask, and a visible node’s score would be computed over hidden edges.
top = db.pagerank(edge_type="CITES")["scores"][:10]
connected_components
def connected_components(
self,
edge_type: str | None = None,
budget_ms: int = 0,
weight_prop: str | None = None,
min_weight: float | None = None,
) -> dict[str, Any]
Weakly connected components.
Returns {"components": [(key, component), …], "truncated": bool},
one row per live node. component is an identifier shared by every
node in the same component; compare them, do not parse them.
edge_type=None follows every edge type; direction is ignored.
budget_ms defaults to 0 (no limit) — see pagerank.
Refused on a scoped() handle with ValueError.
degree_centrality
def degree_centrality(
self,
edge_type: str | None = None,
direction: Literal["out", "in", "both"] = "both",
budget_ms: int = 0,
weight_prop: str | None = None,
min_weight: float | None = None,
) -> dict[str, Any]
Degree centrality: every node’s degree, highest first.
Returns {"scores": [(key, degree), …], "truncated": bool}.
Not degree() or degrees(). Those answer for the keys you name
and can be scoped, masked and filtered; this ranks the whole store.
direction is "both" (the default), "out" or "in".
budget_ms defaults to 0 (no limit) — see pagerank.
Refused on a scoped() handle with ValueError; degrees() is the
scoped way to ask.
communities
def communities(
self,
edge_types: Sequence[str] | None = None,
weight_prop: str | None = None,
min_weight: float | None = None,
resolution: float = 1.0,
max_passes: int = 10,
max_sweeps: int = 20,
budget_ms: int = 0,
node_label: str | None = None,
) -> dict[str, Any]
Communities by modularity (Louvain).
Returns {"communities": [{"id", "members", "internal_weight", "cohesion"}, …], "modularity": float, "truncated": bool}.
edge_types is a list — unlike the other three algorithms, which
take one edge_type — and None or [] follows every edge type.
node_label restricts the pass to one label. resolution above 1.0
favours smaller communities. budget_ms defaults to 0 (no limit) —
see pagerank.
Modularity is global: adding unrelated nodes can move an existing
community. For “which keys are one entity”, use identity_clusters.
Refused on a scoped() handle with ValueError.
Memory
remember
def remember(
self,
text: str,
about: Sequence[str] | None = None,
kind: Literal["note", "decision", "todo"] | None = None,
ts: int | None = None,
source: str | None = None,
entities: Sequence[dict[str, Any]] | None = None,
facts: Sequence[dict[str, str]] | None = None,
) -> dict[str, Any]
Write a note the store can later recall, and whatever it names.
about is the keys the note is about. A key that does not exist is
created as a provisional Entity rather than refusing the call, and
listed under provisional in the report.
entities is a list of {"key", "label", "props"?, "aliases"?}
dicts — entities you recognised in the text, created or updated in the
same commit. facts is a list of {"subject", "predicate", "object"}
dicts — relationships among them, written as edges. Either may be a
tuple or any other sequence, as about may.
kind is "note" (the default), "decision" or "todo". ts is
Unix seconds and defaults to now; it is part of the note’s key, so the
same text at the same ts is the same note. source defaults to
"agent".
Returns the report as a dict: note (the note’s key — not key,
which is what the MCP tool’s JSON reply calls it), created,
matched, derived, provisional, provisional_capped (keys past
the per-call cap of 20, not created), fulltext_declared,
same_as and same_as_lost (each a list of {"a", "b", "score"}:
the identity links this call made, and the ones it retracted). A
score is the raw float (0.6666666666666666); the MCP tool prints
the same score to two decimals.
Raises IngestError when the text is empty or over 4,000 characters,
the kind is unknown, an entity’s props carry aliases or
alias_keys, a name is empty, only whitespace or only invisible
characters — a key in
about, an entity’s key or label, or a fact’s subject,
predicate or object; nothing is written. Its .detail is the
bare sentence; the message carries an ingest error: prefix in
front of it.
A store this binding creates has no memory schema. remember
declares full-text on Note.text and on each new entity label’s
name itself, so recall works after the first call.
r = db.remember("Matthew is driving 0.7", about=["matthew"],
entities=[{"key": "v0.7", "label": "Release"}],
facts=[{"subject": "matthew", "predicate": "WORKS_ON", "object": "v0.7"}])
r["note"] # "note:…"
r["provisional"] # ["matthew"]
recall
def recall(self, topic: str) -> dict[str, Any]
What the store holds about a topic, as rows.
Returns {"indexed": bool, "terms": int, "hits": [...]}. Each hit is
{"key", "label", "summary", "covered", "score"}, best first:
covered is how many of the topic’s terms the node’s own text
holds, and it leads the ranking — the fused score is nearly flat.
A hit covers at least half the terms. At most six hits. terms == 0
with hits present means the topic was all stopwords: its words were
searched together and every hit’s covered is 0. summary is
the first 120 characters of the node’s text, summary or name,
or None when it has none of them.
indexed is False when the store declares no full-text index at
all, so no topic can match — a different answer from an empty hits.
remember declares Note.text on its first call.
This is the MCP recall tool’s ranking, as data; the tool renders the
same rows as a digest, and this method does not offer the digest.
The rows are raw stored content. key, label and summary are
unsanitized: only the digest renderer replaces control characters,
line separators and bidi or zero-width characters with spaces. If you
render a row into an assistant’s context, that sanitisation is yours
to do — a stored line break can otherwise forge a line of your
prompt. This binding exposes no helper for it.
Refused on a scoped() handle with ValueError.
upsert_entity
def upsert_entity(
self,
key: str,
props: dict[str, Scalar],
label: str | None = None,
aliases: Sequence[str] | None = None,
namespace: str | None = None,
) -> dict[str, Any]
Create or update one entity by key, the way the memory tools do.
Unlike upsert_node, this maintains the entity’s two identity lists:
aliases, which the store derives from the current key and name and
recomputes on every call — never accumulated, never yours to set — and
alias_keys, the aliases you pass, kept as written and accumulating.
It also sets id to the key and clears a provisional mark
remember left.
label is required to create and optional to update. An update
never changes a label: one that differs from the stored label
raises IngestError and nothing in props is written.
namespace is the namespace a created node lands in. On an existing
node its own namespace is a no-op and another raises
NamespaceImmutable. A props["ns"] that disagrees with namespace
is a ValueError.
Returns {"key", "label", "created", "updated_fields", "same_as_lost"}. same_as_lost lists the identity links this update
retracted, as {"a", "b", "score"}: a changed name can take a
full-name link below the floor. A score is the raw float
(0.6666666666666666); the MCP tool prints it to two decimals.
aliases or alias_keys inside props raise IngestError: the
store maintains both. So does a key that is empty or only
whitespace, and a create under such a label. An IngestError’s
.detail is the bare sentence; its message carries an
ingest error: prefix.
db.upsert_entity("ada", {"name": "Ada Lovelace"}, label="Person", aliases=["Countess"])
forget
def forget(
self,
key: str | None = None,
prop: str | None = None,
fact: dict[str, str] | None = None,
) -> dict[str, Any]
Forget a node, one property, or one fact — exactly one of the three.
forget(key=k)tombstones the node and every edge on it.forget(key=k, prop=p)removes one property.forget(fact={"subject", "predicate", "object"})retracts one hand-written edge.
Returns the report as a dict: mode ("node", "prop" or
"fact"), target, changed (False when there was nothing to
forget), manual_edges, derived_edges, notes and notes_total
(notes that still say it — listed, never deleted), history_floor,
prop in prop mode, aliases_rewritten (a forgotten name took its
words out of aliases in the same commit) and alias_keys_remain
(forgotten aliases left declared aliases behind in alias_keys).
This is a tombstone, not a redaction. node_history,
edge_history, edges_at and was_linked still read what was
forgotten, from history_floor on, until the log is pruned.
Raises ValueError for any other combination of arguments,
KeyNotFound for an unknown key or fact endpoint, and RuleOwned for
a fact a rule derived. That refusal’s message and its .detail are
the same whole sentence — which rule owns the edge and the fields it
reads — with no edge is rule-owned: prefix in front of it. Nothing
is written in any of the three.
schema_report
def schema_report(self, budget_ms: int = 1000) -> dict[str, Any]
What the store holds and how it is wired.
Returns a dict: brief (nodes and edges counts, labels each
with its fields, edge_types each with its endpoints, commits,
roles, recipes, partial), rules (each with its predicate in
one clause and its namespace), fulltext and indexes ([label, field] lists), provisional (how many nodes remember named and
nothing has described) and provisional_sample (the first ten keys).
budget_ms bounds the counting; a spent budget sets
brief["partial"] and the counts are then lower bounds. 0 is not
“no limit” here, as it is for the graph algorithms: it is a budget
already spent, and returns a partial report. For an unhurried report
pass a large value, such as 60_000.
The MCP schema tool renders this same report. The names under
rules, fulltext, indexes and provisional_sample are raw stored
content, unsanitized, as recall’s rows are.
Refused on a scoped() handle with ValueError.
identity_clusters
def identity_clusters(self, floor: float = 0.6) -> dict[str, Any]
Which keys are one entity: SAME_AS links resolved into identities.
Returns {"clusters": [...], "linked": int, "claims": int, "floor": float}. Each cluster is {"canonical", "members", "weakest"}: every
pair of members is linked at floor or above, canonical is the
oldest member and members[0], and weakest is the lowest pairwise
score inside it. claims counts unordered pairs — both directions of
a link are one claim. weakest is the raw float
(0.6666666666666666); the MCP tool prints it to two decimals.
Empty on a store with no SAME_AS edges. The identity preset that
derives them is applied with mushroomdb schema apply <db> --memory-identity, on the command line, with this handle closed; a
SAME_AS rule made with create_rule is the other way.
Raises ValueError for a floor that is not a finite number.
Refused on a scoped() handle with ValueError.
Errors
Every error the engine raises is a subclass of MushroomError, which subclasses
RuntimeError. Each carries code, a stable snake_case string, and the fields below as
attributes. Classes may be added; a code is never respelled.
| Class | Fields | Raised when |
|---|---|---|
MushroomError | — | Base class for every error the engine raises. |
KeyNotFound | key: str | No node with this key. |
DuplicateKey | key: str | A node with this key already exists. |
IoError | — | The store’s filesystem refused a read or a write. |
Corrupt | detail: str | The store’s on-disk state did not parse. |
RuleInvalid | detail: str | The rule definition was rejected. |
RuleOwned | detail: str | The edge belongs to a rule and is not writable by hand. |
RuleNotFound | name: str | No rule by this name. |
QueryError | detail: str | The Cypher statement failed. |
IngestError | detail: str | The batch was rejected before anything landed. |
ReadOnly | — | This handle never writes. |
CommitOutOfRange | commit: int, total: int, floor: int | The commit is outside the retained range floor..total. |
ViewPropReadOnly | view_name: str | The property is managed by a view and cannot be written directly. |
CasConflict | key: str, expected: int, actual: int | A compare-and-set precondition was not satisfied. |
MaskedReadOnly | — | A write statement reached a scoped or masked query path, which is read-only. |
RoleWriteDenied | reason: str | A role-bound write was denied. |
MushroomBusy | holder: int | None | Another process holds the store’s write lock. |
NamespaceImmutable | key: str, from_: str, to: str | A namespace is set at insert and fixed for the node’s lifetime. |
CrossNamespace | src: str, src_ns: str, dst: str, dst_ns: str | A hand-written edge would cross a namespace boundary. |
CommitTimeNotMonotonic | supplied_ms: int, newest_ms: int | An asserted commit time would move the store’s recorded history backwards. |
NoRecordedTime | — | A date was given to a history call on a store that records no commit times. |
TimeBeforeFloor | floor_ms: int, floor_commit: int | The date predates the oldest commit time the store still records. |