fix(kb): keys are compared as variants, and a cited clause cannot be silently retired (improvise-dj1l, improvise-2f97)

dj1l (P1, found by review of PR #68 after it merged). kb-merge.pl matched
clause keys by UNIFICATION, which both lost and corrupted clauses.
check_self_consistent compared keys with ==, but findall hands back fresh
variables, so two clauses with variant heads were never == and passed the
duplicate gate; foldl_merge then matched by unification and the second removed
the first. Reproduced: 536 before, 2 in delta, 537 after. Worse, adding
`probe_fact(X) :- doctrine(X,_).` over a kb holding probe_fact(a) and
probe_fact(b) deleted probe_fact(a) AND wrote `probe_fact(a):-doctrine(a,A)` --
the match bound X, so the clause written was not the clause supplied. The
round-trip verify cannot see that: it compares the file against an intended
list already corrupted by the same unification.

same_key/2 (copy_term + numbervars) in has_key, conflicts and absent;
canonicalize before duplicate detection; a RULE keys on the whole clause, since
one head shape with different bodies is ordinary Prolog; and check_total/2
asserts the arithmetic the tool already PRINTED beside the delta size -- "2 in
delta, 537 after" was visible in its own output twice and nothing compared them.

2f97. kb_error(dangling_citation/3): a name cited inside another clause's text
must resolve as a doctrine/2, invariant/2 or decision/3, so retiring a clause
can no longer dangle every clause naming it. The token rule is narrow on
purpose -- a name-shaped token directly inside doctrine(, invariant( or
decision( -- so prose cannot false-positive. kb_error/1 is a gate predicate,
so this was written with KB_ALLOW_GATE=1 deliberately: the refusal exists so a
write cannot disarm its own gate, and an intentional change to the gate is
supposed to be a greppable act rather than an impossible one.

Seven cases added to kb-test.sh, each proved by injection: two variant-headed
rules both land; a non-ground rule deletes nothing and is written as supplied;
a duplicate key is still refused; an injected dropping fold is caught by the
total; a cited clause cannot be retired; and the citation arm fires on a name
that resolves to nothing without firing on one that resolves. scripts/kb gains
a KB_MERGE seam so a broken merge can be injected. One pre-existing case moved
off doctrine(bug_first), which the new arm correctly refuses to retire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
Edward Langley
2026-09-20 16:31:02 -07:00
parent 3bb484b1f3
commit 50de0a8fd2
4 changed files with 205 additions and 7 deletions
+29
View File
@@ -116,6 +116,7 @@ invariant(dataset_columns_are_not_header_aligned,'gp_tree::data::preprocess cont
invariant(no_input_line_is_dropped,'The Markdown renderer covers exactly the subset the corpus uses; anything it does not recognise falls through to paragraph text. The failure mode is an ugly paragraph, never a missing one.').
invariant(a_table_cell_boundary_is_an_unescaped_pipe_outside_a_code_span,'splitRow walks the row rather than split(\"|\"), because this corpus documents a file format whose own quoting syntax is |Time Period| and a cell containing one would be shredded into extra columns -- a row that still parses, so no column count could detect it. Latent when fixed, which is the point: the trigger is one ordinary sentence away in a document whose subject is pipe quoting.').
invariant(code_spans_stand_aside_for_emphasis,'Inline rendering replaces code spans with placeholders before matching emphasis, because this corpus routinely opens ** on one side of a code span and closes it on the other; matching per segment silently drops that bold with no visible symptom. Strong is matched before emphasis with a body that may contain a single * pair, because [^*]+ cannot match **a *b* c** and the leftover * then pairs across the bold -- which turned one sentence of gotcha 33 into three italic paragraphs.').
invariant(key_matching_is_variant_equality_never_unification,'scripts/kb-merge.pl compares clause keys with same_key/2 (copy_term + numbervars), never by unifying them. Unification matched too much -- a non-ground key probe(X) matched probe(a) and deleted it -- and MUTATED the incoming clause, binding X to a, so the clause written was not the clause supplied; the round-trip verify cannot see that, since it compares the file against an intended list already corrupted by the same unification (improvise-dj1l). A RULE keys on the whole clause, because one head shape with different bodies is ordinary Prolog. check_total/2 asserts the arithmetic the tool already PRINTED beside the delta size.').
doctrine(bug_first,'write a failing test demonstrating the bug before fixing it').
doctrine(unit_over_e2e,'prefer small unit tests over integration/e2e').
doctrine(suite_speed,'Two numbers, because the ambiguity between them is what let this drift: NO SINGLE TEST BINARY may exceed 2 seconds, and the WORKSPACE WALL CLOCK is the sum of them. Measured at 22d43b2: 1309 tests over 33 binaries, ~2.2s wall, longest single binary 1.42s -- so the workspace is already past 2s and the rule that bites is the per-binary one. Re-MEASURE rather than re-assert: cargo test --workspace prints a \"finished in\" per binary. Excludes compilation, and excludes harness/ (Playwright, minutes, its own CI job).').
@@ -218,6 +219,8 @@ doctrine(a_write_must_not_be_able_to_disarm_its_own_gate,'scripts/kb validates a
doctrine(a_read_validate_write_sequence_is_a_lost_update,'scripts/kb read the knowledge base, merged, validated and moved the result into place, with nothing between the read and the move. Two agents doing that concurrently -- which is ordinary here, waves run agents in parallel -- both read the same file, both validate, and the later move silently discards the earlier addition. Exactly the class this tool was built to remove, reintroduced one level up in the tool itself. Closed by stamping the file content at read time and refusing to commit if it changed, plus an mkdir lock (flock is absent on macOS) so the collision is rare rather than merely loud. The stamp is the part that matters: a lock prevents the common case, a compare-and-swap is what makes an uncommon one impossible to lose silently.').
doctrine(design_against_the_model_before_implementing,'AGENTS.md has always said Prolog is a design tool for reasoning about tickets, and it was the least-used line in the file: kb.pl was written retrospectively. \`scripts/kb check --with <design.pl>\` closes that -- proposed facts are merged into a throwaway candidate and kb_consistent runs over the union, so a design can be refused before code exists. The model already DERIVES rather than merely records: illegal_dependency, may_use, depends_transitively, portable, snapshot_safe, view_state_admits, record_obligation, needs_runtime_check, and eleven kb_error classes including layering_inverted and field_misplaced_in_view_state. Demonstrated on three proposals in one sitting: one added edge (improvise_core depending on improvise_command) produced SIX derived layering violations through the transitive closure, which no author would have enumerated; a proposal to declare decision/3 functional was refused with conflicting_values naming improvise-907, correctly, because one ticket carries several decisions; and a mis-ordered layer/2 argument was named as layer_names_unknown_crate rather than silently accepted. What makes this worth doing rather than decorative is the same rule as everywhere else -- the model must be able to REFUSE, and you must watch it refuse. A design language nothing can reject is documentation with parentheses.').
doctrine(a_key_rule_must_ask_whether_the_relation_is_multi_valued,'scripts/kb keyed every clause on the functor plus all arguments but the last, which is right for doctrine(Name,Text) and wrong for any RELATION: crate_depends_on(A,B) then keyed on A, so a crate depending on two crates read as one key twice and was unaddable. Found by trying to model a proposed crate, not by any test -- the tool had only ever been pointed at single-valued facts. The fix cannot read the KB own functional/1, because that check keys an arity-3 predicate on its FIRST argument, which is right for crate/3 and wrong for decision/3 where one ticket holds several decisions under different names. So key_arity/2 is a hand-maintained table and anything unlisted keys on the WHOLE head, which is the safe direction: an unlisted relation accepts many clauses and only an exact repeat is a duplicate.').
doctrine(a_local_shell_is_not_the_ci_shell,'zsh ABORTS on an unmatched glob and bash leaves it as a literal, so a CI step written and probed in zsh can fail in a way no developer shell can reproduce. node --test web/tests/*.test.js under bash therefore globbed the literal itself, matched nothing, reported 0 tests and exited 0 -- both node steps gated nothing the moment their directory moved (improvise-vz54). Local shell vs CI shell is a fault-injection axis in its own right: probe a CI line under the shell the workflow names, not the one you are typing in. The fix moves the glob into scripts/node-test.sh so the expansion is the script\'s and can be refused.').
doctrine(a_convention_that_keeps_a_checker_quiet_is_the_one_that_blinds_it,'context/test-evidence used #### for browser tests precisely BECAUSE --check-evidence resolved names against Rust fn names only, so a ### heading naming a Playwright test would be reported as an orphan. The workaround made every browser entry invisible, and a deleted test left its heading promising coverage for a whole release (improvise-5ray). Cure: collect ### and #### alike and decide by SHAPE -- a bare identifier is a Rust test, a quoted string is a JS test, anything else is prose naming nothing. When a checker forces a convention on the thing it checks, ask what that convention hides.').
work_done('improvise-8tk',bug_fix,'ZZ/:wq quit even when the save failed, silently discarding the model. Save now aborts the effect batch on failure; save_to_current_path/1 returns Result<String,String>; three tests in ui/effect.rs.').
work_done('improvise-907',bug_fix,'Import wizard step machine redesign: validation_error() gate at ReviewProposals (no-category and over-limit), history-stack previous() bound to Left on every step, and Done made a real confirmation step holding the built workbook. Superseded improvise-gfq, improvise-o1c, improvise-c6j.').
work_done('improvise-6sq',wont_do,'Merging EditOrDrill and EnterEditAtCursorCmd into one EnterEdit{mode} is not safe: they differ in WHEN the cell value is read, not in a mode parameter. Added ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid as executable evidence.').
@@ -241,6 +244,12 @@ work_done('backlog-groom-2026-09-19',groom,'ran the five 2026-08-30 checks over
work_done('improvise-0yq8',refactor,'ClientState -> improvise-protocol/src/client.rs and keys.rs::{resolve,step,KeyOutcome} -> improvise-protocol/src/resolve.rs, with socket.rs::{typed,typed_cell_command} following step. BrowserKey stays in the wasm client: it is the browser half of the two things a front end owns. render_model became a free function, ClientState being foreign. 1311 tests before and after -- no behaviour change.').
work_done('improvise-e24v',feature,'Fifteen command names resolve client-side: cycle-panel-focus, the category panel five, the view panel three, both tile-axis names, both help-page steps, commit-buffer in three kinds, clear-cell and paste. New lockstep harness applies what the client composed to a SECOND authoritative host and compares documents as .improv text (Debug prints the symbol table HashMaps, so it differs per process). Four injected faults, each failing a named test; one needed the fixture widened first. Found and fixed a wire/effect divergence in Command::HelpPage.').
work_done('improvise-x7sw',refactor,'Closed with improvise-ktv1 and improvise-k099 as one question. improvise-protocol/src/help.rs is the one home for a UI string; AppMode::label/hint_text and Keymap::binding_hints deleted, along with every_hint_key_resolves_in_its_mode and its 60-line tokenizer. The TUI help overlay kept its ratatui styles and lost ~310 lines of content; the browser gained one (RenderModel::help, empty means closed), asserted by a new Chromium test in harness/tests/shell.spec.js proved discriminating by two injected faults.').
work_done('improvise-vz54',bug_fix,'node --test with an unmatched glob ran zero tests and exited 0 on both CI node steps. scripts/node-test.sh now owns the expansion and refuses an empty match; it also refuses a matched file that declares no test, checked LEXICALLY because node reports such a file as one passing test named after the file, so its own count reads as the FILE count. scripts/node-test-test.sh proves each refusal by injection and runs in the js job.').
work_done('improvise-5ray',bug_fix,'--check-evidence collected only ### headings and resolved names against Rust fn names, so every browser entry was invisible. Now collects ###/#### alike, classifies by shape, and resolves JS names out of harness/tests and web/tests. It immediately found a real orphan: 36ade35 deleted the Playwright test that asserted the browser draws help and left its heading behind (improvise-v5pc).').
work_done('improvise-lyyc',bug_fix,'--check-evidence gained its second direction: a test whose doc comment names context/test-evidence must have an entry there. It immediately found one, an_entry_that_holds_no_document_is_seeded_over, which had cited the file with no entry since it was written. Ten evidence cases were added to the checker\'s own --self-test, which had none.').
work_done('improvise-x4wv',bug_fix,'web/assemble.sh\'s stamped guard exited on the FIRST missing cache-bust stamp, so breaking two substitutions named one and cost a wasm-pack rebuild to find the next. stamped now records and a gate after the four calls fails the build. Both the fix and the gate\'s own absence were proved by injection; the comment says the gate is the whole guard.').
work_done('improvise-2f97',feature,'kb_error(dangling_citation/3): a name cited inside another clause\'s text must resolve as a doctrine/2, invariant/2 or decision/3. The token rule is narrow on purpose -- only a name-shaped token (lowercase, digits, underscores, at least one underscore) directly inside doctrine(, invariant( or decision( -- so prose cannot false-positive. Retiring doctrine(an_advisory_gate_is_not_a_gate) is now refused by name rather than silently dangling its citers. kb_text/2 is hand-maintained, like gate_predicate/1.').
work_done('improvise-dj1l',bug_fix,'scripts/kb-merge.pl matched clause keys by unification, which lost and corrupted clauses: two variant-headed rules in one delta went two in, one out (536 before, 2 in delta, 537 after), and a non-ground rule over a predicate holding facts deleted one AND was written with its variable bound. See invariant(key_matching_is_variant_equality_never_unification). Five regression cases added to scripts/kb-test.sh, including one that injects a dropping fold to prove check_total fires; scripts/kb gained a KB_MERGE seam so a broken merge can be injected.').
work_followup('improvise-8tk','improvise-lae','no-file-path UX: prompt for a path rather than only erroring. ExportPrompt is CSV-only, so a new SavePrompt TextEntry mode committing to SaveAs is the likely shape.').
work_followup('improvise-6sq','improvise-ct5','i on an empty records grid reports \'cannot drill\', leaking a pivot-mode concept into records mode').
work_followup('improvise-h0b','improvise-a2x','ViewState field types (AppMode, MinibufferConfig, ViewFrame, KeyCode, KeyModifiers) still derive no Serialize, and improvise-command depends only on serde_json. Deferred: nothing serializes ViewState until improvise-cqi builds a Snapshot, so the derives would have no consumer.').
@@ -473,6 +482,7 @@ kb_error(followup_from_unrecorded_work(A)):-work_followup(A,B,C), \+work_done(A,
kb_error(unblocked_by_unrecorded_work(A)):-work_unblocked(A,B,C), \+work_done(A,D,E).
kb_error(field_misplaced_in_view_state(A)):-session_field(A,view_state,B), \+view_state_admits(A).
kb_error(field_evicted_without_cause(A)):-session_field(A,app,B),view_state_admits(A).
kb_error(dangling_citation(A,B,C)):-kb_text(A,D),cited_name(D,B,C), \+citation_resolves(B,C).
kb_consistent:- \+kb_error(A).
kb_report:-setof(A,kb_error(A),B)->length(B,C),format([k,b,:,' ',~,w,' ',p,r,o,b,l,e,m,'(',s,')',~,n],[C]),forall(member(A,B),format([' ',' ',-,' ',~,w,~,n],[A]));write('kb: consistent'),nl.
kb_external_check('grep -oE \"^location\\([a-z_]+,\'[^\']+\',\'[^\']+\'\\)\" prolog-sessions/kb.pl # then confirm each file/symbol still exists').
@@ -534,3 +544,22 @@ opfs_sync(flush).
opfs_sync(close).
browser_fact(sync_access_handle_safari,'Safari 16.4+ supports createSyncAccessHandle, and all current browsers implement the handle methods synchronously despite an early spec that wrongly called them async. Checked 2026-08-31, so the IndexedDB fallback improvise-i34 called for is not needed.').
browser_fact(sync_access_handle_worker_only,'available only in dedicated Web Workers, and exclusive per file entry: a second handle on the same file fails until the first is closed. Hence attach closing what it replaces, and detach existing at all.').
citation_marker('doctrine(',doctrine).
citation_marker('invariant(',invariant).
citation_marker('decision(',decision).
kb_text(doctrine(A),B):-doctrine(A,B).
kb_text(invariant(A),B):-invariant(A,B).
kb_text(decision(A,B),C):-decision(A,B,C).
kb_text(work_done(A,B),C):-work_done(A,B,C).
kb_text(work_followup(A,B),C):-work_followup(A,B,C).
kb_text(kb_issue_found(A),B):-kb_issue_found(A,B).
lower_or_digit(A):-char_code(A,B),B>=97,B=<122.
lower_or_digit(A):-char_code(A,B),B>=48,B=<57.
name_char(A):-lower_or_digit(A),!.
name_char('_').
name_shaped(A):-atom_chars(A,B),B=[C|D],lower_or_digit(C),forall(member(E,B),name_char(E)),memberchk('_',D).
citation_token(A,B):-(sub_atom(A,C,1,D,')')->true;C=999),(sub_atom(A,E,1,F,',')->true;E=999),G is min(C,E),G>0,G<120,sub_atom(A,0,G,H,B).
cited_name(A,B,C):-citation_marker(D,B),sub_atom(A,E,F,G,D),sub_atom(A,H,G,0,I),citation_token(I,C),name_shaped(C).
citation_resolves(doctrine,A):-doctrine(A,B).
citation_resolves(invariant,A):-invariant(A,B).
citation_resolves(decision,A):-decision(B,A,C).
+3 -1
View File
@@ -6,7 +6,9 @@ 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"
# Test seam: kb-test.sh injects a pre-fix kb-merge.pl to prove the arithmetic
# check catches a merge that drops a clause.
MERGE="${KB_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
+49 -5
View File
@@ -33,6 +33,9 @@ run(remove, KbFile, DeltaFile, OutFile) :-
halt(2)
),
foldl_remove(Funcs, Doomed, Old, Kept),
length(Old, NOld), length(Doomed, NDoomed),
ExpectK is NOld - NDoomed,
check_total(Kept, ExpectK),
write_terms(OutFile, Kept),
verify(OutFile, Kept),
length(Old, NO), length(Kept, NK),
@@ -47,6 +50,8 @@ run(Mode, KbFile, DeltaFile, OutFile) :-
conflicts(Funcs, Old, New, Clashes),
gate(Mode, Clashes),
foldl_merge(Funcs, New, Mode, Old, Merged),
expected_total(Funcs, Mode, Old, New, Expect),
check_total(Merged, Expect),
write_terms(OutFile, Merged),
verify(OutFile, Merged),
length(Old, NO), length(New, NN), length(Merged, NM),
@@ -106,7 +111,15 @@ clause_head(H, H).
% predicate on its FIRST argument, which is right for crate/3 and wrong for
% decision/3, where one ticket carries several decisions under different names.
% Hand-maintained, like gate_predicate/1, but the default is the safe direction.
clause_key(Funcs, (H :- _), K) :- !, head_key(Funcs, H, K).
% A RULE keys on the whole clause, because two clauses with the same head shape
% and different bodies is ordinary Prolog -- keying a rule on its head alone
% made the second refuse as a duplicate, or worse, silently replace the first.
clause_key(Funcs, (H :- B), K) :-
!,
( clause_pred((H :- B), P), key_arity(P, _)
-> head_key(Funcs, H, K)
; K = (H :- B)
).
clause_key(Funcs, H, K) :- head_key(Funcs, H, K).
key_arity(doctrine/2, 1).
@@ -137,7 +150,7 @@ clause_pred(H, F/A) :- functor(H, F, A).
% first -- add discarding part of its own payload, visible only as a delta count
% that did not match the clauses added.
check_self_consistent(Funcs, New) :-
findall(K, ( member(T, New), clause_key(Funcs, T, K) ), Ks),
findall(C, ( member(T, New), clause_key(Funcs, T, K), canon_key(K, C) ), Ks),
msort(Ks, Sorted),
adjacent_dups(Sorted, Dups0),
sort(Dups0, Dups),
@@ -157,10 +170,31 @@ adjacent_dups([A, B|T], Out) :-
),
adjacent_dups([B|T], Rest).
% The tool printed "N in delta" beside a total that did not account for N,
% twice, and nothing compared them. This checks the arithmetic the report
% already shows, which catches a dropped clause without knowing about keys.
check_total(Result, Expect) :-
length(Result, Got),
( Got =:= Expect
-> true
; format("kb-merge: REFUSED -- would write ~w clauses, expected ~w; a clause was dropped~n",
[Got, Expect]),
halt(2)
).
expected_total(_, add, Old, New, Expect) :-
length(Old, NO), length(New, NN), Expect is NO + NN.
expected_total(Funcs, replace, Old, New, Expect) :-
length(Old, NO),
findall(x, ( member(T, New), clause_key(Funcs, T, K),
\+ ( member(O, Old), clause_key(Funcs, O, K2), same_key(K, K2) ) ), Fresh),
length(Fresh, NF),
Expect is NO + NF.
conflicts(Funcs, Old, New, Clashes) :-
findall(K,
( member(T, New), clause_key(Funcs, T, K),
member(O, Old), clause_key(Funcs, O, K) ),
member(O, Old), clause_key(Funcs, O, K2), same_key(K, K2) ),
Clashes).
gate(add, []) :- !.
@@ -180,14 +214,24 @@ foldl_merge(Funcs, [T|Ts], Mode, Acc0, Acc) :-
insert_after_last(Acc1, P, T, Acc2),
foldl_merge(Funcs, Ts, Mode, Acc2, Acc).
has_key(Funcs, K, T) :- clause_key(Funcs, T, K).
% Key matching is VARIANT equality, never unification. Unifying a key against a
% clause both matched too much -- a non-ground key probe(X) matched probe(a),
% destroying it -- and MUTATED the incoming clause, because the unification
% bound X to a, so the rule written was not the rule supplied. The round-trip
% check cannot see that: it compares the file against an intended list already
% corrupted by the same unification.
has_key(Funcs, K, T) :- clause_key(Funcs, T, K2), same_key(K, K2).
same_key(K1, K2) :- canon_key(K1, C1), canon_key(K2, C2), C1 == C2.
canon_key(K, C) :- copy_term(K, C), numbervars(C, 0, _).
% A remove that matches nothing is refused rather than silently doing nothing:
% a typo'd key would otherwise report success and leave the clause in place.
absent(Funcs, Old, Doomed, Missing) :-
findall(K,
( member(T, Doomed), clause_key(Funcs, T, K),
\+ ( member(O, Old), clause_key(Funcs, O, K) ) ),
\+ ( member(O, Old), clause_key(Funcs, O, K2), same_key(K, K2) ) ),
Missing).
foldl_remove(_, [], Acc, Acc).
+124 -1
View File
@@ -88,8 +88,13 @@ 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(bug_first,_).\n" > "$work/drop.pl"
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)"
@@ -277,5 +282,123 @@ 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"
if kb replace "$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 "replace refused a rule over a predicate holding facts"
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" >/dev/null 2>&1; then
bad "add accepted the same clause twice in one delta"
elif unchanged; then
ok "add still refuses one key named twice, compared as variants"
else
bad "add refused a duplicate but MODIFIED the kb"
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
printf '\nkb-test: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]