Skip to content

zettelkasten.synapse.substrate

zettelkasten.synapse.substrate

In-memory, n-type substrate assembler for the synapse reasoning layer.

The substrate is the heterogeneous graph a reasoning agent reasons off instead of fabricating: on demand it assembles typed, provenance-tethered :class:Node and :class:Edge objects that a deterministic engine can later verify for grounding. This is the second synapse workstream — it consumes the MemDSL contract (:mod:zettelkasten.synapse.memdsl) and is consumed by the epistemics / router / navigation / render workstreams (not yet built, so nothing here imports them).

Design invariants pinned here:

  • N-type, topology-general. :class:Node and :class:Edge carry arbitrary type/relation pairs — code→data, claim→requirement, code→code, memory→zk — so the substrate is not the bipartite memory × zk graph that :mod:zettelkasten.synapse.memory_source assumes. The node type vocabulary follows :data:zettelkasten.graph.VALID_TYPES and edge relation follows :data:zettelkasten.graph.VALID_RELATIONS, but an unknown type (e.g. the code extension type) degrades gracefully exactly as the MemDSL node-type registry does — the substrate never rejects a node for a novel type.
  • Reuse, don't reinvent. Every store-backed adapter fuses its keyword + semantic channels through :func:zettelkasten.framing.project_query (the same RRF fusion / ANN / rerank the rest of synapse uses) and reuses the provenance extractors in :mod:zettelkasten.synapse.candidates. No adapter re-implements embedding, scoring, or staleness.
  • A read-only-agnostic protocol. The :class:SourceAdapter protocol is a minimal READ surface (retrieve / get / expand); it deliberately does NOT assume an immutable store. v1 ships only read-only adapters (they set :attr:SourceAdapter.mutable to False), but a future session-scoped MUTABLE source (the v2 artifact-under-edit) satisfies the SAME protocol and simply adds its own write methods and flips mutable — no change here.
  • Pure and in-memory. Assembly persists nothing. The shipped persisted link overlay (:mod:zettelkasten.synapse.overlay) and synapse/sharing/ are only ever read, never written, by this module.

Node identity uses the MemDSL addressing scheme store:source:id — every :class:Node builds its canonical id via :class:zettelkasten.synapse.memdsl. schema.Address rather than reinventing a key format.

Budget dataclass

A ceiling on how much substrate a single assembly may materialize.

Assembly is on-demand and bounded so a reasoning turn never drowns in nodes:

  • max_nodes — the hard cap on the total assembled neighborhood; None removes this count cap (hop and downstream token bounds remain).
  • per_source — how many candidates each adapter may contribute (the per-adapter retrieval top_k).
  • hops — expansion depth beyond the retrieved seeds (0 = seeds only).
Source code in zettelkasten/synapse/substrate.py
@dataclass(frozen=True)
class Budget:
    """A ceiling on how much substrate a single assembly may materialize.

    Assembly is on-demand and bounded so a reasoning turn never drowns in nodes:

    * ``max_nodes`` — the hard cap on the total assembled neighborhood;
      ``None`` removes this count cap (hop and downstream token bounds remain).
    * ``per_source`` — how many candidates each adapter may contribute (the
      per-adapter retrieval ``top_k``).
    * ``hops`` — expansion depth beyond the retrieved seeds (``0`` = seeds only).
    """

    max_nodes: int | None = 24
    per_source: int = 8
    hops: int = 0

Node dataclass

One typed, provenance-tethered substrate node.

A node is generic over store and type: store/source/id compose its MemDSL :class:~zettelkasten.synapse.memdsl.schema.Address, type follows the :data:zettelkasten.graph.VALID_TYPES vocabulary (an unknown type is tolerated), and title/text are the human-readable body a renderer shows.

Two provenance channels are kept distinct on purpose:

  • provenance — human/source-level origin handles (where the node came from: file-path tokens, citation ids, the source graph). For orientation and display.
  • tethers — MACHINE-CHECKABLE grounding anchors the deterministic engine later verifies for drift/staleness (e.g. a git-pinned path@sha, a dataset content_hash, a citation handle). This is the seam the epistemics workstream reads to decide grounded-vs-stale.

epistemic_status is a PLACEHOLDER the epistemics module populates later; the assembler leaves it None (assembly makes no epistemic claim). outdated is its INSEPARABLE committed-drift companion — a purely-outdated node wires to grounded (byte-indistinguishable from fresh at the scalar), so the disclosure must ride alongside the scalar to survive the node boundary.

Source code in zettelkasten/synapse/substrate.py
@dataclass
class Node:
    """One typed, provenance-tethered substrate node.

    A node is generic over store and type: ``store``/``source``/``id`` compose its
    MemDSL :class:`~zettelkasten.synapse.memdsl.schema.Address`, ``type`` follows
    the :data:`zettelkasten.graph.VALID_TYPES` vocabulary (an unknown type is
    tolerated), and ``title``/``text`` are the human-readable body a renderer
    shows.

    Two provenance channels are kept distinct on purpose:

    * ``provenance`` — human/source-level origin handles (where the node came
      from: file-path tokens, citation ids, the source graph). For orientation
      and display.
    * ``tethers`` — MACHINE-CHECKABLE grounding anchors the deterministic engine
      later verifies for drift/staleness (e.g. a git-pinned ``path@sha``, a
      dataset ``content_hash``, a citation handle). This is the seam the
      epistemics workstream reads to decide grounded-vs-stale.

    ``epistemic_status`` is a PLACEHOLDER the epistemics module populates later;
    the assembler leaves it ``None`` (assembly makes no epistemic claim).
    ``outdated`` is its INSEPARABLE committed-drift companion — a purely-``outdated``
    node wires to ``grounded`` (byte-indistinguishable from fresh at the scalar), so
    the disclosure must ride alongside the scalar to survive the node boundary.
    """

    store: str
    source: str
    id: str
    type: str = ""
    title: str = ""
    text: str = ""
    provenance: list[str] = field(default_factory=list)
    tethers: list[str] = field(default_factory=list)
    # Populated by the (future) epistemics workstream, never by the assembler.
    epistemic_status: EpistemicStatus | None = None
    # INSEPARABLE committed-drift disclosure companion to ``epistemic_status``: a
    # faithfully-cited-but-superseded git pin wires to ``grounded`` yet is ``outdated``
    # ("grounded as of the pinned sha; HEAD has since changed"). Because the wire
    # scalar cannot distinguish an outdated-grounded node from a fresh one, this flag
    # is stamped alongside it (see ``epistemics.apply_node_statuses``) so a consumer
    # can never present an outdated node as fresh-grounded. ``False`` on every node
    # the epistemics layer has not marked outdated.
    outdated: bool = False
    # Optional external-dataset provenance locators the Address scheme supports
    # (a passage char span, a temporal as-of stamp). ``None`` on a plain node.
    span: tuple[int, int] | None = None
    as_of: str | None = None

    @property
    def address(self) -> Address:
        """The node's unified MemDSL :class:`Address` (``store:source:id``)."""
        return Address(
            store=self.store, source=self.source, id=self.id,
            span=self.span, as_of=self.as_of,
        )

    @property
    def token(self) -> str:
        """The canonical ``store:source:id[{locator}]`` address string.

        This is the node's stable identity key — the assembler dedups on it and
        :class:`Edge` endpoints reference it.
        """
        return self.address.to_token()

    @property
    def known_type(self) -> bool:
        """Whether :attr:`type` is in the canonical vocabulary (advisory)."""
        return is_valid_type(self.type)

