Files
improvise/context/code/kb-tool.md
T
Edward Langley 4ca3a47b41 kb: refusal no longer offers committing the loss; skill and kb-tool.md describe the guard (improvise-za97)
The kb skill (both copies) now describes the HEAD comparison, the ledger,
exit code 3 and the recovery. kb-tool.md records the ih3k/yea8 arguments and
corrects the residue paragraph: .gitignore hides the leftovers, and there are
six, not one.

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

236 lines
13 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 totals, although the ticket asked for a count,
because a total 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.
**Keys are counted, not collected** (improvise-ih3k). The guard first compared
the *set* of keys, and a set cannot see one of two clauses sharing a key.
`work_done/3`, `decision/3` and `work_followup/3` key on two arguments and are
not `functional/1`, so `kb_consistent` accepts two clauses under one key. `kb
add` refuses to *write* such a pair, but a git merge is not a `kb add`: two
branches that each record `work_done(T, bug_fix, …)` conflict at the end of the
block, and keep-both is the natural resolution. Once HEAD holds a pair, losing
either clause left the key present and the write was accepted, with both totals
printed equal. So the guard counts clauses per key, and a key is short when the
result holds fewer than HEAD does. The refusal names the HEAD clauses the result
holds no copy of, which is the one that went.
Two alternatives were rejected. Comparing whole clauses would refuse every
`replace`, whose purpose is to change the text under a key. Making the three
predicates functional would refuse the pair itself at `kb_consistent`. That
would force a merge to resolve it, and may be worth doing, but it is a change to
the gate in `kb.pl`, not to this tool. `functional/1` also keys an arity-3
predicate on its first argument, which is wrong for `decision/3`, so it would
need a new two-argument form. And the tool must not lose a clause whatever the
gate allows, so counting is needed either way.
A pair also had no way out through the tool. `rm` checked its result against
`before - clauses in the delta`, which assumes one clause per key, so removing a
paired key was refused as "a clause was dropped". The expected total is now
`before - clauses the delta's keys match`, which is still counted separately
from the fold that removes them. `replace` of a paired key is still refused by
its total. That is the right answer, because it would silently collapse two
clauses into one. The route for a pair is `kb rm` the key, then `kb add` the one
clause to keep.
**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 key the result is short of is excused only if the ledger names
it. 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. Every write rewrites it, keeping only
the keys the result is still short of, so an entry drops once the removal is
committed and once the key is back to HEAD's count. 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 your
deltas. Do **not** commit the short `kb.pl` to get past it. The refusal
message used to offer that, and taken on a loss nobody intended it makes the
loss permanent: the committed file becomes the floor this guard compares
against, and nothing ever reports it again. A removal you intend goes through
`kb rm`, onto a restored file.
**A ledger speaks only for the file its write published** (improvise-yea8).
Pruning happens only when the tool writes. A commit, a `git checkout --
prolog-sessions/kb.pl`, a `cp`, and a branch switch are not writes. So an
entry from an `rm` that was later abandoned would excuse a later loss of that
same clause by any path. That is easy to reach, because the recommended
recovery is exactly a restore. Pruning against the kb read at the next write
cannot close it, because that kb has already lost the clause. So the ledger's
first line is a `% kb-stamp` comment naming the kb file the write published,
and a later write reads the entries only if the kb it finds is still that file.
The stamp is the content hash **and** the file's inode and nanosecond mtime.
Content alone is not enough, and the test that showed it was the ticket's own
reproduction. `kb.pl` is kept in canonical form, so `grep -v` of one clause
from HEAD produces output **byte-identical** to what `kb rm` of that clause
writes. After `rm X`, a restore, and then the same loss by hand, a content-only
stamp still matched. `rename(2)` carries the candidate's inode and mtime onto
`kb.pl`. Anything else that rewrites the file, whether a `cp` onto it, a git
checkout or an editor, gives a new mtime and usually a new inode. `stat -c` is
tried before `stat -f` because GNU `stat -f` means *filesystem* status and
would silently stamp the wrong thing. It is not keyed by HEAD commit, because a
commit that does not touch `kb.pl` does not make the ledger wrong. And an
uncommitted `rm` carried across a branch switch, with `kb.pl` untouched, is
still exactly the kb it describes.
The cost is a false refusal after an intended `rm` followed by anything that
rewrites the file without the tool. That refusal is loud, and the recovery is
the usual one: restore and redo the `rm`.
**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 leaves a ledger stamped for a
candidate that was never published. It therefore matches nothing, and the next
write ignores it. That can refuse a write an earlier `rm` should have excused.
It cannot excuse a loss, which was the hole before the stamp.
**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 six files behind, because no trap runs: the candidate
`kb.pl.cand.XXXXXX`, its `.base`, `.ledger`, `.removed` and `.stamped`
siblings, and the `kb.pl.lock` directory. None of them is ever read again,
and **git does not see them**. `.gitignore` ignores `prolog-sessions/*`, so
neither `git status --untracked-files=all` nor `git add -A` lists them. That
was measured, and an earlier version of this paragraph warned about `git add
-A` staging them, which does not happen. The one that matters is the lock: the
next write waits 30 seconds for it and then exits 1 with "remove it if stale".
Delete it and the candidates by hand.
The ledger's stamp names a file by inode and nanosecond mtime. A rewrite that
preserves both, such as a `touch -r`, a filesystem with coarse timestamps
rewriting within one tick, or an inode reused at the same mtime, with the bytes
also identical to what the tool published, is indistinguishable from the file
the tool wrote. That takes deliberate effort or a filesystem this repository
does not run on.
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.