Files
improvise/context/code/kb-tool.md
T
Edward Langley a55b3c7147 kb: refuse a write that leaves the kb short of HEAD; publish by rename
scripts/kb add wrote onto a working-tree kb.pl that had lost 276 of its 801
clauses and reported success, because kb_consistent checks consistency, not
completeness (improvise-usus). Every write now runs kb-merge.pl `complete`
on the candidate before kb_consistent. It refuses unless every clause HEAD
has is still present by key, or its removal was recorded by `kb rm` in a
per-worktree ledger in the git dir, which prunes itself on every write.

The candidate is now built beside kb.pl, so publishing it is a rename(2)
rather than a possible cross-device copy that a kill could truncate.

Exit codes: 0 done, 1 refused, 2 usage, 3 HEAD's kb.pl unreadable. kb-test.sh
now builds its fixtures from HEAD and adds cases 18-22. Argument in
context/code/kb-tool.md, evidence and rivals in context/test-evidence/tooling.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 20:46:03 -07:00

160 lines
8.5 KiB
Markdown

# Maintaining the knowledge base
`prolog-sessions/kb.pl` is executable knowledge, checked by CI. This is the
argument for how it is written, and why the Prolog MCP stopped being the way.
## Why the MCP was retired
`loadProgram` **replaces a whole predicate**, so adding one clause means
re-sending every existing clause of it through an agent's context window — and a
short payload silently drops the rest. Three further blockers: `saveSession`
re-adds the MCP's own `host_rpc` shims and reserializes the whole file, and
`assertz` raises `permission_error(modify, static_procedure)` against a
consulted predicate, so there is no incremental route through it at all.
## What replaced it
`scripts/kb`, a shell entry point over `scripts/kb-merge.pl`.
**Clauses are read as DATA, never consulted.** `read_term/3` in a loop yields a
list of terms; merging is list manipulation; `portray_clause/1` writes them
back. Nothing is ever asserted, so the static-procedure wall is not reached and
the cost of adding a clause is the clause.
**Canonical form is `read_term` in, `portray_clause` out.** This was not
designed, it was measured: all 495 facts in the existing file — including every
escaped quote — round-trip byte-identically, and the form is a fixed point in
one pass. The only change on first use is that rules move from the MCP's
two-line layout to one line, which is why the reformat lands with this tool
rather than before it: the MCP would re-expand them on its next save and the two
would thrash.
**trealla comes from the dev shell.** Reaching it through `nix shell` per
invocation cost more than the work: the tool's first real use timed out past two
minutes on a write that takes 0.42 seconds with `tpl` on `PATH`.
## The guards, and why each exists
Every one of these was added because injecting its fault showed the tool
accepting something it should refuse. None was found by reading.
**The round-trip check.** After writing, the file is read back and required to
be the clauses intended. A serializer bug is the wipe-the-knowledge-base failure
with a new owner, and this is the only check that sees it. Note it compares
*variant* equality via `numbervars` over a copy — reading a term back yields
fresh variables, so `==` compares variable identity and reports every rule as a
difference.
**`kb_consistent` runs on the CANDIDATE.** The file is replaced only after the
check passes, so a delta that breaks the knowledge base leaves it untouched
rather than requiring a revert.
**Parse errors are caught explicitly.** An uncaught syntax error drops trealla
into its REPL, which *hangs* with a terminal and *exits 0* without one — a
refusal that reads as acceptance. `scripts/kb` also closes stdin on every call
as a backstop.
**Every run reaches a `halt/1`.** `tpl` exits 0 unless a halt is *reached*:
measured on 3.10.3, `-g "fail"` and `-g "throw(boom)"` both exit 0, and so do
`fail,halt` and `throw(boom),halt`, because a goal that fails or throws never
reaches the halt beside it. `halt(2)` alone exits 2, so halt works — the default
is the hazard. The goal used is therefore
```
(catch(main,E,(write(user_error,E),nl(user_error),halt(2))) -> halt(0) ; halt(1))
```
which gives 0, 2 and 1 for success, throw and failure. Without it, `if ! merge
...` reads a crashed merge as a clean one and replaces the knowledge base.
**Terms must be callable.** `42.` parses, is not a clause, and reached the
knowledge base as a fact whose head is a number. `kb_consistent` has no opinion
about stray terms, so nothing objected.
**A clause can be retired.** `kb rm` takes keys and refuses if any of them is
absent, because a typo'd key would otherwise report success and leave the clause
in place. Without it no correction could ever drop a fact — the first thing it
was used for was retiring `kb_is_maintained_through_the_mcp`, whose *name* was
the claim that went false.
**Duplicate keys are refused by default.** A key is the functor plus every
argument but the last, so `doctrine(Name,_)` keys on the name and
`decision(Id,Key,_)` on the pair. `replace` is the door a correction goes
through, and it overwrites in place — a count that grew would be two clauses
under one name.
**A write may not end up short of HEAD** (improvise-usus). `kb add` wrote four
facts onto a working-tree `kb.pl` that had already lost 276 of its 801, and
reported success: `kb_consistent` asks whether the facts are *consistent*, never
whether they are all *there*, and a knowledge base missing a prefix of a
predicate is perfectly consistent. `kb-merge` printed `529 before` beside a last
known 805 and nothing compared them. The cause was never settled between a
`kb replace` killed mid-write and git being moved under the session, so the
guard had to catch both, and only a comparison against something *outside* the
working tree can: a guard that compares the file against its own pre-write
count passes a file that was truncated before the write began, which is exactly
what happened. So every write runs `kb-merge.pl complete` on the candidate,
before `kb_consistent`, against `git show HEAD:prolog-sessions/kb.pl`, and
refuses unless every clause HEAD has is still present **by key**. It runs first
so that the refusal names the loss rather than whatever consistency error the
loss happens to cause.
It compares keys rather than counts, although the ticket asked for a count,
because a count can be masked: uncommitted adds in one predicate offset a
truncation in another, and the count comes out even. Keys cost the same (0.26s
for a write, most of it trealla start-up) and the message still names both
totals and the number missing.
**How the count legitimately falls.** `add` never lowers it and `replace`
overwrites in place, so neither can trip the guard. `kb rm` is the only
intended removal, and it records the keys it removed in a **ledger** that later
writes read: a clause missing from the working tree is excused only if its key
is in the ledger. The ledger lives in the git dir (`git rev-parse --git-path
kb-removed.pl`), so it is per-worktree and can never be committed; with
`KB_FILE` set it sits beside that file. It prunes itself on every write, keeping
only keys HEAD still has and the candidate does not, so an entry drops once the
removal is committed, and once the key is re-added. That second case matters:
without it, a re-added clause that was lost again would still be excused. There
is deliberately no override flag. The way out of a refusal is to restore
`kb.pl` from HEAD and re-apply, or to commit the removal so HEAD moves.
**The write is a rename.** The candidate used to come from `mktemp` in
`$TMPDIR`, and `mv` from there onto the repository crosses a device wherever
`/tmp` is its own filesystem. That makes `mv` a copy and an unlink, so a kill
partway through leaves a truncated `kb.pl`. The candidate is now a sibling of
`kb.pl` (`kb.pl.cand.XXXXXX`), so publishing it is `rename(2)`. The ledger is
renamed first. A kill between the two renames then leaves a ledger naming keys
the kb still has, and the next write prunes those entries. Until it does, they
would excuse the loss of exactly those clauses, which is a narrower hole than a
kb.pl that is already short.
**Exit codes.** 0 done, 1 refused, 2 usage, 3 HEAD's `kb.pl` could not be read,
127 no `tpl`. Only 1 is a verdict. 3 is separate because "could not compare"
is not "compared and refused" (improvise-b4ks). A pre-existing wart survives:
failing to acquire the lock exits 1 too.
## What no test here reaches
The completeness guard trusts HEAD. A loss that has been **committed** is
invisible to it by construction. That was the right trade for this incident,
where HEAD held all 801, but a bad commit of `kb.pl` becomes the new floor.
A `SIGKILL` leaves its `kb.pl.cand.*` and `kb.pl.lock` behind, because no trap
runs. The lock already had that property. The stray candidate is new: it is
never read, but it shows as untracked in `prolog-sessions/`, where a `git add
-A` would pick it up.
The key rule is looser than `functional/1` for `crate/3`, which keys on the
first argument alone; `kb_consistent` is the backstop for that case rather than
the merge.
A clause name cited *inside another clause's string* is unchecked — dropping or
renaming a clause silently dangles every clause referring to it
(`improvise-2f97`).
A shebang would let `kb-merge.pl` run directly and collapse the two files into
one. It does not work on trealla 3.10.3: `#!` is a syntax error that aborts the
consult, so `main/0` is never defined, and `tpl` wants `-- args` *after* the
file, which a shebang cannot inject. Worth re-testing if trealla gains script
support.