address property

address: Address

The node's unified MemDSL :class:Address (store:source:id).

token property

token: str

The canonical store:source:id[{locator}] address string.

This is the node's stable identity key — the assembler dedups on it and :class:Edge endpoints reference it.

known_type property

known_type: bool

Whether :attr:type is in the canonical vocabulary (advisory).

Edge dataclass

A typed, directed relation between two substrate nodes.

src and dst are node :attr:Node.token address strings, so an edge is self-contained and serializable. relation follows the :data:zettelkasten.graph.VALID_RELATIONS vocabulary and may connect ANY type pair, including within-store edges (e.g. a zk claim --depends-on--> zk requirement) and cross-store edges (a mem decision --applies-to--> a zk claim). confidence is a [0, 1] strength, rationale an optional human note, and signals an open bag of the evidence that produced the edge (e.g. semantic similarity, shared provenance).

Source code in zettelkasten/synapse/substrate.py
@dataclass
class Edge:
    """A typed, directed relation between two substrate nodes.

    ``src`` and ``dst`` are node :attr:`Node.token` address strings, so an edge is
    self-contained and serializable. ``relation`` follows the
    :data:`zettelkasten.graph.VALID_RELATIONS` vocabulary and may connect ANY
    type pair, including within-store edges (e.g. a zk ``claim --depends-on-->``
    zk ``requirement``) and cross-store edges (a ``mem`` decision
    ``--applies-to-->`` a ``zk`` claim). ``confidence`` is a ``[0, 1]`` strength,
    ``rationale`` an optional human note, and ``signals`` an open bag of the
    evidence that produced the edge (e.g. semantic similarity, shared provenance).
    """

    src: str
    dst: str
    relation: str
    confidence: float = 1.0
    rationale: str = ""
    signals: dict[str, Any] = field(default_factory=dict)

    @property
    def known_relation(self) -> bool:
        """Whether :attr:`relation` is in the canonical vocabulary (advisory)."""
        return is_valid_relation(self.relation)

known_relation property

known_relation: bool

Whether :attr:relation is in the canonical vocabulary (advisory).

Neighborhood dataclass

The assembled, in-memory result: a set of nodes plus the edges among them.

nodes is keyed by :attr:Node.token (dedup identity); edges only ever references tokens present in nodes (the assembler drops dangling edges). Nothing here is persisted.

Source code in zettelkasten/synapse/substrate.py
@dataclass
class Neighborhood:
    """The assembled, in-memory result: a set of nodes plus the edges among them.

    ``nodes`` is keyed by :attr:`Node.token` (dedup identity); ``edges`` only ever
    references tokens present in ``nodes`` (the assembler drops dangling edges).
    Nothing here is persisted.
    """

    nodes: dict[str, Node] = field(default_factory=dict)
    edges: list[Edge] = field(default_factory=list)

    def node_list(self) -> list[Node]:
        """The assembled nodes as a list (insertion order preserved)."""
        return list(self.nodes.values())

    def __len__(self) -> int:
        return len(self.nodes)

node_list

node_list() -> list[Node]

The assembled nodes as a list (insertion order preserved).

Source code in zettelkasten/synapse/substrate.py
def node_list(self) -> list[Node]:
    """The assembled nodes as a list (insertion order preserved)."""
    return list(self.nodes.values())

SourceAdapter

Bases: Protocol

A store-agnostic seam that yields substrate nodes + edges on demand.

An adapter maps ONE backing store into :class:Node/:class:Edge objects. It is deliberately a minimal READ surface so it makes no assumption about immutability — a future session-scoped MUTABLE source implements the same three methods (plus its own write methods) and flips :attr:mutable.

Contract:

  • :attr:store — the MemDSL address store prefix this adapter emits (zk / mem / code / …). Advisory; each :class:Node also carries its own store.
  • :attr:mutableFalse for every v1 adapter (all read-only). The seam for the v2 artifact-under-edit: a mutable adapter sets this True so the assembler (and downstream cache layers) can refuse to memoize its nodes.
  • :meth:retrieve — the primary on-demand entry point: return up to budget.per_source candidate nodes most relevant to query, using the store's own reused fusion/embedding machinery.
  • :meth:get — ground a single node by :class:Address (used to re-fetch an elided body, or resolve an edge endpoint). None when it does not resolve.
  • :meth:expand — best-effort one-hop neighborhood around node WITHIN this store: the neighbor nodes and the within-store edges to them. An adapter with no cheap link structure may return ([], []).
