3405fc4def
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
102 lines
3.2 KiB
Bash
Executable File
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
|