Files
improvise/scripts/kb-test.sh
T
Edward Langley cd094b6dfb fix(kb): the dj1l cases discriminate, and a replaced rule cannot silently become an add
Five findings from the second review round, and the first is this PR's own
standard applied to its own P1.

**The two fixes improvise-dj1l names had no discriminating test.** Reverting
`has_key` to unification, or dropping `canon_key` from the duplicate gate, left
kb-test at 27 passed. Case 11 was carried by `check_total` refusing with a
DIFFERENT message, and case 10 by the other half of the fix. Both now assert
which refusal fired (`said()`), plus two cases aimed at each half: a rm key with
a variable in a non-final position must be refused as ABSENT rather than
matching everything, and a keyed FACT whose key holds a variable must ADD
without excluding the siblings it would unify with. Each proved by reverting
exactly one fix and watching exactly that case fail.

**Keying a rule on its whole clause made `replace` on an edited rule silently
become `add`** -- both arms live, the stale one still firing, and `check_total`
cannot see it because `NO + NF` is correct arithmetic for an add. Reachable on
the 15 `kb_error/1` arms and the `kb_text/2` rules, so editing the check
improvise-2f97 adds is the first thing to hit it. `check_rule_replace` refuses
and names the route (rm, then add); the negative arm -- replace of a rule
present verbatim -- is tested, or the refusal would just be "replace never
works on rules".

**The `decision(` arm was dead code.** `cited_name` took the first field, and
the repo spells `decision(<ticket>, <name>)`, whose ticket id fails
`name_shaped/1` on the hyphen -- so that marker could never fire for a real
citation. A second `cited_name` clause takes the second field. All three
markers are now exercised.

Minor, from the same round: `_JS_TEST` no longer accepts `test.only`, which
disables every other test in its file, with a case asserting a heading cannot
be satisfied by one; and `node-test-test.sh`'s last case no longer duplicates
an earlier one.

Filed rather than fixed: improvise-w8ih, extracting `web/assemble.sh`'s gate so
its own absence is visible.

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

503 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 is also the discriminating door --
# under unification `conflicts` sees the non-ground key match both facts and
# refuses the add outright, so the reverted fix fails HERE.
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 ]