Source code in zettelkasten/synapse/substrate.py
@runtime_checkable
class SourceAdapter(Protocol):
    """A store-agnostic seam that yields substrate nodes + edges on demand.

    An adapter maps ONE backing store into :class:`Node`/:class:`Edge` objects. It
    is deliberately a minimal READ surface so it makes no assumption about
    immutability — a future session-scoped MUTABLE source implements the same
    three methods (plus its own write methods) and flips :attr:`mutable`.

    Contract:

    * :attr:`store` — the MemDSL address store prefix this adapter emits
      (``zk`` / ``mem`` / ``code`` / …). Advisory; each :class:`Node` also carries
      its own store.
    * :attr:`mutable` — ``False`` for every v1 adapter (all read-only). The seam
      for the v2 artifact-under-edit: a mutable adapter sets this ``True`` so the
      assembler (and downstream cache layers) can refuse to memoize its nodes.
    * :meth:`retrieve` — the primary on-demand entry point: return up to
      ``budget.per_source`` candidate nodes most relevant to ``query``, using the
      store's own reused fusion/embedding machinery.
    * :meth:`get` — ground a single node by :class:`Address` (used to re-fetch an
      elided body, or resolve an edge endpoint). ``None`` when it does not resolve.
    * :meth:`expand` — best-effort one-hop neighborhood around ``node`` WITHIN
      this store: the neighbor nodes and the within-store edges to them. An
      adapter with no cheap link structure may return ``([], [])``.
    """

    store: str
    mutable: bool

    def retrieve(self, query: str, budget: Budget) -> list[Node]:
        """Return candidate nodes for ``query``, bounded by ``budget.per_source``."""
        ...

    def get(self, address: Address) -> "Node | None":
        """Resolve and ground a single node by address, or ``None`` if absent."""
        ...

    def expand(self, node: Node, budget: Budget) -> "tuple[list[Node], list[Edge]]":
        """Return the within-store one-hop neighborhood ``(neighbors, edges)``."""
        ...

retrieve

retrieve(query: str, budget: Budget) -> list[Node]

Return candidate nodes for query, bounded by budget.per_source.

Source code in zettelkasten/synapse/substrate.py
def retrieve(self, query: str, budget: Budget) -> list[Node]:
    """Return candidate nodes for ``query``, bounded by ``budget.per_source``."""
    ...

get

get(address: Address) -> 'Node | None'

Resolve and ground a single node by address, or None if absent.

Source code in zettelkasten/synapse/substrate.py
def get(self, address: Address) -> "Node | None":
    """Resolve and ground a single node by address, or ``None`` if absent."""
    ...

expand

expand(node: Node, budget: Budget) -> 'tuple[list[Node], list[Edge]]'

Return the within-store one-hop neighborhood (neighbors, edges).

Source code in zettelkasten/synapse/substrate.py
def expand(self, node: Node, budget: Budget) -> "tuple[list[Node], list[Edge]]":
    """Return the within-store one-hop neighborhood ``(neighbors, edges)``."""
    ...

NotesSourceAdapter

A read-only adapter over any object exposing the ZK source-graph surface.

This is the shared implementation behind the memory, zettelkasten, and dataset adapters. Its backing graph need only expose .notes (a mapping of id → note-like with .title/.type/.tags/.aliases/.body/.links) and an optional .embeddings search index — exactly the surface :func:zettelkasten.framing.project_query consumes. Retrieval therefore REUSES that hybrid RRF fusion unchanged (one synthetic single-source scope), rather than reinventing keyword/semantic scoring.

Subclasses customize node typing, provenance, and tethers via :meth:_provenance / :meth:_tethers; the base keeps them empty.

