1. Executive Summary
Nova is a personal companion built by one author for one user, and
its defining constraint is stated in the README: no LLM in the
core, no cloud AI. Nothing on a running path calls a model.
core/llm_bridge.py, an Ollama client, has no caller outside
a manual script. Its refusal of a refuted belief is keyed on the value
and consulted on every write path that can store a definition.
That constraint puts every epistemic decision in the code. A MiniLM embedding model and spaCy run as bounded specialists that propose relations, and nothing they propose is stored until the user picks it from a numbered list. No model answers a question or decides what is true, so string matching, a hand-built concept graph and rules do all of it.
Its knowledge memory is a concept graph with provenance on
every edge. A concept is a word; a word has senses carrying
sense_ids; a sense has relations (is_a,
part_of, causes, synonym,
antonym) and each relation carries a source, a
confidence, a status and a
created_at. The committed concepts.json holds
1,234 concepts and 4,569 relations: 4,372 with source user,
174 embedding, 17 wikipedia and 6
auto_extract. Of those, 4,359 are confirmed, 198 rejected
and 12 unverified. A relation a person confirms from a list takes source
user and keeps the original in
oorspronkelijke_bron.
A relation parsed from the user's sentence passes a typed
confirmation gate. Before storing it, Nova asks "Mag ik
onthouden dat 'X' is een soort van 'Y'?" — may I remember that X is
a kind of Y — and writes only on ja. If the subject has
more than one known sense it asks which one first, by number. The gate
is a turn in the conversation at the terminal prompt. Two automatic
writers do not pass it: _auto_extract_is_a and the
Wikipedia teacher's first store append unverified edges
without asking. Those edges have an exit: "wat weet je niet
zeker?" — what are you unsure of — lists them by number, and
bevestig N or wijs af N confirms or rejects
one (modules/knowledge/uncertainty.py:424-434).
Every concept carries its own audit log.
ensure_concept seeds an audit_log array, and
sense creation, upgrade, confidence change, relation add, rejection and
reactivation append to it, with old_value and
new_value where a value changed. Only concept creation and
hard deletes are mirrored to logs/concepts.jsonl
(core/semantic.py:84, :459).
The refusal is consulted before the write on the main
path. weerleg — refute — sets
status = "rejected" on a sense, a concept's every sense, or
a relation, with the old status and a timestamp in the concept's audit
log. The reasoning layer filters status != "rejected" when
choosing a sense and when disambiguating by context, while
get_senses() keeps showing it for "transparantie"
— so a person inspecting the graph sees what the reasoner refuses to
use.
reject_sense, reject_relation and
reject_concept take a reason, and none of the
three weerleg handlers passes one
(core/intent_router.py:619, :690,
:751). The two refutations in the committed graph carry no
reason.
What makes it a tombstone rather than a soft delete is the
deduplication loop in add_sense
(core/semantic.py:191). It matches an incoming definition
against existing senses by definition text, and its
first test on a match is the rejected status
(:207). A hit returns not a sense but a signal —
{"blocked": "rejected", "sense": …, "attempted_source": …}
— and the stored sense is not touched at all.
The signal is threaded up rather than swallowed.
TeachEngine.teach passes it through untouched and skips
_auto_extract_is_a on that path — "er is niets nieuws
om relaties uit te halen" (:2162).
SemanticConceptsModule.teach (:2735) holds the
chat bus and splits on the source. A non-user source —
wikipedia, auto_extract — is only reported,
never applied.
A re-assertion by the user becomes a
pending_reactivation and a typed question: "I had
already rejected '<word>' →
'<definition>'. Do you really want to confirm this
again?" Answering nee leaves the rejection standing.
Answering ja sets confirmed and writes a
sense_reactivated audit entry carrying the previous status
and "Kevin bevestigde expliciet opnieuw" (:2800).
The committed graph holds one such round trip, gitaar#1,
rejected and reactivated on 8 August 2026.
So the value-keyed refusal is durable against automatic re-derivation, and lifting it requires a person to answer a question about that value, as an audited event. That is the rejected-value tombstone built with string equality and a dict.
The limit is exactness. The match is
s.get("definition") == definition, so a paraphrase is a
different string and is not blocked. The other writers of a definition
consult the same status. upgrade_unknown_sense, which
TeachEngine.teach and the Wikipedia teacher call before
add_sense, returns the same blocked signal
(core/semantic.py:297). A wiki <word>
refresh returns before touching a rejected or a user-confirmed Wikipedia
sense. It keeps every existing relation and adds only new ones, and it
writes a wikipedia_refresh audit entry with the old
definition and status
(modules/knowledge/wikipedia_teacher.py:335,
:367).
Relations get the same refusal by a different route. Declining a
proposed relation — laat N vallen for a Wikipedia
extraction, negeer N for an embedding match — stores it as
rejected with a reason
(modules/knowledge/similarity_proposals.py:457-468).
add_relation then refuses that type and target on the
sense, and the proposal lists skip the pair.
hard_delete sits behind the refusal as a second step: it
refuses while any sense is still unrejected, so physical removal
requires the refusal first.
Licence caveat, stated because it governs what a reader may
do with the rest. LICENSE.txt is headed
"Viewable, Not Reusable" — all rights reserved, published so
others can read the project and follow how it is being built. This
report analyses mechanisms; it is not an invitation to copy code, and
the ideas below are described so they can be re-implemented rather than
lifted.
2. Mental Model
Nova has two memories that never touch. Events are what happened; concepts are what is held true. The refusal is keyed on a sense's definition text, so the diagram follows a sense.
Diagram source
%% caption: a sense's status in the concept graph; every automatic write of a definition checks the rejected status first, and only a typed yes re-admits one
stateDiagram-v2
[*] --> Placeholder: unknown word, or the plural of a known one
Placeholder: sense with definition "unknown"<br/>confidence 0.1, never offered as a meaning
[*] --> Confirmed: the user teaches a new definition
[*] --> Unverified: Wikipedia adds a sense
Placeholder --> Unverified: Wikipedia fills it
Placeholder --> Confirmed: the user types a definition<br/>blocked if that text was rejected
Unverified: status unverified<br/>used by reasoning
Confirmed: status confirmed
Unverified --> Confirmed: the user teaches the same text
Unverified --> Unverified: wiki refresh<br/>relations kept, audited
Unverified --> Rejected: weerleg
Confirmed --> Rejected: weerleg
Rejected: status rejected<br/>kept in concepts.json, filtered from reasoning
Rejected --> Rejected: the same text from Wikipedia or auto<br/>blocked and reported
Rejected --> Asking: the same text typed by the user
Asking: pending_reactivation<br/>one slot in process memory
Asking --> Rejected: nee
Asking --> Confirmed: ja, sense_reactivated audited
Rejected --> [*]: hard_delete, refused unless rejectedThe self-loop on Rejected is the design's best idea: no
automatic source can re-admit a refuted definition, and a person
re-admits one only by answering for it. That matters because
is_a_chained walks inference chains, and a wrong edge left
in place would be reasoned through confidently forever.
Placeholders come from auto_learn, which runs on nouns
in unmatched sentences, normalises a plural to an existing singular
(core/semantic.py:2085-2094) and adds an
unknown sense beside that concept's real ones. Filling a
placeholder goes through upgrade_unknown_sense, which
checks the rejected status before it writes, so a placeholder is not a
way around the refusal.
Status is monotone under automatic sources. In add_sense
an automatic match never downgrades a confirmed sense, and the Wikipedia
refresh returns early on a Wikipedia sense the user confirmed. It
touches only an unverified one, which it leaves unverified.
Relations have a smaller machine. A relation parsed from a typed
sentence waits in pending_relation until ja
and is stored confirmed — evidence before belief
in a system with no model to distrust. weerleg rejects a
relation. confirm_relation promotes an
unverified one from the uncertainty list or a proposal
list, sets source user and confidence 1.0, writes a
relation_confirmed audit entry, and refuses a rejected
relation or one under a rejected sense
(core/semantic.py:1075). Nothing lifts a rejected relation:
add_relation returns early on any existing type and target
(:998-1000). handle_confirm reads that return
and says why nothing was stored — already rejected, already known, or
failed.
3. Architecture
One process on one host. main.py builds an in-process
EventBus, loads modules from core/,
modules/ and identity/, and reads the user's
turns from a terminal prompt (main.py:796). Storage is
data/: interactions.db and
interactions.jsonl for events, concepts.json
for knowledge, and a dozen smaller JSON files for the profile, learned
patterns, chess state and classifier models.
core/ carries the infrastructure —
memory.py (948 lines), semantic.py (3,250),
intent_router.py (5,887), event_bus.py,
response_engine.py, module_loader.py.
modules/ is the surface area: knowledge, learning, weather,
chess, math, activity, preferences, context, chat, network, time.
identity/ holds personality, emotion, expression and
self-query.
Deployment and ergonomics
The core runs as a Linux daemon; the README names Docker on Unraid as
the tested platform. The Dockerfile installs Python 3.11.9
and Stockfish only; code and data are mounted as a volume, so the image
holds no memory. requirements.txt adds
python-chess, scikit-learn for the intent classifier,
MediaPipe and websockets, spaCy for sentence parsing and
sentence-transformers for the optional embedding
specialist, which caches vectors in
data/embeddings_cache.npz and switches itself off when the
package is absent. No database server, no vector store, no API key, and
no network call is required for the memory itself to work.
A Windows companion client, not in this tree, streams activity, focus
and presence labels to modules/network/client_bridge.py.
The bridge requires a shared token in the first message, compared with
hmac.compare_digest, and refuses every connection when no
token is configured (:126-134, :297-327). It
sends whitelisted open-app and open-URL commands back. None of its five
message types is a chat turn, so it cannot answer the confirmation
gate.
The cost is that it is one person's machine. There is no scope key
anywhere in memory.py or semantic.py — no
user, no tenant, no project — because the system is built for exactly
one named user, and the profile module is literally
kevin_profile.py. That is a coherent choice rather than an
oversight, and it is also what makes the design non-transferable without
a schema change.
4. Essential Implementation Paths
The confirmation gate
RelationFlowEngine holds one
pending_relation at a time and drives a short conversation.
If the subject has several real senses it asks which, by number; then
the same for the object; then it asks for confirmation in words a person
can answer:
"Mag ik onthouden dat '{subject}' {rel_text} '{obj}'?"
handle_confirm writes on
ja/yes/y, discards on
nee/no/n, and on anything else
re-asks rather than guessing. It is reached from
core/intent_router.py:5867-5869 only when the whole turn is
ja or nee and no earlier pending question
claimed it. Senses whose definition is "unknown" are
excluded from the numbered list (core/semantic.py:2506,
:2523).
The gate is the only path from the user's sentence to a stored
relation. It is not the only path to a stored relation:
_auto_extract_is_a derives an is_a from a
taught definition that matches one of its patterns and appends it
unverified with source auto_extract
(:2270), and the Wikipedia teacher appends the edges it
extracts (modules/knowledge/wikipedia_teacher.py:501).
Neither goes through add_relation, so neither writes an
audit entry.
The correction that is kept out of the ground truth
The intent classifier learns from Kevin's corrections — the "nee ik
bedoelde X" ("no, I meant X") flow — and the handling of that data is
the sharpest piece of engineering in the repository. Corrections go to
data/gecorrigeerde_voorbeelden.jsonl, deliberately
not into training_data.json.
retrain_vanuit_bestanden combines three sources — the
curated base set, the confirmed corrections, and Layer 0 examples that a
deterministic detect_*() already got right without the
classifier guessing — and the docstring is explicit about why the
combination is temporary:
"BELANGRIJK: dit schrijft GEEN van de twee aanvullende bronnen terug naar
training_data.jsonzelf — dat bestand blijft Kevin's eigen, schone basisset. De combinatie gebeurt enkel TIJDELIJK, in het geheugen, vlak vóór het trainen."
The merge happens in a local copy because retrain()
writes self.voorbeelden back to disk, so extending the
instance attribute would have silently persisted corrections into the
human-owned file. The hazard is named in a comment beside the line that
avoids it, and a try/finally restores the clean set even
when training fails.
This is a real answer to a problem most memory systems have and few separate: the difference between what a person carefully asserted and what the system inferred from being corrected. Here the curated set is a file a human owns, the derived set is a different file, and the merge exists only in RAM for the duration of a training run.
Words the classifier could not place go to
data/onbekende_correcties.jsonl as a hint list for future
categories, rather than being forced onto the nearest existing label — a
refusal to guess, recorded rather than discarded. Both files are
empty at this commit: the machinery is wired to a
four-hourly retrain in main.py (:321) and
documented as confirmed end-to-end in the changelog, but the live
corrections are not committed, so what ships is the mechanism without
the data.
Retrieval that says what it is not
memory.search is
SELECT * FROM interactions WHERE data LIKE ? over the event
JSON, newest first, with an optional recent_weeks cutoff
(core/memory.py:391). find_similar pulls the
newest 500 rows and scores them with
difflib.SequenceMatcher (:632). The intent
router calls both for memory questions
(core/intent_router.py:3771, :3790). The
docstring draws the boundary itself:
"Dit is PUUR symbolisch (difflib, standaard in Python) — GEEN ML, GEEN embeddings, GEEN betekenis-matching. 'hond' wordt dus niet gelinkt aan 'kat' met deze methode, enkel woorden die er letterlijk op lijken."
Purely symbolic; no ML, no embeddings, no meaning-matching — "dog" is not linked to "cat", only to words that literally resemble it. Stating the limit next to the method is the same discipline memU applies to its retrieval, and it matters more here, because a reader who assumed semantic recall would misread every result the function returns.
Semantic recall does exist, but it lives in the graph rather than the
index. is_a_chained, part_of_chained and
causes_chained walk relations with a visited set and return
the path, and explain_is_a renders that path as a sentence
— "Ja, een {source} is een {target}, want: hond → dier → levend
wezen." Each walk reads through get_relations, so a
rejected edge breaks the chain.
Contradiction detection, and the loop that answers it
find_contradictions checks a word's is_a
parents against three hardcoded incompatible groups —
{dier, plant, meubel, voertuig, gebouw, apparaat, voedsel},
{levend, niet-levend}, {vloeibaar, vast, gas}
— and returns a list with a readable reason per conflict
(core/semantic.py:1545).
modules/knowledge/contradiction_checker.py is its
caller: a sweep every 15 minutes of main.py's background
loop (main.py:332), walking the whole knowledge graph,
collecting conflicts, and raising them with the user through
layer4_response with a concrete
weerleg: proposal per conflict so the refusal is
one typed line away. Its module docstring says it adds no intelligence:
"Puur symbolisch: geen ML, geen generatie — roept enkel bestaande,
al-geteste reasoning-code aan".
A contradiction_state.json remembers which conflicts
have been raised, keyed on the word plus its sorted conflict list, so
the same collision produces the same key whatever order the
is_a relations were stored in. An unresolved conflict is
mentioned once, not every cycle.
That is resolve, don't just detect completed end to end: detection, a bounded proactive surface, a named resolution verb, and a state that ends in the store rather than in a conversation.
5. Memory Data Model
Two stores, different shapes.
Events. SQLite interactions with
timestamp, month, year,
event_type, data (the JSON payload) and
created_at, plus an interactions_old archive
table with the same columns, plus a JSONL mirror that rotates at 50 MB;
search and query read both tables through a
UNION ALL unless the caller passes
include_archief=False, so an event older than ninety days
is still found (core/memory.py,
tests/test_memory_archief_search.py). An
ignore_types set keeps loop-inducing types out, including
memory:interaction_added — memory listens to everything,
including itself (:49). The SQLite file,
data/backups/, the JSONL mirror and its rotated archives
are gitignored and in no commit at this pin; the mirror and two archives
were tracked until 13 October 2026.
Concepts. concepts.json, keyed by
lowercased word. hond at this commit:
"hond": {
"senses": [{
"sense_id": "hond#1",
"definition": "De hond is de gedomesticeerde ondersoort van de wolf.",
"pos": "noun", "source": "wikipedia", "confidence": 0.8,
"status": "unverified",
"relations": [
{"type": "is_a", "target": "wolf", "confidence": 0.8,
"source": "wikipedia", "status": "unverified", "created_at": "..."},
{"type": "is_a", "target": "zoogdier", "confidence": 0.9,
"source": "user", "status": "confirmed", "created_at": "..."},
{"type": "is_a", "target": "meubel", "confidence": 1.0,
"source": "user", "status": "rejected", "created_at": "..."}
],
"audit_log": []
}],
"metadata": {"created_at": "...", "updated_at": "...", "sources": ["..."],
"last_used_at": "...", "usage_count": 0,
"confidence_history": []},
"audit_log": ["concept_created", "sense_created", "relation_add",
"relation_rejected"]
}
The concept-level audit_log is shown by event type.
Every sense also carries an audit_log array, and no writer
appends to it: _audit_sense and
_audit_relation write to the concept's list
(core/semantic.py:167, :958).
Provenance sits per edge, which is the right grain — a concept
assembled from a Wikipedia definition and three user-supplied relations
records which is which. The status field is the trust
state: unverified rows are used by reasoning,
rejected rows are not. confidence_history and
usage_count give decay and reinforcement somewhere to land.
What is absent: no validity time separate from record time, and no scope
key.
The whole file is json.dumped on every save:
ConceptStore.save rewrites the entire graph for a single
confidence bump. At 299 concepts that is affordable, and the write is
atomic — a temporary file in the same directory, flush plus
os.fsync, then os.replace, with the PID in the
temporary name and the orphaned file cleaned up if the write fails
(core/semantic.py:34). An interrupted write leaves the
previous graph intact rather than truncating it. The cost is still
O(graph) per mutation; the failure mode is not.
6. Retrieval Mechanics
Three paths, none of them ranked by a model:
- Keyword —
LIKE '%term%'against the event JSON blob, newest first. This matches key names as readily as values. - Fuzzy —
SequenceMatcherover the newest 500 events,min_ratio=0.6, top 5. Catches typos; the 500-row window means older events are unreachable this way regardless of relevance. - Graph — chained relation walks with cycle protection, returning the path, over relations filtered of rejected rows and rejected senses.
The write buffer is flushed before both SQL paths so a just-written
event is searchable, and a comment notes that _flush_buffer
is deliberately called outside the caller's lock because it takes its
own.
The absence of fusion is not the usual gap here — no read path has a semantic arm to fuse with. The embedding specialist scores word pairs for the proposal list and nothing else. What a reader should take is the shape: hybrid retrieval fusion assumes an embedding arm that misses exact tokens. Nova is the other half of that trade running alone — exact and near-exact matching only, with meaning handled by an explicit graph rather than by proximity in a vector space.
7. Write Mechanics
Events arrive on the bus and land in a write buffer; an arriving
event flushes it when 50 are buffered or 5 seconds have passed since the
last flush (core/memory.py:66-67), and
append_to_disk retries up to three times. Writes do not
block the conversation; because both search paths force a flush first,
the lag is not observable through them. atexit and signal
handlers flush on shutdown.
Knowledge writes are different in kind: synchronous, one at a time,
and add_relation is called from the gate with its defaults,
source="user" and confidence=1.0, which is
what a confirmed answer is. add_relation deduplicates on
(type, target) within a sense, appends the relation, stamps
metadata.updated_at, writes an audit entry, and saves the
whole file (core/semantic.py:973).
The two automatic relation writers skip that function.
_auto_extract_is_a appends an is_a with
confidence 0.9, source auto_extract and status
unverified directly to the sense (:2270), and
the Wikipedia teacher appends its extracted edges the same way
(wikipedia_teacher.py:501). Each dedups by target against
every existing edge on the sense, rejected ones included, so a refuted
target is not appended again on that sense. Neither writes an audit
entry, so the log answers "who told me" only for edges that came through
add_relation — the gate, a proposal list, or a confirmation
from the uncertainty list.
Operational cost
No model calls, so there is no token cost and no provider latency
anywhere in the memory path. The background loop runs maintenance every
six hours: trim the RAM cache from a daytime ceiling of 5,000 events
back to 500, move events older than 90 days to
interactions_old, gzip anything past 365 days, vacuum,
rotate backups, and run a health check. The classifier retrains every
four hours.
No background pass rewrites the store's contents — archival moves rows between tables and compression moves them to files, and the contradiction sweep only reads. That is why cost does not scale with corpus size, and also why the graph never improves on its own.
8. Agent Integration
There is no agent framework here and no tool protocol. The
integration surface is the EventBus: modules publish and
subscribe, MemoryModule.on_event records everything not in
ignore_types, and intent_router.py decides
what a message meant and which module answers. chat_message
has one publisher, the terminal loop (main.py:873).
The unusual integration is temporal. Nova runs continuously and
decides on her own initiative whether now is a reasonable
moment to speak, using interruption_tracker.py against a
learned interruption_patterns.json of when interruptions
were previously tolerated. Activity and presence arrive from the Windows
client through the bridge. Memory feeds that decision rather than only
answering questions with it — prospective memory driven by a person's
habits rather than a schedule.
9. Reliability, Safety, and Trust
Strengths:
- An audit log on the concept itself, appended on create, upgrade, confidence change, relation add, rejection and reactivation, with old and new values.
- A confirmation gate as the only path from the user's sentence to a stored relation, with sense disambiguation asked first.
- A review queue for what Nova is unsure of — the uncertainty list numbers unverified relations and confirms or rejects one per typed answer, and the two proposal lists store nothing until a person picks.
- Per-edge provenance, confidence and status in the schema, with real, differing sources in the committed data.
- Corrections quarantined from the curated training
set, the merge held in a local copy, restored under
try/finallyeven when training fails. - A refusal to guess, recorded — unplaceable correction words go to a hint file instead of being forced onto the nearest label.
- Explainable inference — chained walks that return the path, and helpers that render it as a sentence a person can check.
- A real operational lifecycle — buffered writes with retry, RAM trimming, archival, compression, vacuum, backup rotation, health check, shutdown flush, and an atomic graph save.
Gaps:
- Two automatic relation writers stay unaudited.
_auto_extract_is_aand the Wikipedia teacher's first store append directly to the sense. - A rejection only a person can make. Every path to
rejectedruns throughweerleg; nothing lets the system refuse its own bad inference, so a contradiction sits until someone answers the prompt. - A refutation with no reason. The three
weerleghandlers pass none (core/intent_router.py:619,:690,:751); the proposal lists and the uncertainty list do. - The whole graph is rewritten on every save, though atomically.
tombstone— earned, by design and pinned by tests. The record is keyed on the definition text; the reasoner filters it; andadd_sense's dedup loop tests the rejected status before anything else, returning ablockedsignal and leaving the stored sense untouched.upgrade_unknown_senseand the Wikipedia refresh check it too, and a declined proposal is stored rejected.negative_eval— earned, with a positive control.test_part_of_chained_na_weerlegging_is_falseasserts that a refuted intermediate link removes a reasoning path, andtest_get_relations_negeert_rejected_relatieasserts a refutedis_ais absent fromget_relationswhile remaining inconcepts.json. Each first asserts that the same query succeeds on the populated graph, so a pass cannot come from retrieval being broken.trust_state— earned.unverified,confirmedandrejectedon every sense and relation, set fromsourceat write time, andrejectedexcluded byget_best_definition,detect_sense,get_relationsandget_all_relations. A person moves a relation fromunverifiedtoconfirmed, and no path moves one out ofrejected.- No scope key, by design, which makes the schema single-user rather than merely deployed that way.
The asymmetry in forgetting
Nova can forget two kinds of thing, with verbs that differ in kind.
For preferences, vergeet: <woord> calls
beeindig_preference, which moves every current block for
the word into a historiek with a valid_until
and removes it from the current set
(modules/preferences/kevin_profile.py:450).
wis definitief erases the word and its history. A change of
sentiment within one source closes the old block into the history
instead of overwriting it.
The commit calls this bi-temporal, and the dates are the dates of
recording. add_preference sets valid_from to
today on a new or changed block, and the one product caller of
beeindig_preference passes no datum, so
valid_until is today too
(core/intent_router.py:309). Validity time is not separate
from record time, so bitemporal is withheld; a history of
when a taste was recorded and when it was ended is more than most
preference stores keep.
weerleg applies to what Nova knows. It leaves the
refuted knowledge in the file where a person can still see it while the
reasoner cannot use it, so the record that a belief was held and refuted
survives.
10. Tests, Evals, and Benchmarks
tests/ holds 146 test_*.py files carrying
2,520 test functions and 4,171 assert lines, and they
are pytest cases that fail rather than exploration scripts whose output
a person reads. Twelve print-only scripts sit apart in
tests/manual tests/. conftest.py in the
repository root makes pytest treat the root as rootdir; it
runs at collection, before any test. I ran none of them.
The memory-relevant suites are the ones to read.
test_tombstone.py holds the refusal in seven cases — a
reasoning chain before and after a refuted intermediate link, a refuted
relation kept in concepts.json, get_relations
ignoring a refuted relation and every relation under a refuted sense,
and the re-assertion path through add_sense from a user and
from a non-user source. Each isolates itself in tmp_path
with its own ConceptStore, and the module docstring says
why it drives SenseEngine/RelationEngine
directly rather than SemanticConceptsModule: the latter
always constructs a store at the default path, which is the author's
real concepts.json.
The rejected-sense case asserts an empty list with no positive
assertion before the rejection; the two relation cases carry one.
test_reactivatie_flow.py drives the typed reactivation
question and the Wikipedia teacher's rejected-sense guard.
test_hard_delete_en_atomic_save.py covers
hard_delete's refusal while any sense is unrejected, and
that an interrupted save leaves the previous graph
intact.
test_bugs_47_48_49.py covers the three paths that once
went around the refusal or misreported it:
test_47_upgrade_unknown_sense_blokkeert_weerlegde_definitieand the Wikipedia variant assert that a placeholder is not filled with a refuted definition, beside controls asserting that a different definition still upgrades it.test_48_refresh_behoudt_weerlegde_en_bevestigde_relatiesandtest_48_refresh_schrijft_audit_entryassert that a refresh keeps rejected and confirmed relations and is audited.test_49_weerlegde_relatie_wordt_eerlijk_gemeld_en_blijft_weerlegddrives the realhandle_confirmon a refuted edge.
test_retrain_vanuit_bestanden_laat_training_data_json_schoon
asserts that a retrain leaves the curated example count unchanged and
writes no Layer 0 example into training_data.json. That is
the quarantine's invariant by count and by marker, not byte
identity.
A small answer benchmark is committed with its run
log. tools/run_benchmark.py copies the project to
a temporary directory, restores concepts.json before every
question, compares a hash of the real graph before and after, and checks
the routed events and the answer text.
tests/benchmark/benchmark_cases.json has 29 fixed cases in
11 categories. Three are afgewezen_feit: asked whether a
dog is furniture, an is_a the graph holds as rejected, the
answer must not contain ja.
data/benchmark_resultaten.jsonl records the history: those
three failed at the answer level on 26 October and pass from 29 October,
and the fixed set reached 29 of 29 on 30 October after changes made
against it. A ten-case ongezien (unseen) set scored 8 of 10 and
then 10 of 10 seven minutes later, so it is no longer unseen. The check
is whole-word matching on Dutch answer text, and the run is the
author's, not mine.
README.md cites no paper and there is no
CITATION.cff. The changelog records live end-to-end
confirmations with dates and documents bugs found while building —
including one where an over-broad search-and-replace deleted an entire
method, caught by a local harness before the file shipped.
11. For Your Own Build
Steal
- Gate the write on a typed confirmation. "May I remember that X is a kind of Y?" is a better governance surface than a review queue nobody opens, and it costs one turn at exactly the moment the user has the context to answer.
- Quarantine corrections from curated ground truth. Keep the human-authored set in its own file, keep derived corrections in another, and merge only in memory for the duration of a training run — with the reason written beside the local copy that makes it safe.
- Record the inputs you refused to classify instead of forcing them onto the nearest existing label. The hint file is how the next category gets discovered.
- Put the audit log on the record, not only in a side channel, so exporting one concept exports its history — the placement NOOA Memory argues for from the other direction.
- Return the inference path, not just the answer. Turning a graph walk into a sentence a person can check is what makes a symbolic store auditable in a way a similarity score is not.
- Make the refusal a return value the caller has to
handle. The
blockedsignal forces each layer to decide between reporting and asking.
Avoid
- A refusal on one write path when another runs
first. Nova's rejected check lived in
add_sensewhile the placeholder upgrade before it never asked, and a Wikipedia refresh replaced relation lists wholesale. The project closed each with its own fix and test (bugs 32, 47 and 48). Put the check where every writer of that value passes, and the next writer cannot forget it. - A confirmation that cannot tell a write from a
no-op. Announcing "remembered" when the store returned
Falseteaches the user that the gate worked when nothing changed; bug 49 was exactly that. - Calling record dates validity dates. A history
whose
valid_fromis the day it was written answers "since when have I known", not "since when was it true". - A reason parameter the main verb never fills. A refutation without a reason is half of what a person reviewing it later needs.
Fit
Read this if you are building symbolic or graph memory without a model, or if you want to see what correction looks like when there is no LLM to blame. The confirmation gate, the typed re-admission and the training-set quarantine were each decided explicitly, because there was no model to delegate them to.
Do not read it as a component. The licence forbids reuse, the schema has no scope key, and the store is a single JSON file rewritten whole. It is a well-kept notebook of one person's design decisions, and its value is the decisions — of which the strongest is that a refusal should be keyed on what was said, not on the row that said it. The gaps are the other lesson: a refusal holds only on the paths that consult it, and each new writer is a new path.
12. Open Questions
- Does the exact-text refusal need a looser key? A paraphrase of a
refuted definition is a different string, and a store with no embedding
has no cheap way to close that —
difflib.SequenceMatcher, whichfind_similarincore/memory.pyuses, would be the obvious candidate. - Will
vergeetever take a date?beeindig_preferenceaccepts one and no caller passes it, which is the one change that would make the preference history bi-temporal. - Does the embedding specialist's similarity score reach anything beyond the proposal list? At this commit it does not, and an answer-time use would be the first model judgement on a read path.
- Are
gecorrigeerde_voorbeelden.jsonlandonbekende_correcties.jsonlempty because they are runtime-only, or because the flow rarely fires? - What would
concepts.jsonneed for a second user, given no scope key exists?
Appendix: File Index
- Event memory:
core/memory.py(MemoryModule,on_event,_flush_buffer,_rotate_if_needed,search,query,find_similar,archive_old_events,compress_ancient_events,run_maintenance,health_check). - Knowledge:
core/semantic.py(ConceptStore.save,_append_audit,SenseEngine.add_sense,upgrade_unknown_sense,reject_sense,hard_delete_concept,RelationEngine.add_relation,reject_relation,confirm_relation,get_relations,ReasoningEngine.find_contradictions,is_a_chained,explain_is_a,TeachEngine.teach,_auto_extract_is_a,auto_learn,RelationFlowEngine.start_relation_flow,handle_confirm,SemanticConceptsModule.teach,handle_reactivation_confirm). - Automatic knowledge writers:
modules/knowledge/wikipedia_teacher.py(_teach_word),modules/chat/response_pipeline.py(_auto_learn_from_sentence). - Proposals and review:
modules/knowledge/uncertainty.py,knowledge_proposals.py,knowledge_extractor.py(spaCy),similarity_proposals.py,embedding_specialist.py(MiniLM). - Refutation commands:
core/intent_router.py(handle_weerleg). - Contradictions:
modules/knowledge/contradiction_checker.py. - Corrections and learning:
modules/learning/intent_classifier.py(retrain_vanuit_bestanden,_laad_gecorrigeerde_voorbeelden),modules/learning/pattern_matcher.py,modules/learning/word_associations_learner.py. - Preferences and their forget path:
modules/preferences/kevin_profile.py(add_preference,beeindig_preference,wis_definitief,get_historiek). - Routing and integration:
core/intent_router.py,core/event_bus.py,core/response_engine.py,main.py,modules/network/client_bridge.py. - Timing:
core/interruption_tracker.py,modules/activity/session_watcher.py. - Unwired:
core/llm_bridge.py(Ollama client, called only fromtests/manual tests/manual_llm_bridge.py). - Tests:
tests/test_tombstone.py,tests/test_reactivatie_flow.py,tests/test_hard_delete_en_atomic_save.py,tests/test_bugs_47_48_49.py,tests/test_kevin_profile_historiek.py,tests/test_memory_archief_search.py. - Benchmark:
tools/run_benchmark.py,tests/benchmark/,data/benchmark_resultaten.jsonl. - Committed state:
data/concepts.json(1,234 concepts),logs/concepts.jsonl,data/training_data.json,data/gecorrigeerde_voorbeelden.jsonl(empty),data/onbekende_correcties.jsonl(empty). - Deployment:
Dockerfile,requirements.txt. - Licence:
LICENSE.txt("Viewable, Not Reusable").
Recorded searches
Run from the repository root at the pinned commit.
git grep -n -E 'llm_bridge|genereer_zin' -- core modules identity main.py ':!core/llm_bridge.py' # nothing: the Ollama client has no caller
git grep -l -i -E 'embedding|sentence_transformers' -- core # core/intent_router.py (routing to the proposal list) and core/memory.py (a docstring saying search has none)
git grep -n -E 'user_id|tenant|scope' -- core/memory.py core/semantic.py # nothing: no scope key
git grep -n -E 'publish\("chat_message"' -- '*.py' ':!tests' # main.py:873 only
git grep -n -E 'reject_(sense|relation|concept)\(' -- core/intent_router.py # three calls, none passing reason
git grep -n -E 'unverified' -- '*.py' ':!tests' ':!core/semantic.py' ':!scripts' # wikipedia_teacher.py writes it; nothing reads it
git grep -n -E 'rel\["status"\] *=' -- '*.py' ':!tests' ':!scripts' # core/semantic.py:1057 (reject) and :1100 (confirm)
git grep -n -E 'relations"?\]?\)?\.append\(|setdefault\("relations"' -- core modules # writers at :1018, :2270 and wikipedia_teacher.py:501, plus :253 building a list; only :1018 is followed by an audit entry
git grep -n -E '_write_log\(|_append_audit\(' -- '*.py' # mirror writers: concept creation and the three hard deletes
git grep -n -E 'setdefault\("audit_log"' -- '*.py' ':!tests' # six appends, each to the concept's list; none to a sense's
git grep -n -E '\.(reject_sense|reject_relation|reject_concept)\(' -- '*.py' ':!tests' # the three weerleg handlers and their wrappers, plus the two proposal lists and the uncertainty list, each passing a reason
git grep -n -E 'beeindig_preference\(' -- '*.py' ':!tests' # core/intent_router.py:309, passing no datum
git grep -n -E 'bericht_type ==' -- modules/network/client_bridge.py # five message types, none a chat turn
git grep -n -i -E 'arxiv|bibtex|@article|@misc|doi\.org' -- README.md # nothing: no paper
git log --format=%h origin/main -- data/interactions.db data/backups # nothing: no commit on main holds the database
History
2026-11-02 — 1211abfc…
— 71 commits on, no history rewrite. All five marks hold, and the
published gaps are closed upstream. The placeholder upgrade checks the
rejected status (bug 47). A Wikipedia refresh keeps relations, spares a
confirmed sense and is audited (17 October and bug 48).
handle_confirm reports a refused edge honestly (bug 49),
and all three are tested. New: an uncertainty list and two proposal
lists, spaCy and MiniLM, through which a person confirms or declines
relations, a decline being stored rejected (section 1); a token-authenticated
bridge; a 29-case answer benchmark with its run log (section 10). Preferences
gained valid_from/valid_until set to record
dates, so bitemporal stays withheld (section 9). Screened: no
auto-run surface, three build-time points; nothing run.
2026-09-28 — re-pinned to 802a3218….
The previous pin left main because its history was
rewritten once: every commit after 67b784f7…
(1 July 2026) was replaced by one whose tree lacks only
data/interactions.db and data/backups/, 154
with their subjects and author dates and one emptied commit dropped. The
pin's twin is 4940cff1…;
the four dated archive branches are successive heads of the old line,
not further rewrites. Fifteen later commits leave
core/semantic.py and core/memory.py untouched.
Marks unchanged. The refusal's two bypasses, the typed gate and the
unfilled reason are in section
2. Screened: no auto-run surface, two build-time execution points,
twelve unpinned requirements. Nothing installed, built or run.
2026-09-19 — re-pinned to da6b9161….
All five marks stand. human_review's record had one file
and no line numbers behind it and now carries both, plus the producer
test: handle_confirm (core/semantic.py:2052)
has exactly one caller, core/intent_router.py:4253, reached
only when the transcribed turn is literally ja or
nee and nothing else holds the floor; the re-admission
question and handle_reactivation_confirm are at
:2159-2168 and :2183. What settles it is the
absence rather than a check: core/ contains no MCP server,
tool-call dispatcher or function-call surface, so the only channel an
answer can arrive on is the user speaking. That is the same shape as
parsing an adjudication out of the user's own message, and it is the
strongest form this mark takes. The licence caveat in section 1 is
unchanged and still governs what a reader may do with the rest. Screened
again first; nothing was installed and no suite was run.
2026-09-16 — 4ad85507…
— re-read at a commit dated 16 September 2026. The previous pin could
not be compared against this one: the GitHub comparison refuses with a
422 because 5d989252 is no longer an ancestor of
main, so the branch was rewritten rather than advanced —
and the head moved again between two requests a minute apart, so the
rewriting is ongoing. The pinned commit itself survives, both upstream
by sha and in the atlas's archive fork, so the previous reading remains
checkable. Against the new head the two anchored files show a 2,741-line
diff that is not a change: git diff -w --ignore-cr-at-eol
between the pins is empty, and core/semantic.py went from
zero carriage returns to 2,496, so the whole difference is a conversion
from LF to CRLF. The tombstone mechanism and its test are therefore
identical in content, and all five marks hold verbatim. Screened before
reading, from a full clone: no auto-run surface, two build-time
execution points, one unpinned dependency surface and one dependency
file inside the seven-day cooldown. Nothing was installed, built or
run.
2026-09-07 — 5d989252…
— re-pinned eight commits on. On the memory path,
MemoryModule.search and query read the
interactions_old archive by default with an
include_archief opt-out, and get_stats counts
it (core/memory.py, ten cases in
tests/test_memory_archief_search.py); a
core/last_context.py module tracks the last concept a
conversation resolved, the intent router grew by 476 lines, and a
response-variant learner with a feedback log was added under
modules/response_learning/. The committed state moved with
use — interactions.db, the word-association map, the intent
classifier. The concept graph, its statuses, tombstones and audit fields
did not change; five marks stand on the same evidence. Screened before
reading: no auto-run surface, two build-time execution points, nothing
installed or run.
2026-08-15 — 924f91ac…
— 11 commits on. Two published criticisms closed and the tombstone's
provenance reversed: what this report described as a property held by an
omission is a designed, tested, human-gated refusal.
Bug #32 (core/semantic.py:204, dated 8 August 2026 in
the source comment) puts the rejected-status test first in
add_sense's dedup loop. A definition matching a refuted
sense returns
{"blocked": "rejected", "sense": …, "attempted_source": …}
and leaves the stored sense untouched, where before it fell through to
the status branch — which meant a user re-assertion
silently set confirmed and lifted the refusal. The signal
is threaded through TeachEngine.teach (:1634,
which also skips _auto_extract_is_a on that path) to
SemanticConceptsModule.teach (:2154), where a
non-user source is reported only and a user re-assertion becomes a
pending_reactivation and a spoken yes/no. A ja
writes a sense_reactivated audit entry carrying the old
status; a nee leaves the rejection standing.
negative_eval is earned.
tests/test_tombstone.py asserts that a refuted intermediate
link removes a part_of_chained path, that
get_relations returns neither a refuted relation nor
anything under a refuted sense, and that a re-asserted rejected
definition changes no status and creates no second sense. Each negative
case is paired with an assertion that the same query succeeds before the
refutation.
ConceptStore.save is atomic (:31):
temporary file in the same directory, flush plus
os.fsync, os.replace, PID in the temporary
name, orphan cleaned up on failure. At the previous pin it was a bare
open(…, "w") on concepts.json itself.
The test suite is the other reversal: 21 files carrying 3 test functions and 4 assertions at the previous pin, 27 files carrying 272 test functions and 491 assertions here.
Unchanged: the licence is still "Viewable, Not Reusable", all rights reserved; there is still no scope key; and the refusal still matches definition text exactly, so a paraphrase evades it.
Nothing was run. Screening found no auto-run surface, two build-time
execution surfaces (conftest.py, a vendored Stockfish
Makefile that is new since the previous pin) and ten
unpinned requirements.
2026-08-06 — c4c000b1…
— 5 commits on. Four published criticisms went stale together, all
closed by the project, and the report is rewritten around what replaced
them.
weerleg sets status = "rejected" on a
sense, a whole concept's senses or a relation, with reason and timestamp
in the record's own audit log; the reasoning query and the
disambiguation candidate list both filter it out while
get_senses() still shows it. add_sense's dedup
loop matches an incoming definition without exempting rejected rows, and
its status branch promotes only on source == "user" — so
wikipedia_teacher and auto_extract re-deriving
a refuted definition land on the refusal and cannot lift it.
tombstone earned, keyed on the value.
hard_delete refuses while any sense is unrejected, so
physical removal requires the refusal first.
trust_state earned: unverified,
confirmed, rejected as a field on senses and
relations, written from source, with
scripts/migrate_trust_state.py backfilling existing rows —
dry-run by default, timestamped backup before --apply,
re-read and revalidated after writing, idempotent.
find_contradictions has a caller.
modules/knowledge/contradiction_checker.py sweeps the graph
on the background loop, raises each conflict with the user through
layer4_response with a concrete weerleg:
proposal, and keeps a contradiction_state.json keyed on the
word plus its sorted conflict list so a standing conflict is raised once
rather than every cycle.
Unchanged: the licence is still "Viewable, Not Reusable",
all rights reserved; 21 test files still carry five assertions between
them, so none of the above is pinned by a test; and
ConceptStore.save still rewrites the whole graph
non-atomically.
Nothing was run — requirements.txt changed three days
before this reading, inside the cooldown.
2026-08-04 — 4a7d89b9…
— first reading.