Files
improvise/scripts/kb
T
Edward Langley 3405fc4def kb: retire the Prolog MCP for a tool that costs the clause, not the predicate
loadProgram REPLACES a whole predicate, so adding one clause meant re-sending
every existing clause of it through an agent's context window. Measured on the
wave-3 batch: ~113KB read and ~113KB written to deliver ~25KB of new text. That
sets the behaviour -- writing becomes a thing you batch and do rarely -- and the
batch is where a short payload silently drops the rest of a predicate, which has
cost this repo 22 decisions and 19 doctrines once already.

scripts/kb reads clauses as DATA and never consults them, so the cost of adding
a clause is the clause. Canonical form is read_term in, portray_clause out --
measured, not designed: all 495 existing facts round-trip byte-identically and
the form is a fixed point in one pass. Rules move to one line, which is why the
reformat lands here rather than earlier: the MCP would re-expand them on its
next save and the two would thrash.

trealla moves into the devShell. Reaching it through `nix shell` per invocation
took the tool's first real use past a two-minute timeout for a write that takes
0.42s with tpl on PATH.

Four guards, each added because injecting its fault showed the tool accepting
what it should refuse. None was found by reading:

- an unparseable delta dropped trealla into its REPL, which HANGS with a
  terminal and EXITS 0 without one -- a refusal that reads as acceptance
- `-g main,halt` exits 0 on a throw AND on a failure, because a goal that fails
  never reaches the halt beside it; halt(2) alone exits 2, so the default is the
  hazard. The goal now guarantees a halt on every path (0/2/1)
- `42.` parses, is not callable, and landed as a fact whose head is a number;
  kb_consistent has no opinion about stray terms
- fmt discarded the merge's exit code and would have committed a failed
  canonicalization

Plus the two that were designed in: the result is read back and required to be
the clauses intended (variant equality -- == compares variable identity and
reports every rule as different), and kb_consistent runs on the CANDIDATE, so a
bad delta leaves the knowledge base untouched rather than needing a revert.

scripts/kb-test.sh proves all nine by injection. context/code/kb-tool.md carries
the argument, including what it does not reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 15:54:39 -07:00

102 lines
3.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# The knowledge base's one maintenance tool. Replaces the Prolog MCP cycle:
# adding a clause costs the clause, not the whole predicate.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/.." && pwd)"
KB="${KB_FILE:-$repo/prolog-sessions/kb.pl}"
MERGE="$here/kb-merge.pl"
# trealla comes from the dev shell, which direnv puts on PATH. Reaching it
# through `nix shell` per invocation cost more than the work it did: this
# tool's first real use timed out past two minutes on a half-second write.
if ! command -v tpl >/dev/null 2>&1; then
echo "kb: tpl is not on PATH -- run inside the dev shell (direnv allow, or nix develop)" >&2
exit 127
fi
# stdin is closed on every call: an uncaught Prolog error falls back to the
# REPL, which hangs on a terminal and exits 0 without one -- a refusal that
# reads as acceptance. kb-merge.pl catches parse errors; this is the backstop.
tpl() { command tpl "$@" </dev/null; }
# The goal that runs kb-merge.pl. `-g main,halt` is NOT enough: measured on
# trealla 3.10.3 it exits 0 on a throw and on a failure alike, so a crash reads
# as success to `if ! tpl ...`. This propagates both.
MERGE_GOAL="(catch(main,E,(write(user_error,E),nl(user_error),halt(2))) -> halt(0) ; halt(1))"
merge() { tpl -q "$MERGE" -g "$MERGE_GOAL" -- "$@"; }
# kb_consistent is the gate CI runs. Anything that writes runs it on the
# CANDIDATE, before the candidate becomes the knowledge base.
check_file() {
tpl "$1" -g "(kb_consistent -> halt(0) ; (kb_report, halt(1)))" 2>/dev/null
}
usage() {
cat >&2 <<'EOF'
usage: kb <command>
check run kb_consistent (what CI runs)
count clauses per keyed predicate
query '<goal>' run a goal against the knowledge base
fmt rewrite in canonical form (idempotent)
add <delta.pl> add clauses; refuses if any key already exists
replace <delta.pl> add clauses, overwriting existing keys
Every writing command verifies the result reads back as the clauses intended
and passes kb_consistent before it replaces the knowledge base.
EOF
exit 2
}
commit() { # $1 = candidate file
local cand="$1"
if ! check_file "$cand"; then
rm -f "$cand"
echo "kb: REFUSED -- kb_consistent fails on the result; $KB is unchanged" >&2
exit 1
fi
mv -f "$cand" "$KB"
}
[ $# -ge 1 ] || usage
cmd="$1"; shift
case "$cmd" in
check)
if check_file "$KB"; then echo "kb_consistent: TRUE"; else exit 1; fi
;;
count)
merge count "$KB" 2>/dev/null
;;
query)
[ $# -eq 1 ] || usage
tpl "$KB" -g "($1 -> halt(0) ; (write('false'), nl, halt(1)))" 2>/dev/null
;;
fmt)
cand="$(mktemp)"
: > "${cand}.delta"
if ! merge add "$KB" "${cand}.delta" "$cand" >/dev/null 2>&1; then
rm -f "$cand" "${cand}.delta"
echo "kb: REFUSED -- $KB is unchanged" >&2
exit 1
fi
rm -f "${cand}.delta"
commit "$cand"
echo "kb: canonicalized"
;;
add|replace)
[ $# -eq 1 ] || usage
cand="$(mktemp)"
if ! merge "$cmd" "$KB" "$1" "$cand"; then
rm -f "$cand"
echo "kb: REFUSED -- $KB is unchanged" >&2
exit 1
fi
commit "$cand"
echo "kb: $KB updated"
;;
*) usage ;;
esac