Source code in zettelkasten/synapse/substrate.py
class NotesSourceAdapter:
    """A read-only adapter over any object exposing the ZK source-graph surface.

    This is the shared implementation behind the memory, zettelkasten, and dataset
    adapters. Its backing ``graph`` need only expose ``.notes`` (a mapping of id →
    note-like with ``.title``/``.type``/``.tags``/``.aliases``/``.body``/``.links``)
    and an optional ``.embeddings`` search index — exactly the surface
    :func:`zettelkasten.framing.project_query` consumes. Retrieval therefore
    REUSES that hybrid RRF fusion unchanged (one synthetic single-source scope),
    rather than reinventing keyword/semantic scoring.

    Subclasses customize node typing, provenance, and tethers via
    :meth:`_provenance` / :meth:`_tethers`; the base keeps them empty.
    """

    # Default read-only marker; a mutable subclass would override to ``True``.
    mutable = False

    def __init__(
        self,
        source: str,
        graph_provider: GraphProvider,
        *,
        store: str = STORE_ZK,
        node_types: "tuple[str, ...] | None" = None,
        link_graph: "str | None" = None,
    ) -> None:
        """Bind the adapter to one source name and its lazy graph provider.

        ``source`` is the MemDSL address source segment (a ZK box name, or the
        memory tree constant); ``store`` its address prefix. ``node_types``, when
        given, restricts retrieval to those note types (e.g. ``("dataset",)``);
        ``None`` keeps every type.

        ``link_graph`` is the graph name the BACKING notes stamp on an intra-graph
        ``link.graph`` field (used ONLY by :meth:`expand` to decide which links
        stay within this source). It DEFAULTS to ``source``, so a local adapter is
        byte-identical to before. A FEDERATED adapter, whose ``source`` is an
        address-safe encoding of ``<repo_id>:<box>`` rather than the real box name,
        must pass the REAL local box name here: a peer's :class:`ZettelGraph` is
        loaded under its local box name (see ``retrieval._federated_graph``), so
        its notes carry that local name — not the encoded ``source`` — on
        ``link.graph``.
        """
        self.store = store
        self.source = source
        self._graph_provider = graph_provider
        self._node_types = node_types
        self._link_graph = source if link_graph is None else link_graph

    # -- helpers ---------------------------------------------------------------

    def _graph(self) -> Any:
        """Resolve the backing source graph (lazily, so a rebuild is picked up)."""
        return self._graph_provider()

    def _type_ok(self, note: Any) -> bool:
        if self._node_types is None:
            return True
        return (getattr(note, "type", "") or "") in self._node_types

    def _provenance(self, note_id: str, note: Any) -> list[str]:
        """Human/source-level origin handles for a note (overridden per store)."""
        return []

    def _tethers(self, note_id: str, note: Any) -> list[str]:
        """Machine-checkable grounding anchors for a note (overridden per store)."""
        return []

    def _to_node(self, note_id: str, note: Any) -> Node:
        """Build a :class:`Node` from a backing note-like object.

        Epistemic status (and its ``outdated`` committed-drift disclosure) is
        DELIBERATELY not read from the backing note here: navigate ALWAYS
        recomputes it from the body/tethers (see
        :func:`zettelkasten.synapse.epistemics.apply_node_statuses`), which stamps
        the live in-memory :attr:`Node.epistemic_status` / :attr:`Node.outdated`.
        Because the disclosure's inseparability guarantee is scoped to this LIVE
        navigate path, note-frontmatter persistence of ``outdated`` is intentionally
        not carried (it would be inert dead storage this recompute never consults).
        """
        return Node(
            store=self.store,
            source=self.source,
            id=note_id,
            type=getattr(note, "type", "") or "",
            title=getattr(note, "title", "") or "",
            text=getattr(note, "body", "") or "",
            provenance=self._provenance(note_id, note),
            tethers=self._tethers(note_id, note),
        )

    # -- protocol --------------------------------------------------------------

    def retrieve(self, query: str, budget: Budget) -> list[Node]:
        """Retrieve candidate nodes for ``query`` via reused hybrid fusion.

        Delegates to :func:`zettelkasten.framing.project_query` over a single
        synthetic source, so keyword + semantic channels are fused with exactly
        the same RRF/rerank the rest of synapse uses. A type filter (if any) is
        applied to the fused hits. Any retrieval failure yields ``[]`` — a bad
        source loses the ranking, it never crashes assembly.
        """
        from zettelkasten import framing

        graph = self._graph()
        get_graph = lambda _name: graph  # noqa: E731 - single-source grapher

        try:
            hits = framing.project_query(query, [self.source], budget.per_source, get_graph)
        except Exception:  # noqa: BLE001 - a bad source loses ranking, never crashes
            logger.warning("substrate: retrieval failed for %s", self.source, exc_info=True)
            return []

        notes = getattr(graph, "notes", {}) or {}
        out: list[Node] = []
        for note_id, _sim, _sname, _tier in hits:
            note = notes.get(note_id)
            if note is None or not self._type_ok(note):
                continue
            out.append(self._to_node(note_id, note))
        return out

    def get(self, address: Address) -> "Node | None":
        """Resolve a single node by address (matched on store + source + id).

        The ``source`` segment is part of node identity — an adapter bound to one
        source must not resolve an id living in a sibling source (e.g. another ZK
        box), so a store OR source mismatch yields ``None``.
        """
        if address.store != self.store or address.source != self.source:
            return None
        note = (getattr(self._graph(), "notes", {}) or {}).get(address.id)
        if note is None or not self._type_ok(note):
            return None
        return self._to_node(address.id, note)

    def expand(self, node: Node, budget: Budget) -> "tuple[list[Node], list[Edge]]":
        """Return within-store neighbors + edges from a note's own links.

        Best-effort and structural only: it reads the note's declared ``links``
        (``.target``/``.relation``) and resolves each target within THIS source.
        A store whose notes carry no such link structure (e.g. the memory tree
        adapter) simply returns ``([], [])``.
        """
        notes = getattr(self._graph(), "notes", {}) or {}
        note = notes.get(node.id)
        if note is None:
            return [], []
        # Guard the SOURCE token once up front. ``node.token`` (and each
        # ``neighbor.token`` below) constructs an :class:`Address`, which raises
        # :class:`MemDSLParseError` on an id carrying a char ZK's ``validate_id``
        # permits but the address scheme forbids (whitespace / ``,`` / ``{`` /
        # ``}`` / ``:``). If the source node is itself unaddressable, no edge can
        # name it, so yield an empty neighborhood cleanly rather than letting the
        # raise sink the whole expansion (the broad upstream ``except`` would else
        # erase EVERY neighbor of this node).
        src_token = _safe_token(node)
        if src_token is None:
            return [], []
        neighbors: list[Node] = []
        edges: list[Edge] = []
        for link in getattr(note, "links", []) or []:
            target = getattr(link, "target", None)
            relation = getattr(link, "relation", None)
            # Only follow edges that stay within this source (no cross-graph hop):
            # a within-store edge has an empty ``graph`` or one naming this source's
            # backing graph. ``self._link_graph`` is the name the backing notes
            # actually stamp (== ``self.source`` locally; the REAL box name for a
            # federated adapter whose ``source`` is an encoded identity).
            link_graph = getattr(link, "graph", "") or ""
            if not target or not relation or (link_graph and link_graph != self._link_graph):
                continue
            tgt_note = notes.get(target)
            if tgt_note is None or not self._type_ok(tgt_note):
                continue
            neighbor = self._to_node(target, tgt_note)
            # Per-neighbor guard: a neighbor whose id is address-invalid would
            # raise here on ``neighbor.token``. SKIP just that one neighbor (a
            # debug log via ``_safe_token``) and continue, so a good sibling later
            # in the link list survives instead of being erased with the bad one.
            # Only the address-validity ``MemDSLParseError`` is skipped; any other
            # error keeps the existing best-effort behavior (it propagates to the
            # upstream ``except``). Surviving neighbors keep their link order.
            neighbor_token = _safe_token(neighbor)
            if neighbor_token is None:
                continue
            neighbors.append(neighbor)
            edges.append(Edge(src=src_token, dst=neighbor_token, relation=relation))
        return neighbors, edges

retrieve

retrieve(query: str, budget: Budget) -> list[Node]

Retrieve candidate nodes for query via reused hybrid fusion.

Delegates to :func:zettelkasten.framing.project_query over a single synthetic source, so keyword + semantic channels are fused with exactly the same RRF/rerank the rest of synapse uses. A type filter (if any) is applied to the fused hits. Any retrieval failure yields [] — a bad source loses the ranking, it never crashes assembly.

Source code in zettelkasten/synapse/substrate.py
def retrieve(self, query: str, budget: Budget) -> list[Node]:
    """Retrieve candidate nodes for ``query`` via reused hybrid fusion.

    Delegates to :func:`zettelkasten.framing.project_query` over a single
    synthetic source, so keyword + semantic channels are fused with exactly
    the same RRF/rerank the rest of synapse uses. A type filter (if any) is
    applied to the fused hits. Any retrieval failure yields ``[]`` — a bad
    source loses the ranking, it never crashes assembly.
    """
    from zettelkasten import framing

    graph = self._graph()
    get_graph = lambda _name: graph  # noqa: E731 - single-source grapher

    try:
        hits = framing.project_query(query, [self.source], budget.per_source, get_graph)
    except Exception:  # noqa: BLE001 - a bad source loses ranking, never crashes
        logger.warning("substrate: retrieval failed for %s", self.source, exc_info=True)
        return []

    notes = getattr(graph, "notes", {}) or {}
    out: list[Node] = []
    for note_id, _sim, _sname, _tier in hits:
        note = notes.get(note_id)
        if note is None or not self._type_ok(note):
            continue
        out.append(self._to_node(note_id, note))
    return out

