cd094b6dfb
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
326 lines
12 KiB
Prolog
326 lines
12 KiB
Prolog
% Reads the knowledge base and a delta as DATA -- never consults either -- and
|
|
% writes their merge in canonical form. See context/code/kb-tool.md for why.
|
|
|
|
% No initialization/1: scripts/kb supplies a goal that turns a throw into
|
|
% exit 2 and a failure into exit 1. Measured on trealla 3.10.3, `-g main,halt`
|
|
% exits 0 for BOTH -- silently reporting a crash as a clean run.
|
|
main :-
|
|
current_prolog_flag(argv, Argv),
|
|
( Argv = [ModeStr, KbFile, DeltaFile, OutFile], mode_atom(ModeStr, Mode)
|
|
-> run(Mode, KbFile, DeltaFile, OutFile)
|
|
; Argv = [CountStr, KbFile], CountStr == "count"
|
|
-> count_only(KbFile)
|
|
; die('usage: kb-merge.pl <add|replace> <kb> <delta> <out> | count <kb>')
|
|
).
|
|
|
|
% argv arrives as strings; every later comparison is on an atom.
|
|
mode_atom(S, add) :- S == "add", !.
|
|
mode_atom(S, replace) :- S == "replace", !.
|
|
mode_atom(S, remove) :- S == "remove", !.
|
|
|
|
run(remove, KbFile, DeltaFile, OutFile) :-
|
|
!,
|
|
terms_of(KbFile, Old),
|
|
terms_of(DeltaFile, Doomed),
|
|
check_callable(Doomed),
|
|
functional_preds(Old, Funcs),
|
|
check_self_consistent(Funcs, Doomed),
|
|
absent(Funcs, Old, Doomed, Missing),
|
|
( Missing == []
|
|
-> true
|
|
; format("kb-merge: REFUSED -- no such clause to remove:~n", []),
|
|
forall(member(K, Missing), format(" ~q~n", [K])),
|
|
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),
|
|
Gone is NO - NK,
|
|
format("kb-merge: ~w before, ~w removed, ~w after~n", [NO, Gone, NK]).
|
|
run(Mode, KbFile, DeltaFile, OutFile) :-
|
|
terms_of(KbFile, Old),
|
|
terms_of(DeltaFile, New),
|
|
check_callable(New),
|
|
functional_preds(Old, Funcs),
|
|
check_self_consistent(Funcs, New),
|
|
conflicts(Funcs, Old, New, Clashes),
|
|
gate(Mode, Clashes),
|
|
check_rule_replace(Funcs, Mode, Old, New),
|
|
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),
|
|
format("kb-merge: ~w before, ~w in delta, ~w after~n", [NO, NN, NM]).
|
|
|
|
% A term can parse and still not be a clause: `42.` reads fine, is not
|
|
% callable, and reached the knowledge base as a fact whose head is a number.
|
|
% Nothing downstream objected -- kb_consistent has no opinion about stray terms.
|
|
check_callable([]).
|
|
check_callable([T|Ts]) :-
|
|
( bad_shape(T, Why)
|
|
-> format("kb-merge: REFUSED -- ~w: ~q~n", [Why, T]),
|
|
halt(2)
|
|
; check_callable(Ts)
|
|
).
|
|
|
|
% A DIRECTIVE is callable, so a callable test alone lets one through -- and the
|
|
% candidate is validated by consulting it, so `:- halt(0).` exits the check
|
|
% with success before kb_consistent runs and the gate passes by never running.
|
|
bad_shape(T, 'a directive, which would run when the result is checked') :-
|
|
nonvar(T), T = (:- _), !.
|
|
bad_shape(T, 'a query') :-
|
|
nonvar(T), T = (?- _), !.
|
|
bad_shape(T, 'not a clause') :-
|
|
clause_head(T, H), \+ callable(H).
|
|
bad_shape(T, 'part of the gate that validates this write') :-
|
|
\+ gate_writes_allowed,
|
|
clause_pred(T, P),
|
|
gate_predicate(P).
|
|
|
|
% Removing kb_consistent made the check pass by not existing; REPLACING it with
|
|
% a fact that is trivially true passes forever after. A write that can disarm
|
|
% its own validation is refused unless KB_ALLOW_GATE says otherwise, which makes
|
|
% an intentional change to the checker a deliberate, greppable act.
|
|
% Hand-maintained, and that is its weakness: a new gate predicate is unprotected
|
|
% until it is named here.
|
|
gate_predicate(kb_consistent/0).
|
|
gate_predicate(kb_report/0).
|
|
gate_predicate(kb_error/1).
|
|
gate_predicate(functional/1).
|
|
gate_predicate(kb_sentinel/1).
|
|
gate_predicate(known_crate/1).
|
|
gate_predicate(kb_external_check/1).
|
|
|
|
gate_writes_allowed :-
|
|
catch(getenv('KB_ALLOW_GATE', V), _, fail),
|
|
V \== '',
|
|
V \== '0'.
|
|
|
|
clause_head((H :- _), H) :- !.
|
|
clause_head(H, H).
|
|
|
|
% Which leading arguments identify a clause. Anything not listed keys on the
|
|
% WHOLE head, so a multi-valued relation -- a crate depends on several crates --
|
|
% can hold many clauses and only an exact repeat is a duplicate.
|
|
% This cannot be read off the KB's own functional/1: 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 carries several decisions under different names.
|
|
% Hand-maintained, like gate_predicate/1, but the default is the safe direction.
|
|
% 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).
|
|
key_arity(invariant/2, 1).
|
|
key_arity(decision/3, 2).
|
|
key_arity(work_done/3, 2).
|
|
key_arity(work_followup/3, 2).
|
|
key_arity(crate/3, 1).
|
|
key_arity(location/3, 2).
|
|
key_arity(groom_check/2, 1).
|
|
key_arity(kb_issue_found/2, 1).
|
|
key_arity(wasm_runtime_hazard/3, 2).
|
|
|
|
head_key(_, H, K) :-
|
|
functor(H, F, A),
|
|
( key_arity(F/A, N), H =.. [F|Args], length(Front, N), append(Front, _, Args)
|
|
-> K =.. [F|Front]
|
|
; K = H
|
|
).
|
|
|
|
functional_preds(_, []).
|
|
|
|
clause_pred((H :- _), F/A) :- !, functor(H, F, A).
|
|
clause_pred(H, F/A) :- functor(H, F, A).
|
|
|
|
% Keys were only checked against the EXISTING file, so two clauses sharing a
|
|
% previously-unused key clashed with nothing and the merge silently dropped the
|
|
% 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(C, ( member(T, New), clause_key(Funcs, T, K), canon_key(K, C) ), Ks),
|
|
msort(Ks, Sorted),
|
|
adjacent_dups(Sorted, Dups0),
|
|
sort(Dups0, Dups),
|
|
( Dups == []
|
|
-> true
|
|
; format("kb-merge: REFUSED -- the delta names one key twice:~n", []),
|
|
forall(member(K, Dups), format(" ~q~n", [K])),
|
|
halt(2)
|
|
).
|
|
|
|
adjacent_dups([], []).
|
|
adjacent_dups([_], []).
|
|
adjacent_dups([A, B|T], Out) :-
|
|
( A == B
|
|
-> Out = [A|Rest]
|
|
; Out = Rest
|
|
),
|
|
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, K2), same_key(K, K2) ),
|
|
Clashes).
|
|
|
|
% A rule keys on its whole clause, so an EDITED rule matches nothing and
|
|
% `replace` silently becomes `add` -- both arms then live, and the stale one
|
|
% goes on firing beside the new one. check_total cannot see it: NO + NF is
|
|
% correct arithmetic for an add. So replace refuses a rule it cannot find and
|
|
% names the route, which is rm then add (improvise-dj1l, review of PR #69).
|
|
check_rule_replace(Funcs, replace, Old, New) :-
|
|
!,
|
|
findall(T,
|
|
( member(T, New), T = (_ :- _),
|
|
clause_key(Funcs, T, K),
|
|
\+ ( member(O, Old), clause_key(Funcs, O, K2), same_key(K, K2) ) ),
|
|
Fresh),
|
|
( Fresh == []
|
|
-> true
|
|
; format("kb-merge: REFUSED -- replace cannot find these rules, so it would ADD them beside the ones already there:~n", []),
|
|
forall(member(T, Fresh), format(" ~q~n", [T])),
|
|
die('a rule is keyed on its whole clause, so an edited body is a NEW clause: kb rm the old one, then kb add')
|
|
).
|
|
check_rule_replace(_, _, _, _).
|
|
|
|
gate(add, []) :- !.
|
|
gate(add, Clashes) :-
|
|
!,
|
|
format("kb-merge: REFUSED -- ~w clause(s) already present:~n", [Clashes]),
|
|
forall(member(K, Clashes), format(" ~q~n", [K])),
|
|
die('use replace to overwrite an existing clause').
|
|
gate(replace, _).
|
|
gate(M, _) :- die(M).
|
|
|
|
foldl_merge(_, [], _, Acc, Acc).
|
|
foldl_merge(Funcs, [T|Ts], Mode, Acc0, Acc) :-
|
|
clause_key(Funcs, T, K),
|
|
exclude(has_key(Funcs, K), Acc0, Acc1),
|
|
clause_pred(T, P),
|
|
insert_after_last(Acc1, P, T, Acc2),
|
|
foldl_merge(Funcs, Ts, Mode, Acc2, Acc).
|
|
|
|
% 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, K2), same_key(K, K2) ) ),
|
|
Missing).
|
|
|
|
foldl_remove(_, [], Acc, Acc).
|
|
foldl_remove(Funcs, [T|Ts], Acc0, Acc) :-
|
|
clause_key(Funcs, T, K),
|
|
exclude(has_key(Funcs, K), Acc0, Acc1),
|
|
foldl_remove(Funcs, Ts, Acc1, Acc).
|
|
|
|
% Keeps each predicate a contiguous block: a new clause lands after the last
|
|
% one of its own predicate, or at the end when the predicate is new.
|
|
insert_after_last([], _, T, [T]).
|
|
insert_after_last([H|Rest], P, T, Out) :-
|
|
( clause_pred(H, P), \+ ( member(X, Rest), clause_pred(X, P) )
|
|
-> Out = [H, T|Rest]
|
|
; Out = [H|Tail], insert_after_last(Rest, P, T, Tail)
|
|
).
|
|
|
|
terms_of(File, Terms) :-
|
|
catch(open(File, read, S), _, die_file(File)),
|
|
read_all(S, Terms),
|
|
close(S).
|
|
|
|
% An uncaught syntax error drops trealla into its REPL, which waits on stdin --
|
|
% so a malformed delta HANGS with a terminal and exits 0 without one, reading
|
|
% as acceptance. Catching it is what makes the refusal a refusal.
|
|
read_all(S, Terms) :-
|
|
catch(read_term(S, T, []), E, parse_died(E)),
|
|
( T == end_of_file
|
|
-> Terms = []
|
|
; Terms = [T|Rest], read_all(S, Rest)
|
|
).
|
|
|
|
parse_died(E) :-
|
|
format("kb-merge: REFUSED -- does not parse: ~w~n", [E]),
|
|
halt(2).
|
|
|
|
write_terms(File, Terms) :-
|
|
open(File, write, S),
|
|
forall(member(T, Terms), portray_clause(S, T)),
|
|
close(S).
|
|
|
|
% The guard that matters: read the file back and require it to be exactly what
|
|
% was intended. A serializer bug is the wipe-the-KB failure with a new owner.
|
|
verify(File, Intended) :-
|
|
terms_of(File, Written),
|
|
canon(Written, CW),
|
|
canon(Intended, CI),
|
|
( CW == CI
|
|
-> true
|
|
; length(Written, NW), length(Intended, NI),
|
|
format("kb-merge: ROUND-TRIP FAILED -- wrote ~w terms, meant ~w~n", [NW, NI]),
|
|
die('output does not read back as the intended clauses')
|
|
).
|
|
|
|
% Reading a term back yields FRESH variables, so == compares variable identity
|
|
% rather than clause content. numbervars over a copy gives variant equality.
|
|
canon(Terms, Canon) :- copy_term(Terms, Canon), numbervars(Canon, 0, _).
|
|
|
|
count_only(KbFile) :-
|
|
terms_of(KbFile, Terms),
|
|
forall(member(P, [doctrine/2, invariant/2, decision/3, work_done/3]),
|
|
( findall(x, ( member(T, Terms), clause_pred(T, P) ), L),
|
|
length(L, N),
|
|
P = F/A,
|
|
format("~w/~w=~w~n", [F, A, N]) )),
|
|
length(Terms, Total),
|
|
format("total=~w~n", [Total]).
|
|
|
|
die(Msg) :- format("kb-merge: ~w~n", [Msg]), halt(2).
|
|
die_file(F) :- format("kb-merge: cannot read ~w~n", [F]), halt(2).
|