Files
improvise/scripts/kb-test.sh
T
Edward Langley 7945221eed fix(kb): two comments claimed things the code does not do (improvise-9d5k)
kb-test case 10 said it was where same_key is discriminated. Reverting same_key
to unification leaves case 10 PASSING and fails the wildcard rm and case 17
instead -- a rule keys on its whole clause, so the `:-` term cannot unify with
probe_fact(a) and conflicts never fires there. Re-demonstrated by injection
before rewriting the comment. The site is guarded; the explanation was wrong.

replace's refusal said it would add the rules "beside the ones already there",
which is false when the predicate has none. The behaviour was always right and
fail-safe; only that clause of the sentence was wrong.

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

505 lines
20 KiB
Bash
Executable File

#!/usr/bin/env bash
# check_the_checker for scripts/kb: every refusal is proved by injecting the
# fault it names, and every refusal must leave the knowledge base UNCHANGED.
set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/.." && pwd)"
KB="$repo/prolog-sessions/kb.pl"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
pass=0
fail=0
ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); }
bad() { printf ' FAIL %s\n' "$1"; fail=$((fail+1)); }
# Each case runs against its own copy, so a bug cannot reach the real file.
fresh() { cp "$KB" "$work/kb.pl"; }
kb() { KB_FILE="$work/kb.pl" "$here/kb" "$@"; }
unchanged() { diff -q "$KB" "$work/kb.pl" >/dev/null; }
# A refusal is only evidence when it is the refusal you NAMED: two of these
# cases passed against a tree with the fix reverted, because a DIFFERENT guard
# caught the same delta and the assertion was only "fails and kb unchanged"
# (review of PR #69). said() asserts which one fired.
said() { grep -qF "$2" "$1"; }
echo "kb-test: the refusals"
# 1. A duplicate key is refused, and nothing is written.
fresh
cat > "$work/dup.pl" <<'EOF'
doctrine(bug_first,'a second, contradicting text for a name that already exists').
EOF
if kb add "$work/dup.pl" >/dev/null 2>&1; then
bad "add accepted a duplicate key"
elif unchanged; then
ok "add refuses a duplicate key and leaves the kb unchanged"
else
bad "add refused a duplicate key but MODIFIED the kb"
fi
# 2. replace is the door that duplicate goes through, and it replaces rather
# than appending -- a count that grew would be two clauses for one name.
fresh
before="$(kb count | grep '^doctrine/2=')"
if kb replace "$work/dup.pl" >/dev/null 2>&1; then
after="$(kb count | grep '^doctrine/2=')"
if [ "$before" = "$after" ]; then
ok "replace overwrites in place ($after)"
else
bad "replace changed the clause count: $before -> $after"
fi
else
bad "replace refused a duplicate key"
fi
# 3. A delta that does not parse is refused.
fresh
printf 'doctrine(broken, unterminated\n' > "$work/bad.pl"
if kb add "$work/bad.pl" >/dev/null 2>&1; then
bad "add accepted an unparseable delta"
elif unchanged; then
ok "add refuses an unparseable delta and leaves the kb unchanged"
else
bad "add refused an unparseable delta but MODIFIED the kb"
fi
# 4. A delta that parses but breaks kb_consistent is refused. This is the one
# that matters: the candidate is checked BEFORE it becomes the kb.
fresh
cat > "$work/illegal.pl" <<'EOF'
crate_depends_on(improvise_core,improvise_command).
EOF
if kb add "$work/illegal.pl" >/dev/null 2>&1; then
bad "add accepted a delta that breaks kb_consistent"
elif unchanged; then
ok "add refuses a kb_consistent violation and leaves the kb unchanged"
else
bad "add refused a violation but MODIFIED the kb"
fi
# 4b. A delta trealla cannot even take apart must refuse, not crash into a
# success. `-g main,halt` exits 0 on a throw, so this is the guard for it.
fresh
printf '42.\n' > "$work/notaclause.pl"
if kb add "$work/notaclause.pl" >/dev/null 2>&1; then
bad "add accepted a term that is not a clause"
elif unchanged; then
ok "add refuses a non-clause term and leaves the kb unchanged"
else
bad "add refused a non-clause term but MODIFIED the kb"
fi
# 4c. rm removes by key, and a key that is not there is REFUSED rather than
# silently doing nothing -- a typo would otherwise report success.
# The clause removed is one this case ADDS. It used to name bug_first, which
# the citation check (improvise-2f97) now refuses to retire -- correctly, since
# before_fixing/2 calls it and kb_issue_found names it in prose.
fresh
printf "doctrine(a_probe_doctrine_to_remove,'a clause nothing else names').\n" > "$work/probe.pl"
kb add "$work/probe.pl" >/dev/null 2>&1
printf "doctrine(a_probe_doctrine_to_remove,_).\n" > "$work/drop.pl"
before="$(kb count | grep '^doctrine/2=' | cut -d= -f2)"
if kb rm "$work/drop.pl" >/dev/null 2>&1; then
after="$(kb count | grep '^doctrine/2=' | cut -d= -f2)"
if [ "$((before - 1))" = "$after" ]; then
ok "rm removes exactly one clause ($before -> $after)"
else
bad "rm moved the count wrongly: $before -> $after"
fi
else
bad "rm refused a key that exists"
fi
fresh
printf "doctrine(a_key_that_does_not_exist_at_all,_).\n" > "$work/nokey.pl"
if kb rm "$work/nokey.pl" >/dev/null 2>&1; then
bad "rm accepted a key that is not present"
elif unchanged; then
ok "rm refuses an absent key and leaves the kb unchanged"
else
bad "rm refused an absent key but MODIFIED the kb"
fi
# 4d. A DIRECTIVE is callable, so a callable test alone lets it through -- and
# the candidate is validated by CONSULTING it, so `:- halt(0).` exits the
# check with success before kb_consistent runs. The gate passes by never
# running. Found by review on PR #68; demonstrated before it was fixed.
fresh
printf ':- halt(0).\n' > "$work/directive.pl"
if kb add "$work/directive.pl" >/dev/null 2>&1; then
bad "add accepted a directive that disables its own gate"
elif unchanged; then
ok "add refuses a directive and leaves the kb unchanged"
else
bad "add refused a directive but MODIFIED the kb"
fi
# 4e. Keys were checked only against the EXISTING file, so two clauses sharing a
# previously-unused key clashed with nothing and the merge dropped the first
# -- add discarding part of its own payload, visible only as a delta count
# that did not match the clauses added.
fresh
cat > "$work/selfdup.pl" <<'EOD'
doctrine(a_name_used_twice_in_one_delta,'first').
doctrine(a_name_used_twice_in_one_delta,'second').
EOD
if kb add "$work/selfdup.pl" >/dev/null 2>&1; then
bad "add accepted a delta naming one key twice"
elif unchanged; then
ok "add refuses a delta that names one key twice"
else
bad "add refused a self-duplicate but MODIFIED the kb"
fi
# 4f. The gate can be REMOVED. kb_consistent then throws existence_error, and
# tpl exits 0 when a goal throws before reaching halt -- so the check passes
# by not existing and the unchecked candidate is committed.
fresh
printf 'kb_consistent.\n' > "$work/killgate.pl"
if kb rm "$work/killgate.pl" >/dev/null 2>&1; then
bad "rm removed the gate that validates the write"
elif unchanged; then
ok "rm refuses to remove the validating gate"
else
bad "rm refused to remove the gate but MODIFIED the kb"
fi
# 4g. And REPLACED with something trivially true, which passes forever after.
fresh
if kb replace "$work/killgate.pl" >/dev/null 2>&1; then
bad "replace made the gate trivially true"
elif unchanged; then
ok "replace refuses to disarm the validating gate"
else
bad "replace refused to disarm the gate but MODIFIED the kb"
fi
# 4h. An intentional change to the gate is still possible, deliberately.
fresh
if KB_ALLOW_GATE=1 kb replace "$work/killgate.pl" >/dev/null 2>&1; then
ok "KB_ALLOW_GATE lets a deliberate gate change through"
else
bad "KB_ALLOW_GATE did not allow a gate change"
fi
# 4i. Two writers reading the same file would both validate, and the later mv
# would silently discard the earlier addition. The stamp makes the loser
# loud. KB_TEST_RACE simulates the other writer landing mid-flight.
fresh
cp "$work/kb.pl" "$work/other.pl"
printf "doctrine(written_by_the_other_writer,'x').\n" >> "$work/other.pl"
cat > "$work/mine.pl" <<'EOD'
doctrine(written_by_me,'y').
EOD
if KB_TEST_RACE="$work/other.pl" kb add "$work/mine.pl" >/dev/null 2>&1; then
bad "a concurrent write was silently discarded"
elif grep -q written_by_the_other_writer "$work/kb.pl" && ! grep -q written_by_me "$work/kb.pl"; then
ok "a concurrent write is refused, and the other writer's addition survives"
else
bad "the race was refused but the kb is not the other writer's file"
fi
# 4j. A multi-valued relation must accept several clauses. The key rule keyed
# on all-but-last, so a crate depending on two crates read as a duplicate
# key and was unaddable -- found by trying to model a design, not by a test.
fresh
cat > "$work/multi.pl" <<'EOD'
crate(improvise_probe,'crates/improvise-probe','a probe crate').
crate_depends_on(improvise_probe,improvise_core).
crate_depends_on(improvise_probe,improvise_protocol).
layer(probe,improvise_probe).
EOD
if kb add "$work/multi.pl" >/dev/null 2>&1; then
ok "a multi-valued relation accepts several clauses"
else
bad "add refused a legitimate multi-valued relation"
fi
# 4k. check --with asks whether a design the tree does not have is one the model
# accepts. It must be able to say BOTH, and must never write.
fresh
printf 'crate_depends_on(improvise_core,improvise_command).\n' > "$work/bad-design.pl"
if kb check --with "$work/bad-design.pl" >/dev/null 2>&1; then
bad "check --with accepted a design that inverts the layering"
elif unchanged; then
ok "check --with refuses an unsound design and writes nothing"
else
bad "check --with refused a design but MODIFIED the kb"
fi
if kb check --with "$work/multi.pl" >/dev/null 2>&1 && unchanged; then
ok "check --with accepts a sound design and writes nothing"
else
bad "check --with rejected a sound design, or wrote to the kb"
fi
echo "kb-test: the happy paths"
# 5. A genuinely new clause lands, and the count moves by exactly one.
fresh
cat > "$work/new.pl" <<'EOF'
doctrine(a_name_that_is_certainly_not_taken,'text for a brand new doctrine').
EOF
before="$(kb count | grep '^doctrine/2=' | cut -d= -f2)"
if kb add "$work/new.pl" >/dev/null 2>&1; then
after="$(kb count | grep '^doctrine/2=' | cut -d= -f2)"
if [ "$((before + 1))" = "$after" ]; then
ok "add lands one new clause ($before -> $after)"
else
bad "add moved the count wrongly: $before -> $after"
fi
else
bad "add refused a valid new clause"
fi
# 6. The new clause is readable back as itself -- a count can move without the
# content being what was asked for.
if KB_FILE="$work/kb.pl" "$here/kb" query \
"doctrine(a_name_that_is_certainly_not_taken,T), T == 'text for a brand new doctrine'" \
>/dev/null 2>&1; then
ok "the added clause reads back with the text it was given"
else
bad "the added clause is not readable back as itself"
fi
# 7. Canonical form is a fixed point: a second fmt changes nothing.
fresh
kb fmt >/dev/null 2>&1
cp "$work/kb.pl" "$work/once.pl"
kb fmt >/dev/null 2>&1
if diff -q "$work/once.pl" "$work/kb.pl" >/dev/null; then
ok "fmt is idempotent"
else
bad "fmt is not idempotent -- every add would churn the file"
fi
# 8. Canonicalizing loses no clause. A serializer that drops a predicate is the
# wipe-the-kb failure, so the count is checked across the reformat.
fresh
b="$(kb count)"
kb fmt >/dev/null 2>&1
a="$(kb count)"
if [ "$b" = "$a" ]; then
ok "fmt preserves every clause count"
else
bad "fmt changed the counts"
fi
# 9. A delta holding two clauses whose heads are VARIANTS -- same shape, fresh
# variables -- is two clauses. check_self_consistent compared keys with ==
# over findall results, which never holds for fresh variables, so the delta
# passed; the merge then matched by UNIFICATION and the second removed the
# first. Two in, one out, reported as a success (improvise-dj1l).
cat > "$work/rules.pl" <<'EOF'
probe_rule(X) :- doctrine(X,_).
probe_rule(Y) :- invariant(Y,_).
EOF
fresh
b="$(kb count | grep '^total=' | cut -d= -f2)"
if kb add "$work/rules.pl" >/dev/null 2>&1; then
a="$(kb count | grep '^total=' | cut -d= -f2)"
if [ "$((a - b))" -eq 2 ]; then
ok "two variant-headed rules in one delta both land ($b -> $a)"
else
bad "two clauses in the delta, $((a - b)) landed"
fi
else
bad "add refused two variant-headed rules"
fi
# 10. A non-ground key must not match a ground clause. `probe_fact(X) :- ...`
# unified with `probe_fact(a)`, which DELETED the fact and, worse, bound X
# to a, so the clause written was not the clause supplied. The round-trip
# check cannot see that: it compares the file against an intended list
# already corrupted by the same unification.
fresh
printf 'probe_fact(a).\nprobe_fact(b).\n' > "$work/facts.pl"
kb add "$work/facts.pl" >/dev/null 2>&1
printf 'probe_fact(X) :- doctrine(X,_).\n' > "$work/rule.pl"
# `add`, not `replace`: the rule is new, and a rule that is not already there
# is what case 15 makes replace refuse. This case is NOT where same_key is
# discriminated -- a rule keys on its whole clause, so the `:-` term cannot
# unify with probe_fact(a) and `conflicts` never fires here. Reverting same_key
# to unification leaves this case passing and fails the wildcard rm and case 17
# instead; the site is guarded, by those (improvise-9d5k).
if kb add "$work/rule.pl" >/dev/null 2>&1; then
kept=0
grep -q '^probe_fact(a)\.$' "$work/kb.pl" && grep -q '^probe_fact(b)\.$' "$work/kb.pl" && kept=1
if [ "$kept" = 1 ]; then
ok "a non-ground rule does not delete the ground clauses it would unify with"
else
bad "a non-ground rule deleted a ground clause"
fi
if grep -q '^probe_fact(a):-' "$work/kb.pl"; then
bad "the clause written is not the clause supplied: its variable was bound"
elif grep -qE '^probe_fact\([A-Z][A-Za-z0-9_]*\):-doctrine\(' "$work/kb.pl"; then
ok "the rule is written as supplied, with its variable still a variable"
else
bad "the rule was not written at all"
fi
else
bad "add refused a rule over a predicate holding facts: a variable key matched them"
fi
# 11. The duplicate gate must still fire once keys are compared as variants:
# two clauses that really are the same key are still two clauses for one key.
fresh
printf 'probe_rule(X) :- doctrine(X,_).\nprobe_rule(Z) :- doctrine(Z,_).\n' > "$work/same.pl"
if kb add "$work/same.pl" >"$work/out" 2>&1; then
bad "add accepted the same clause twice in one delta"
elif ! unchanged; then
bad "add refused a duplicate but MODIFIED the kb"
elif said "$work/out" "names one key twice"; then
ok "add still refuses one key named twice, compared as variants"
else
bad "the delta was refused, but not BY THE DUPLICATE GATE (canon_key is unreached)"
fi
# 12. The arithmetic check, proved by injecting the fault it exists for: a
# merge that silently drops the last clause of its delta. The count was
# PRINTED beside the delta size all along and nothing compared them, which
# is how the real loss reported itself as "2 in delta, 537 after". The
# injection is aimed at the fold rather than at the keys, because the check
# must catch a dropped clause whatever dropped it.
sed 's/^foldl_merge(_, \[\], _, Acc, Acc)\./foldl_merge(_, [_], _, Acc, Acc)./' \
"$here/kb-merge.pl" > "$work/merge-dropping.pl"
if ! grep -q '^foldl_merge(_, \[_\], _, Acc, Acc)\.$' "$work/merge-dropping.pl"; then
bad "the injection did not apply -- case 12 proves nothing"
else
fresh
if KB_MERGE="$work/merge-dropping.pl" kb add "$work/rules.pl" >/dev/null 2>&1; then
bad "a merge that dropped a clause was accepted: the total is unchecked"
elif unchanged; then
ok "a merge that drops a clause is refused by the total it already printed"
else
bad "the dropped clause was caught but the kb was MODIFIED"
fi
fi
# 13. Clauses cite each other by NAME inside their text, and nothing checked
# that the name resolved -- so retiring a clause silently dangled every
# clause referring to it, which is the fact-stated-twice hazard the KB's
# own doctrine warns about in prose (improvise-2f97). The token rule is
# deliberately narrow: only a name-shaped token inside doctrine(...),
# invariant(...) or decision(...) brackets, so ordinary prose cannot
# false-positive.
fresh
echo 'doctrine(an_advisory_gate_is_not_a_gate,_).' > "$work/cited.pl"
if kb rm "$work/cited.pl" >/dev/null 2>&1; then
bad "rm retired a clause that another clause cites by name"
elif unchanged; then
ok "retiring a cited clause is refused, and the kb is unchanged"
else
bad "the dangling citation was caught but the kb was MODIFIED"
fi
# The other direction: the arm must FIRE on a citation that resolves to
# nothing, and must not fire on one that resolves. A check that only ever
# reports success is untested (kb_issue_found(checker_never_failed)).
cat > "$work/dangling.pl" <<'EOF'
doctrine(a_probe_that_cites_nothing,'cites doctrine(a_name_that_was_retired), which does not exist').
EOF
cat > "$work/resolving.pl" <<'EOF'
doctrine(a_probe_that_cites_a_real_one,'cites doctrine(an_advisory_gate_is_not_a_gate), which exists').
EOF
fresh
if kb check --with "$work/dangling.pl" >/dev/null 2>&1; then
bad "the model accepted a design citing a clause that does not exist"
elif kb check --with "$work/resolving.pl" >/dev/null 2>&1; then
ok "a citation is checked, and one that resolves is still accepted"
else
bad "the citation check refuses a name that DOES resolve"
fi
# 14. The case only same_key/2 can catch. A rm key with a variable in a
# NON-final position -- work_done keys on two arguments -- matches nothing
# as a variant and everything by unification. Under unification it removed
# an arbitrary bug_fix entry and reported success; here it must be refused
# as ABSENT, by that message and no other.
fresh
printf 'work_done(_,bug_fix,_).\n' > "$work/wild.pl"
if kb rm "$work/wild.pl" >"$work/out" 2>&1; then
bad "rm removed clauses by unifying a variable key against them"
elif ! unchanged; then
bad "rm refused a wildcard key but MODIFIED the kb"
elif said "$work/out" "no such clause to remove"; then
ok "a key with a variable in it matches nothing, rather than everything"
else
bad "the wildcard rm was refused, but not as ABSENT (same_key is unreached)"
fi
# 15. A rule keys on its whole clause, so an EDITED rule matches nothing and
# replace would silently become add -- leaving the stale arm firing beside
# the new one, with correct arithmetic (found by review on PR #69).
fresh
printf 'probe_rule(X) :- doctrine(X,_).\n' > "$work/rule1.pl"
printf 'probe_rule(X) :- doctrine(X,_), X \\== b.\n' > "$work/rule2.pl"
kb add "$work/rule1.pl" >/dev/null 2>&1
before="$(kb count | grep '^total=' | cut -d= -f2)"
if kb replace "$work/rule2.pl" >"$work/out" 2>&1; then
after="$(kb count | grep '^total=' | cut -d= -f2)"
bad "replace ADDED an edited rule beside the old one ($before -> $after)"
elif said "$work/out" "kb rm the old one"; then
ok "replace refuses a rule it cannot find, and names the route"
else
bad "the edited rule was refused, but without naming rm/add"
fi
# The negative arm: replace of a rule that IS there must still work, or the
# refusal above would just be "replace never works on rules".
if kb replace "$work/rule1.pl" >/dev/null 2>&1; then
ok "replace of an unchanged rule is still accepted"
else
bad "replace refuses a rule that is present verbatim"
fi
# 16. All three citation markers must FIRE. doctrine( and invariant( take the
# name as the first field; decision( takes the SECOND, because the repo
# spells decision(<ticket>, <name>) and a ticket id is not name-shaped --
# so a single-field rule left that marker unreachable (review, PR #69).
cat > "$work/cite-d.pl" <<'EOF'
doctrine(a_probe_citing_a_doctrine,'cites doctrine(a_name_that_was_retired), which does not exist').
EOF
cat > "$work/cite-i.pl" <<'EOF'
doctrine(a_probe_citing_an_invariant,'cites invariant(a_rule_that_was_never_written), which does not exist').
EOF
cat > "$work/cite-x.pl" <<'EOF'
doctrine(a_probe_citing_a_decision,'cites decision(improvise-nope, a_name_that_was_never_decided), which does not exist').
EOF
fresh
markers=0
for m in d i x; do
kb check --with "$work/cite-$m.pl" >/dev/null 2>&1 || markers=$((markers+1))
done
if [ "$markers" -eq 3 ]; then
ok "all three citation markers are reachable, not just the first"
else
bad "only $markers of 3 citation markers fire"
fi
# 17. The case aimed at has_key/2 itself. A keyed FACT with a variable in a
# key position -- work_done keys on (id, kind) -- is a key no existing
# clause is a variant of, so the add must land and every sibling survive.
# Under unification the fold excludes the clauses the key happens to match
# and the add is refused instead (by the total, which is the backstop, not
# the fix). Asserted as the OUTCOME, because that is what a wrong has_key
# destroys.
fresh
before="$(kb count | grep '^work_done/3=' | cut -d= -f2)"
printf "work_done(_,maintenance,'a probe clause with a variable id').\n" > "$work/wildfact.pl"
if kb add "$work/wildfact.pl" >"$work/out" 2>&1; then
after="$(kb count | grep '^work_done/3=' | cut -d= -f2)"
if [ "$((before + 1))" = "$after" ]; then
ok "a fact whose key holds a variable adds without excluding its siblings"
else
bad "adding one clause moved work_done/3 from $before to $after"
fi
else
bad "add refused a key holding a variable: the fold matched by unification"
fi
printf '\nkb-test: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]