get

get(address: Address) -> 'Node | None'

Resolve a single node by address (matched on store + source + id).

The source segment is part of node identity — an adapter bound to one source must not resolve an id living in a sibling source (e.g. another ZK box), so a store OR source mismatch yields None.

Source code in zettelkasten/synapse/substrate.py
def get(self, address: Address) -> "Node | None":
    """Resolve a single node by address (matched on store + source + id).

    The ``source`` segment is part of node identity — an adapter bound to one
    source must not resolve an id living in a sibling source (e.g. another ZK
    box), so a store OR source mismatch yields ``None``.
    """
    if address.store != self.store or address.source != self.source:
        return None
    note = (getattr(self._graph(), "notes", {}) or {}).get(address.id)
    if note is None or not self._type_ok(note):
        return None
    return self._to_node(address.id, note)

expand

expand(node: Node, budget: Budget) -> 'tuple[list[Node], list[Edge]]'

Return within-store neighbors + edges from a note's own links.

Best-effort and structural only: it reads the note's declared links (.target/.relation) and resolves each target within THIS source. A store whose notes carry no such link structure (e.g. the memory tree adapter) simply returns ([], []).

Source code in zettelkasten/synapse/substrate.py
def expand(self, node: Node, budget: Budget) -> "tuple[list[Node], list[Edge]]":
    """Return within-store neighbors + edges from a note's own links.

    Best-effort and structural only: it reads the note's declared ``links``
    (``.target``/``.relation``) and resolves each target within THIS source.
    A store whose notes carry no such link structure (e.g. the memory tree
    adapter) simply returns ``([], [])``.
    """
    notes = getattr(self._graph(), "notes", {}) or {}
    note = notes.get(node.id)
    if note is None:
        return [], []
    # Guard the SOURCE token once up front. ``node.token`` (and each
    # ``neighbor.token`` below) constructs an :class:`Address`, which raises
    # :class:`MemDSLParseError` on an id carrying a char ZK's ``validate_id``
    # permits but the address scheme forbids (whitespace / ``,`` / ``{`` /
    # ``}`` / ``:``). If the source node is itself unaddressable, no edge can
    # name it, so yield an empty neighborhood cleanly rather than letting the
    # raise sink the whole expansion (the broad upstream ``except`` would else
    # erase EVERY neighbor of this node).
    src_token = _safe_token(node)
    if src_token is None:
        return [], []
    neighbors: list[Node] = []
    edges: list[Edge] = []
    for link in getattr(note, "links", []) or []:
        target = getattr(link, "target", None)
        relation = getattr(link, "relation", None)
        # Only follow edges that stay within this source (no cross-graph hop):
        # a within-store edge has an empty ``graph`` or one naming this source's
        # backing graph. ``self._link_graph`` is the name the backing notes
        # actually stamp (== ``self.source`` locally; the REAL box name for a
        # federated adapter whose ``source`` is an encoded identity).
        link_graph = getattr(link, "graph", "") or ""
        if not target or not relation or (link_graph and link_graph != self._link_graph):
            continue
        tgt_note = notes.get(target)
        if tgt_note is None or not self._type_ok(tgt_note):
            continue
        neighbor = self._to_node(target, tgt_note)
        # Per-neighbor guard: a neighbor whose id is address-invalid would
        # raise here on ``neighbor.token``. SKIP just that one neighbor (a
        # debug log via ``_safe_token``) and continue, so a good sibling later
        # in the link list survives instead of being erased with the bad one.
        # Only the address-validity ``MemDSLParseError`` is skipped; any other
        # error keeps the existing best-effort behavior (it propagates to the
        # upstream ``except``). Surviving neighbors keep their link order.
        neighbor_token = _safe_token(neighbor)
        if neighbor_token is None:
            continue
        neighbors.append(neighbor)
        edges.append(Edge(src=src_token, dst=neighbor_token, relation=relation))
    return neighbors, edges

MemoryAdapter

Bases: NotesSourceAdapter

Read-only adapter over the .memory/ research tree (mem:tree:<id>).

Wraps :func:zettelkasten.synapse.memory_source.get_memory_source (the cached :class:~zettelkasten.synapse.memory_source.MemorySourceGraph), so it reuses memory's existing model2vec vectors for the semantic channel with no corpus re-embed. Provenance is the entry's normalized git-pinned file tokens; the tethers are the RAW path@sha pins (the drift anchors the engine checks).

Source code in zettelkasten/synapse/substrate.py
class MemoryAdapter(NotesSourceAdapter):
    """Read-only adapter over the ``.memory/`` research tree (``mem:tree:<id>``).

    Wraps :func:`zettelkasten.synapse.memory_source.get_memory_source` (the cached
    :class:`~zettelkasten.synapse.memory_source.MemorySourceGraph`), so it reuses
    memory's existing model2vec vectors for the semantic channel with no corpus
    re-embed. Provenance is the entry's normalized git-pinned file tokens; the
    tethers are the RAW ``path@sha`` pins (the drift anchors the engine checks).
    """

    def __init__(self, graph_provider: "GraphProvider | None" = None) -> None:
        if graph_provider is None:
            def graph_provider() -> Any:
                from zettelkasten.synapse.memory_source import get_memory_source

                return get_memory_source()

        super().__init__(MEM_TREE_SOURCE, graph_provider, store=STORE_MEM)

    def _provenance(self, note_id: str, note: Any) -> list[str]:
        # Reuse the candidate module's provenance extractor (normalized file
        # tokens with any ``@sha`` pin stripped) rather than re-parsing ``files``.
        from zettelkasten.synapse import candidates

        return sorted(candidates._memory_provenance(note))

    def _tethers(self, note_id: str, note: Any) -> list[str]:
        # The raw git-pinned ``files`` (keeping the ``@sha`` suffix) are the
        # machine-checkable drift anchors for a memory entry.
        entry = getattr(note, "entry", {}) or {}
        raw = entry.get("files")
        if isinstance(raw, str):
            items = [p.strip() for p in raw.replace("\n", ",").split(",")]
        elif isinstance(raw, list):
            items = [str(p).strip() for p in raw]
        else:
            items = []
        return [f"file:{p}" for p in items if p]

