DDR-006 — The engine, not the agent, keeps the graph connected¶
Version: 0.1.0 | Date: 2026-09-24 | Status: Accepted
Context¶
Engrama's write contract relies on agent discipline. The system prompt
(v0.5/v0.6) and the engrama_relate docstring say "call relate right
after remember", and engrama_remember answers relations_created: 0
without further comment when the agent does not. Sustained use shows what
happens when an agent forgets, loses context, or reaches for a tag instead
of an edge. The graph degrades in recognisable ways:
- Tags instead of edges. Nodes carry a tag that is literally the name of an existing anchor node (a project, a course, a domain) but have no edge to it. Search still finds them, but the graph does not connect them, and a large share of the live nodes can end up with no edge at all.
- Stubs that become hubs. Inline relations create a target stub
(
status: stub) when nothing matches. Nothing ever clears that status or asks for enrichment, so a stub can collect many edges while having no summary. Those stubs carry real structure: removing them splits the graph, so the fix is to enrich them, not to drop them. - Duplicates across labels and names.
lookup_node_labelmatches a name case-insensitively on any label and returns an arbitrary one (LIMIT 1), whilemerge_nodeis label-specific, so the same entity can exist under two labels. Most duplication is semantic rather than lexical (a product, "the product's API" and "the product's certificate" as three unlinked nodes), so exact name matching finds few of them. - Label and status drift. Labels get stretched past their meaning
(
Domain, meant for areas of knowledge, used for internet domain names).statusis free text in practice, including values in the user's own language, so the queries that filter onopen/activequietly miss nodes. - Two meanings of
Insight. Reflect writesInsightnodes for review, and agents also use the label, correctly, for syntheses they write themselves: lessons learned, post-mortems, conclusions. Those hand-written Insights tend to be well documented and well linked, but every detector and structural query skips:Insight, so they never take part in curation.
The fuzzy resolution added in #93 is a good start but has three limits.
It is lexical only (difflib). It never runs for the node being
remembered, only for inline targets. It scans an alphabetically
ordered, capped list of candidates (list_existing_nodes,
LIMIT 1000), so above 1000 nodes the tail of the alphabet is never a
candidate. The scan is also linear: in a synthetic benchmark it takes
13 ms per write at 1 000 nodes and 300 ms at 20 000. It also resolves to
archived nodes, which search hides.
Decision¶
Every write goes through one entity resolver, and every write response tells the agent the structural consequence of what it just did. The engine guarantees resolution, feedback and stub hygiene regardless of how careful the agent is. Rejection stays opt-in.
Concretely¶
- Entity resolver (
engrama/core/resolve.py), used byengrama_rememberfor the node itself and for every inline target. The policy is pure and identical on every backend; the stores only supply bounded, scope-filtered candidates: name_candidates: nodes whose name contains a three-character fragment of each word of the normalised target (casefold, accents stripped,-/_/space unified), exact names and similar lengths first, capped at 200. Unlike a fulltext lookup, this still finds a candidate when the target has a typo;- vector kNN, for the remembered node only, reusing the embedding already computed for embed-on-write. Neighbours are re-scored with an exact cosine, because each backend's vector index reports similarity on a different scale.
Names are compared after normalisation (difflib ratio). The
three-way policy from #93 (connect / ask / create) stays, with
thresholds configurable (ENGRAMA_RESOLVE_*). Only near-certain
lexical matches connect automatically; semantic similarity only
ever warns. Archived nodes are never linked silently: an exact match on
an archived node is reported as revive_candidate.
2. Resolution of the remembered node itself. When a node with a
different key (or the same key under another label) resolves above
the threshold, the write still happens. The response carries
possible_duplicate_of: [{label, name, score, reason}].
ENGRAMA_REMEMBER_DEDUPE=off|warn|block sets the behaviour, with
warn as the default. block returns an error with did_you_mean
unless the call passes force_new: true.
3. Label-aware lookup. Exact matches prefer the requested label. An
exact match under a different label is reported as label_conflict
instead of silently picking one (replaces LIMIT 1).
4. Degree feedback. Every engrama_remember response carries the
node's resulting degree. When it is 0 the response adds
orphan_warning and suggested_relations, built from the resolver and
from tag anchors (item 5). The opt-in ENGRAMA_REQUIRE_RELATIONS=true
rejects degree-0 writes except for anchor labels (Project,
Domain, Client, Course, Person).
5. Tag anchoring. A tag that resolves exactly (normalised or alias)
to an anchor node in scope becomes a suggested relation, using a
per-label default type (Project → BELONGS_TO, Course → COVERS
reversed, Client → FOR, Domain → IN_DOMAIN).
ENGRAMA_TAG_LINKING=off|suggest|auto, default suggest. With auto,
the edges are created and listed in the response as
relations_from_tags, so the caller sees exactly what was added. (Edges
carry no properties in the SQLite backend, so they are not stamped.)
6. Stub lifecycle. A stub keeps status: stub only until it is
enriched. A write that supplies summary or details clears it (to
the caller's status, else active). Stub labels come from the explicit
label, then the resolver, then the relation type. Relation-type
inference is kept only as the last resort. Responses that touch a stub
with degree ≥ 3 carry enrich_hint.
7. Controlled vocabularies.
- status is validated against a per-label enum declared in the
profile. Unknown values are normalised when a mapping exists;
otherwise they are kept verbatim in status_note, with the status
set to active and a warning.
- Insight keeps both meanings, and they are told apart by origin. A
system Insight is one that carries source_query, which reflect
always sets. Only system Insights follow the review lifecycle and
the Insight-specific rules in DDR-007 and DDR-008. A hand-written
Insight is an ordinary domain node for the resolver, the detectors
and the structural metrics. Writes are unchanged and nothing is
relabelled. A single helper, is_system_insight(node), carries the
rule for both backends.
- The Domain label's description is tightened to "area of knowledge"
in the profiles and tool docs.
Consequences¶
- Positive:
- The graph's structural quality stops depending on prompt adherence. An agent that has lost context still gets told about duplicates, orphans and the anchors it forgot to link.
- The resolver replaces a linear, capped scan with index lookups, and measured candidate retrieval is flat in graph size: sqlite-vec kNN with scope takes 0.9 ms at 1 000 vectors and 5.3 ms at 5 000.
- Negative:
- Responses grow. Agents that pattern-match on the old response shape keep working, because the fields are additive, but they see more fields.
warnmeans some duplicates are still written. It is the default only becauseblockneeds a model-side retry loop.- Status normalisation rewrites values on the next write; the original
is preserved in
status_note. - Neutral:
- The resolver runs with or without an embedder. Without one, it
degrades to lexical resolution. The core never
requires an LLM. A pluggable decider (
EntityDeciderprotocol) can replace the threshold policy with a calibrated local classifier; that is an open follow-up.
Open follow-ups (not blocking)¶
- A pluggable
EntityDeciderfor resolution, relation-type choice and supersession, calibrated on real accept/reject data. - Deferred stub enrichment: a background task that drafts a summary for hub stubs from their neighbourhood. It needs an LLM, so it stays outside the core.
- An
aliasesproperty consulted by the resolver. Aliases come from merging nodes, so they arrive with the merge operation (DDR-008). - Per-profile status vocabularies for the domain modules (
hacking,teaching,photography,ai). - An episodic label (
EventorSession) for session logs and periodic reports, which agents currently file asInsightfor lack of a better fit.