ZettelAdapter

Bases: NotesSourceAdapter

Read-only adapter over one zettelkasten box (zk:<box>:<note_id>).

Retrieval reuses the box graph's own embedding index + keyword channel through :func:~zettelkasten.framing.project_query. Provenance is the note's cited-work ids and file-path tokens (via :mod:~zettelkasten.synapse.candidates); tethers are its citation handles (the grounding a verified quote/finding rests on).

Source code in zettelkasten/synapse/substrate.py
class ZettelAdapter(NotesSourceAdapter):
    """Read-only adapter over one zettelkasten box (``zk:<box>:<note_id>``).

    Retrieval reuses the box graph's own embedding index + keyword channel through
    :func:`~zettelkasten.framing.project_query`. Provenance is the note's cited-work
    ids and file-path tokens (via :mod:`~zettelkasten.synapse.candidates`); tethers
    are its citation handles (the grounding a verified quote/finding rests on).
    """

    def __init__(
        self,
        box: str,
        graph_provider: GraphProvider,
        *,
        node_types: "tuple[str, ...] | None" = None,
        link_graph: "str | None" = None,
    ) -> None:
        super().__init__(
            box,
            graph_provider,
            store=STORE_ZK,
            node_types=node_types,
            link_graph=link_graph,
        )

    def _provenance(self, note_id: str, note: Any) -> list[str]:
        from zettelkasten.synapse import candidates

        return sorted(candidates._zk_provenance(note))

    def _tethers(self, note_id: str, note: Any) -> list[str]:
        # Citation handles a note's evidence is grounded in — the tether the
        # epistemics layer verifies against the cited source.
        cites: list[str] = []
        for link in getattr(note, "links", []) or []:
            if getattr(link, "graph", None) == "_citations":
                target = getattr(link, "target", None)
                if target:
                    cites.append(f"cite:{target}")
        return cites

DatasetAdapter

Bases: ZettelAdapter

Read-only adapter over dataset-typed zettelkasten notes.

A thin specialization of :class:ZettelAdapter that restricts retrieval to dataset notes and, on top of the citation tethers, adds the dataset's content_hash — the reproducible-bytes anchor from the note's data block (see :mod:zettelkasten.datasets). That hash is the tether the engine uses to prove a measured claim still rests on the same numbers.

Source code in zettelkasten/synapse/substrate.py
class DatasetAdapter(ZettelAdapter):
    """Read-only adapter over ``dataset``-typed zettelkasten notes.

    A thin specialization of :class:`ZettelAdapter` that restricts retrieval to
    ``dataset`` notes and, on top of the citation tethers, adds the dataset's
    ``content_hash`` — the reproducible-bytes anchor from the note's ``data``
    block (see :mod:`zettelkasten.datasets`). That hash is the tether the engine
    uses to prove a measured claim still rests on the same numbers.
    """

    def __init__(self, box: str, graph_provider: GraphProvider) -> None:
        super().__init__(box, graph_provider, node_types=("dataset",))

    def _tethers(self, note_id: str, note: Any) -> list[str]:
        tethers = super()._tethers(note_id, note)
        data = getattr(note, "data", None) or {}
        content_hash = data.get("content_hash") if isinstance(data, dict) else None
        if content_hash:
            tethers.append(f"hash:{content_hash}")
        return tethers

CodeAdapter

Best-effort, read-only adapter over angelo's own source code (code:…).

Thin by design (v1): source code is not a first-class synapse store, so this adapter resolves a symbol/file anchor for a query against the memory code graph (:mod:memory.code_context) when one is available and emits a single code node for it. When no code graph is provided — or resolution fails — it degrades to yielding nothing rather than raising, so it is always safe to include in an assembly. It exists to prove the protocol is genuinely store-agnostic (a code node can sit beside zk/mem nodes and take part in edges such as code→data); a richer code adapter can replace it without touching the protocol.

Source code in zettelkasten/synapse/substrate.py
class CodeAdapter:
    """Best-effort, read-only adapter over angelo's own source code (``code:…``).

    Thin by design (v1): source code is not a first-class synapse store, so this
    adapter resolves a symbol/file anchor for a query against the memory code
    graph (:mod:`memory.code_context`) when one is available and emits a single
    ``code`` node for it. When no code graph is provided — or resolution fails —
    it degrades to yielding nothing rather than raising, so it is always safe to
    include in an assembly. It exists to prove the protocol is genuinely
    store-agnostic (a ``code`` node can sit beside ``zk``/``mem`` nodes and take
    part in edges such as ``code→data``); a richer code adapter can replace it
    without touching the protocol.
    """

    store = STORE_CODE
    mutable = False

    # A zero-arg provider of a code graph handle (a kglite graph, as returned by
    # the memory server's code-graph loader), or ``None`` when unavailable.
    def __init__(self, graph_provider: "Callable[[], Any] | None" = None) -> None:
        self._graph_provider = graph_provider

    def _graph(self) -> Any:
        if self._graph_provider is None:
            return None
        try:
            return self._graph_provider()
        except Exception:  # noqa: BLE001 - a missing code graph is not an error
            logger.debug("substrate: code graph unavailable", exc_info=True)
            return None

    def _node_from_anchor(self, anchor: dict) -> "Node | None":
        """Build a ``code`` node from a resolved :func:`code_context` anchor.

        Reads the anchor shape :func:`memory.code_context.resolve_anchor` returns:
        ``kind`` ∈ {symbol, file, absent}, plus ``file_path`` /
        ``qualified_name`` / ``name`` / ``type`` when resolved. An ``absent``
        anchor (nothing located) yields ``None``.
        """
        if str(anchor.get("kind") or "absent") == "absent":
            return None
        path = str(anchor.get("file_path") or "").strip()
        symbol = str(anchor.get("qualified_name") or anchor.get("name") or "").strip()
        if not path and not symbol:
            return None
        # Address: module source from the file's directory, id from the symbol or
        # file name. Sanitize any ``:`` the Address scheme forbids.
        source = (path.rsplit("/", 1)[0] if "/" in path else "code").replace(":", "_") or "code"
        node_id = (symbol or path.rsplit("/", 1)[-1] or path).replace(":", "_")
        return Node(
            store=self.store,
            source=source,
            id=node_id,
            type="code",  # an extension type; tolerated like an unknown MemDSL type
            title=symbol or path,
            text=str(anchor.get("type") or ""),
            # ``provenance`` is display-only lineage — keep the file token so the
            # anchor's origin is always visible. ``tethers`` is the machine-checkable
            # drift anchor and must NOT carry an unpinned ``file:{path}`` (a v1 code
            # anchor exposes no commit/blob sha — see ``code_context.resolve_anchor``
            # — so there is nothing to check drift against). Emitting an unpinned pin
            # would make the node structurally aspire to ``grounded`` while relying
            # on the resolver to catch it; instead emit no git tether, leaving the
            # node honestly ``inferred`` until a sha-pinnable code adapter arrives.
            provenance=[f"file:{path}"] if path else [],
            tethers=[],
        )

    def retrieve(self, query: str, budget: Budget) -> list[Node]:
        """Resolve a single best-effort code anchor for ``query`` (or ``[]``)."""
        graph = self._graph()
        if graph is None or not query.strip():
            return []
        try:
            from memory import code_context

            anchor = code_context.resolve_anchor(graph, query=query)
        except Exception:  # noqa: BLE001 - best-effort: never crash assembly
            logger.debug("substrate: code anchor resolution failed", exc_info=True)
            return []
        if not anchor:
            return []
        node = self._node_from_anchor(anchor)
        return [node] if node is not None else []

    def get(self, address: Address) -> "Node | None":
        """v1 code adapter has no address→node grounding path (returns ``None``)."""
        return None

    def expand(self, node: Node, budget: Budget) -> "tuple[list[Node], list[Edge]]":
        """v1 code adapter contributes no within-store expansion."""
        return [], []

retrieve

retrieve(query: str, budget: Budget) -> list[Node]

Resolve a single best-effort code anchor for query (or []).

Source code in zettelkasten/synapse/substrate.py
def retrieve(self, query: str, budget: Budget) -> list[Node]:
    """Resolve a single best-effort code anchor for ``query`` (or ``[]``)."""
    graph = self._graph()
    if graph is None or not query.strip():
        return []
    try:
        from memory import code_context

        anchor = code_context.resolve_anchor(graph, query=query)
    except Exception:  # noqa: BLE001 - best-effort: never crash assembly
        logger.debug("substrate: code anchor resolution failed", exc_info=True)
        return []
    if not anchor:
        return []
    node = self._node_from_anchor(anchor)
    return [node] if node is not None else []

get

get(address: Address) -> 'Node | None'

v1 code adapter has no address→node grounding path (returns None).

Source code in zettelkasten/synapse/substrate.py
def get(self, address: Address) -> "Node | None":
    """v1 code adapter has no address→node grounding path (returns ``None``)."""
    return None

expand

expand(node: Node, budget: Budget) -> 'tuple[list[Node], list[Edge]]'

v1 code adapter contributes no within-store expansion.

Source code in zettelkasten/synapse/substrate.py
def expand(self, node: Node, budget: Budget) -> "tuple[list[Node], list[Edge]]":
    """v1 code adapter contributes no within-store expansion."""
    return [], []

is_valid_type

is_valid_type(node_type: str) -> bool

Whether node_type is in the canonical note-type vocabulary.

Advisory only: the substrate tolerates unknown types (they degrade gracefully like the MemDSL node-type registry). Consumers use this to decide whether a type needs special-casing, never to reject a node.

Source code in zettelkasten/synapse/substrate.py
def is_valid_type(node_type: str) -> bool:
    """Whether ``node_type`` is in the canonical note-type vocabulary.

    Advisory only: the substrate tolerates unknown types (they degrade gracefully
    like the MemDSL node-type registry). Consumers use this to decide whether a
    type needs special-casing, never to reject a node.
    """
    return node_type in VALID_TYPES

is_valid_relation

is_valid_relation(relation: str) -> bool

Whether relation is in the canonical edge-relation vocabulary.

Source code in zettelkasten/synapse/substrate.py
def is_valid_relation(relation: str) -> bool:
    """Whether ``relation`` is in the canonical edge-relation vocabulary."""
    return relation in VALID_RELATIONS

assemble_neighborhood

assemble_neighborhood(query: str, adapters: 'list[SourceAdapter]', budget: 'Budget | None' = None, *, expand: bool = False, linkers: 'tuple[Linker, ...]' = ()) -> Neighborhood

Assemble an on-demand, in-memory substrate neighborhood for query.

The pipeline is deliberately simple and topology-general:

  1. Retrieve. Each adapter contributes up to budget.per_source nodes via its own reused fusion; nodes are merged and de-duplicated on their :attr:Node.token up to budget.max_nodes when that cap is set.
  2. Expand (optional). When expand is set and budget.hops > 0, the frontier is expanded budget.hops levels: each frontier node's producing adapter yields its within-store one-hop neighborhood, newly-discovered neighbor nodes are admitted up to the cap, and each becomes part of the next hop's frontier (so a chain A→B→C is fully reached at hops=2).
  3. Link. Each injected linker may add cross-cutting edges (e.g. typed cross-store links from the persisted overlay) among the assembled nodes.
  4. Prune. Edges whose endpoints are not both present are dropped, so the returned graph is always internally consistent.

Nothing is persisted. Returns a :class:Neighborhood.

Source code in zettelkasten/synapse/substrate.py
def assemble_neighborhood(
    query: str,
    adapters: "list[SourceAdapter]",
    budget: "Budget | None" = None,
    *,
    expand: bool = False,
    linkers: "tuple[Linker, ...]" = (),
) -> Neighborhood:
    """Assemble an on-demand, in-memory substrate neighborhood for ``query``.

    The pipeline is deliberately simple and topology-general:

    1. **Retrieve.** Each adapter contributes up to ``budget.per_source`` nodes
       via its own reused fusion; nodes are merged and de-duplicated on their
       :attr:`Node.token` up to ``budget.max_nodes`` when that cap is set.
    2. **Expand** (optional). When ``expand`` is set and ``budget.hops > 0``, the
       frontier is expanded ``budget.hops`` levels: each frontier node's producing
       adapter yields its within-store one-hop neighborhood, newly-discovered
       neighbor nodes are admitted up to the cap, and each becomes part of the
       next hop's frontier (so a chain A→B→C is fully reached at ``hops=2``).
    3. **Link.** Each injected ``linker`` may add cross-cutting edges (e.g. typed
       cross-store links from the persisted overlay) among the assembled nodes.
    4. **Prune.** Edges whose endpoints are not both present are dropped, so the
       returned graph is always internally consistent.

    Nothing is persisted. Returns a :class:`Neighborhood`.
    """
    budget = budget or Budget()
    nodes: dict[str, Node] = {}
    # Track which adapter produced each seed so expansion calls the right one
    # (several ZK adapters share the ``zk`` store, so store→adapter is ambiguous).
    seeds: list[tuple[Node, SourceAdapter]] = []

    def _at_node_cap() -> bool:
        """Whether the optional width ceiling has been reached."""

        return budget.max_nodes is not None and len(nodes) >= budget.max_nodes

    # 1. Retrieve.
    for adapter in adapters:
        if _at_node_cap():
            break
        try:
            found = adapter.retrieve(query, budget)
        except Exception:  # noqa: BLE001 - one bad adapter never sinks the assembly
            logger.warning("substrate: adapter %r retrieve failed", adapter, exc_info=True)
            continue
        for node in found:
            if _at_node_cap():
                break
            token = _safe_token(node)
            if token is None or token in nodes:
                continue
            nodes[token] = node
            seeds.append((node, adapter))

    edges: list[Edge] = []

    # 2. Expand (optional, bounded). Breadth-first over ``budget.hops`` levels:
    # the current frontier is expanded within-store, each newly-admitted neighbor
    # (counting against ``max_nodes``) joins the next hop's frontier, so a chain
    # A→B→C is reached at hops=2. Already-seen nodes are never re-expanded, which
    # also terminates cycles. Overflow edges to unadmitted nodes are dropped by
    # the dangling-edge prune below.
    if expand and budget.hops > 0:
        frontier = list(seeds)
        for _hop in range(budget.hops):
            if not frontier:
                break
            next_frontier: list[tuple[Node, SourceAdapter]] = []
            for node, adapter in frontier:
                try:
                    neighbors, hop_edges = adapter.expand(node, budget)
                except Exception:  # noqa: BLE001 - expansion is best-effort
                    logger.debug("substrate: expand failed for %s", node.token, exc_info=True)
                    continue
                edges.extend(hop_edges)
                for neighbor in neighbors:
                    neighbor_token = _safe_token(neighbor)
                    if neighbor_token is None or neighbor_token in nodes:
                        continue
                    if _at_node_cap():
                        continue
                    nodes[neighbor_token] = neighbor
                    next_frontier.append((neighbor, adapter))
            frontier = next_frontier

    # 3. Link (injected cross-cutting edge providers).
    for linker in linkers:
        try:
            edges.extend(linker(nodes))
        except Exception:  # noqa: BLE001 - a bad linker adds no edges
            logger.debug("substrate: linker %r failed", linker, exc_info=True)

    # 4. Prune dangling edges so the graph is internally consistent.
    edges = [e for e in edges if e.src in nodes and e.dst in nodes]

    return Neighborhood(nodes=nodes, edges=edges)

overlay_linker

overlay_linker(kind: str = 'generic', min_conf: float = 0.6) -> Linker

Build a :data:Linker that reads typed cross-store edges from the overlay.

Returns a callable that, given the assembled node map, reads the SHIPPED, persisted synapse link overlay (:func:zettelkasten.synapse.overlay. load_overlay, read-only — never written) and emits an :class:Edge for every stored memory ↔ zk link whose confidence clears min_conf AND whose BOTH endpoints are already present in the neighborhood. Endpoints are matched by the full (store, source, id) triple against the assembled nodes — the zk endpoint uses the edge's own zk_source (box) segment — so an overlay edge attaches only to the correct node and never conflates two boxes that happen to share an id. The linker stays topology-agnostic: it only connects nodes the adapters already surfaced.

Source code in zettelkasten/synapse/substrate.py
def overlay_linker(kind: str = "generic", min_conf: float = 0.6) -> Linker:
    """Build a :data:`Linker` that reads typed cross-store edges from the overlay.

    Returns a callable that, given the assembled node map, reads the SHIPPED,
    persisted synapse link overlay (:func:`zettelkasten.synapse.overlay.
    load_overlay`, read-only — never written) and emits an :class:`Edge` for every
    stored ``memory ↔ zk`` link whose confidence clears ``min_conf`` AND whose
    BOTH endpoints are already present in the neighborhood. Endpoints are matched
    by the full ``(store, source, id)`` triple against the assembled nodes — the
    zk endpoint uses the edge's own ``zk_source`` (box) segment — so an overlay
    edge attaches only to the correct node and never conflates two boxes that
    happen to share an id. The linker stays topology-agnostic: it only connects
    nodes the adapters already surfaced.
    """

    def _link(nodes: dict[str, Node]) -> list[Edge]:
        from zettelkasten.synapse import overlay

        # Index present nodes by (store, source, id) for O(1) endpoint resolution.
        # The source segment is load-bearing: two ZK boxes may hold the same id,
        # so keying on (store, id) alone would attach an edge to the wrong box.
        by_addr: dict[tuple[str, str, str], str] = {
            (n.store, n.source, n.id): token for token, n in nodes.items()
        }
        edges: list[Edge] = []
        for edge in overlay.load_overlay(kind).get("edges", []):
            confidence = float(edge.get("confidence", 0.0) or 0.0)
            if confidence < min_conf:
                continue
            src = by_addr.get((STORE_MEM, MEM_TREE_SOURCE, str(edge.get("memory_id") or "")))
            dst = by_addr.get((STORE_ZK, str(edge.get("zk_source") or ""), str(edge.get("zk_id") or "")))
            if src is None or dst is None:
                continue
            edges.append(Edge(
                src=src, dst=dst,
                relation=str(edge.get("relation") or "related"),
                confidence=confidence,
                rationale=str(edge.get("rationale") or ""),
                signals=dict(edge.get("signals") or {}),
            ))
        return edges

    return _link