kibi-core 0.24.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-core",
3
- "version": "0.24.0",
3
+ "version": "0.27.0",
4
4
  "private": false,
5
5
  "description": "Core Prolog modules and RDF graph logic for Kibi",
6
6
  "type": "module",
@@ -157,6 +157,8 @@ validate_fact_shape(property_value, Props) :-
157
157
  valid_operator(Op),
158
158
  memberchk(value_type=VT, Props),
159
159
  valid_value_type(VT),
160
+ ( Op == matches -> VT == string ; true ),
161
+ ( memberchk(Op, [includes, subset_of, excludes]) -> VT == set ; true ),
160
162
  exactly_one_value_field(Props),
161
163
  value_type_matches_field(VT, Props),
162
164
  valid_optional_fact_enums(Props),
@@ -225,7 +227,7 @@ text_atom(Value, Atom) :- atom(Value), !, Atom = Value.
225
227
  text_atom(Value, Atom) :- string(Value), atom_string(Atom, Value).
226
228
 
227
229
  % valid_operator(+Op)
228
- valid_operator(Op) :- memberchk(Op, [eq, neq, lt, lte, gt, gte]), !.
230
+ valid_operator(Op) :- memberchk(Op, [eq, neq, lt, lte, gt, gte, matches, includes, subset_of, excludes]), !.
229
231
 
230
232
  % valid_value_type(+VT)
231
233
  valid_value_type(ValueType) :- memberchk(ValueType, [string, int, number, bool, set]), !.
@@ -204,7 +204,7 @@ context_domain(Members, Domain, Reason) :-
204
204
  ; Types == [string]
205
205
  -> Domain = string, context_scalar_reason(Members, Reason)
206
206
  ; Types == [set]
207
- -> Domain = set, context_scalar_reason(Members, Reason)
207
+ -> Domain = set, context_set_reason(Members, Reason)
208
208
  ; Types = [_, _|_]
209
209
  -> Domain = none, Reason = "the facts use different value types"
210
210
  ; Domain = none, Reason = "the value type is not supported"
@@ -228,6 +228,25 @@ context_scalar_reason(Members, Reason) :-
228
228
  ; Reason = none
229
229
  ).
230
230
 
231
+ % Sets also compare with includes (every member of the bound has the
232
+ % property), subset_of (every member with the property is in the bound) and
233
+ % excludes (no member of the bound has it), but only as requirements: a
234
+ % forbidden one has no single negation among the set operators. neq beside
235
+ % subset_of or excludes is left unresolved: the finite candidates below decide
236
+ % those only without neq.
237
+ context_set_reason(Members, Reason) :-
238
+ ( member(_-tuple(_, _, Op, _, _, _, _, Polarity, _, _), Members),
239
+ ( \+ memberchk(Op, [eq, neq, includes, subset_of, excludes])
240
+ ; memberchk(Op, [includes, subset_of, excludes]), Polarity == forbid
241
+ )
242
+ -> Reason = "only eq, neq and a required includes, subset_of or excludes compare set values"
243
+ ; member(_-tuple(_, _, neq, _, _, _, _, _, _, _), Members),
244
+ member(_-tuple(_, _, Bounding, _, _, _, _, _, _, _), Members),
245
+ memberchk(Bounding, [subset_of, excludes])
246
+ -> Reason = "neq beside subset_of or excludes on one set property is not compared"
247
+ ; Reason = none
248
+ ).
249
+
231
250
  context_member(_Domain, Fact-tuple(_, _, Op0, _, Value, _, _, Polarity, From, To), m(Fact, Op, Value, From, To)) :-
232
251
  ( Polarity == forbid
233
252
  -> numeric_constraint_negation(c(Op0, _, Value), c(Op, _, _))
@@ -250,13 +269,17 @@ certificate_member_holds(Domain, m(_, Op, Bound, _, _), Value) :-
250
269
  ; Op == neq -> ValueText \== BoundText
251
270
  ).
252
271
 
253
- % A set value is the exact set of allowed values: eq holds for the same
254
- % members in any order, neq for any other set.
272
+ % A set value is the exact set of members with the property: eq holds for
273
+ % the same members in any order, neq for any other set, includes for any set
274
+ % that contains every member of the bound.
255
275
  certificate_member_holds(set, m(_, Op, Bound, _, _), Value) :-
256
276
  set_texts(Value, ValueTexts),
257
277
  set_texts(Bound, BoundTexts),
258
278
  ( Op == eq -> ValueTexts == BoundTexts
259
279
  ; Op == neq -> ValueTexts \== BoundTexts
280
+ ; Op == includes -> subtract(BoundTexts, ValueTexts, [])
281
+ ; Op == subset_of -> subtract(ValueTexts, BoundTexts, [])
282
+ ; Op == excludes -> \+ ( member(Text, ValueTexts), memberchk(Text, BoundTexts) )
260
283
  ).
261
284
 
262
285
  %% set_texts(+Value, -Texts)
@@ -308,7 +331,18 @@ scalar_candidates(set, Members, Candidates) :-
308
331
  sort(Texts0, Texts),
309
332
  atomic_list_concat(["kibi:fresh"|Texts], '|', FreshAtom),
310
333
  atom_string(FreshAtom, Fresh),
311
- append(Sets, [[Fresh]], Candidates).
334
+ % The union of every bound satisfies any set of includes facts alone; the
335
+ % union of the includes bounds is the least set every includes fact
336
+ % allows, so with subset_of and excludes (and no eq or neq) it is
337
+ % satisfiable exactly when that union is.
338
+ includes_union(Members, Included),
339
+ append(Sets, [Texts, Included, [Fresh]], Candidates).
340
+
341
+ includes_union(Members, Union) :-
342
+ findall(Text,
343
+ (member(m(_, includes, Bound, _, _), Members), set_texts(Bound, Set), member(Text, Set)),
344
+ Texts),
345
+ sort(Texts, Union).
312
346
 
313
347
  property_theorem(Contexts, Numbered, Theorem, Used, Rejected) :-
314
348
  findall(Index-Certificate,
@@ -125,9 +125,17 @@ model_candidates(string, Members, Candidates) :-
125
125
  model_candidates(set, Members, Candidates) :-
126
126
  findall(Set, (member(m(_, _, Bound, _, _), Members), set_texts(Bound, Set)), Sets0),
127
127
  sort(Sets0, Sets),
128
- findall(Text, (member(Set, Sets), member(Text, Set)), Texts),
128
+ findall(Text, (member(Set, Sets), member(Text, Set)), Texts0),
129
+ sort(Texts0, Texts),
129
130
  fresh_text(Texts, 0, Fresh),
130
- append(Sets, [[Fresh]], Candidates).
131
+ % The union of every bound satisfies any set of includes facts alone;
132
+ % the union of the includes bounds alone is the least set they allow,
133
+ % which subset_of and excludes facts accept when anything does.
134
+ findall(Text,
135
+ (member(m(_, includes, Bound, _, _), Members), set_texts(Bound, Set), member(Text, Set)),
136
+ Included0),
137
+ sort(Included0, Included),
138
+ append(Sets, [Texts, Included, [Fresh]], Candidates).
131
139
 
132
140
  fresh_text(Bounds, Index, Fresh) :-
133
141
  format(string(Candidate), "kibi:model:~w", [Index]),
package/src/checks.pl CHANGED
@@ -6,7 +6,11 @@
6
6
  :- module(checks, [
7
7
  check_all/1, % Returns all violations as a dict
8
8
  check_all_json/1, % Returns all violations as JSON string
9
+ check_all_json_file/2, % Writes all violations as JSON to a file
9
10
  check_selected_json/2, % Returns only selected rule violations as JSON
11
+ check_selected_json_file/2, % Writes selected rule violations as JSON to a file
12
+ check_all_json_transport/4, % File transport with an answer fallback
13
+ check_selected_json_transport/4, % Same, for selected rules
10
14
  check_must_priority_coverage/1, % Returns list of must-priority violations
11
15
  check_symbol_coverage/1, % Returns list of uncovered symbols
12
16
  check_changed_symbol_coverage/2, % symbol-coverage restricted to a staged change set
@@ -470,7 +474,7 @@ parse_json_object(Dict, Dict) :-
470
474
  !.
471
475
  parse_json_object(Raw, Dict) :-
472
476
  (atom(Raw) ; string(Raw)),
473
- catch(atom_json_dict(Raw, Dict, [value_string_as(string)]), _, fail),
477
+ json_text_term(Raw, Dict),
474
478
  is_dict(Dict).
475
479
 
476
480
  parse_json_list(Raw, Entries) :-
@@ -482,9 +486,48 @@ parse_json_list(^^(Value, _), Entries) :-
482
486
  parse_json_list(Value, Entries).
483
487
  parse_json_list(Raw, Entries) :-
484
488
  (atom(Raw) ; string(Raw)),
485
- catch(atom_json_dict(Raw, Entries, [value_string_as(string)]), _, fail),
489
+ json_text_term(Raw, Entries),
486
490
  is_list(Entries).
487
491
 
492
+ % The same JSON text (a rule body, a claim inventory, a receipt history) is
493
+ % parsed by several rules and by every requirement that links the fact, so
494
+ % the parsed term is kept by its text. Parsing is a pure function of the
495
+ % text, so the memo never goes stale; it is bounded so a long-lived engine
496
+ % cannot grow it without limit.
497
+ :- dynamic json_text_memo/2.
498
+ :- dynamic json_text_memo_size/1.
499
+
500
+ % mutalog-disable-next-line literal: the bound is a tuning value; any size gives the same answers
501
+ json_text_memo_limit(16384).
502
+
503
+ json_text_term(Raw, Term) :-
504
+ atom_string(Text, Raw),
505
+ ( json_text_memo(Text, Stored)
506
+ -> Stored = parsed(Term)
507
+ ; ( catch(atom_json_dict(Text, Parsed, [value_string_as(string)]), _, fail)
508
+ -> Stored = parsed(Parsed)
509
+ ; Stored = invalid
510
+ ),
511
+ json_text_remember(Text, Stored),
512
+ Stored = parsed(Term)
513
+ ).
514
+
515
+ json_text_remember(Text, Stored) :-
516
+ ( json_text_memo_size(Size)
517
+ -> true
518
+ ; Size = 0
519
+ ),
520
+ json_text_memo_limit(Limit),
521
+ ( Size >= Limit
522
+ -> retractall(json_text_memo(_, _)),
523
+ retractall(json_text_memo_size(_)),
524
+ Next = 1
525
+ ; retractall(json_text_memo_size(_)),
526
+ Next is Size + 1
527
+ ),
528
+ assertz(json_text_memo(Text, Stored)),
529
+ assertz(json_text_memo_size(Next)).
530
+
488
531
  %% check_no_dangling_refs(-Violations)
489
532
  % Finds all relationships referencing non-existent entities.
490
533
  check_no_dangling_refs(Violations) :-
@@ -1281,7 +1324,28 @@ analysis_outcome(analysis([], [], false, []), feasible).
1281
1324
  % advisory reasons in reporting order; ByException is true when some check is
1282
1325
  % satisfiable only because an approved exception waived a constraint;
1283
1326
  % NotApplicable is non-empty only when no assumption is governed at all.
1284
- scenario_feasibility_analysis(ScenarioId, analysis(Witnesses, Unknowns, ByException, NotApplicable)) :-
1327
+ % The analysis of a scenario is kept per KB generation: the blocking rule,
1328
+ % the unknown-feasibility rule and every requirement's proof ladder ask for
1329
+ % the same scenarios. Inside a transaction (what-if staging) the KB is not
1330
+ % the generation's, so the analysis is computed afresh.
1331
+ scenario_feasibility_analysis(ScenarioId, Analysis) :-
1332
+ ( nonvar(ScenarioId),
1333
+ \+ rdf_db:rdf_active_transaction(_),
1334
+ kb:index_verification_key(scenario_feasibility, Key)
1335
+ -> ( kb:kb_scenario_feasibility_memo(ScenarioId, Key, Analysis0)
1336
+ -> Analysis = Analysis0
1337
+ ; compute_scenario_feasibility_analysis(ScenarioId, Analysis),
1338
+ ( once(kb:kb_scenario_feasibility_memo(_, Stored, _)),
1339
+ Stored \== Key
1340
+ -> retractall(kb:kb_scenario_feasibility_memo(_, _, _))
1341
+ ; true
1342
+ ),
1343
+ assertz(kb:kb_scenario_feasibility_memo(ScenarioId, Key, Analysis))
1344
+ )
1345
+ ; compute_scenario_feasibility_analysis(ScenarioId, Analysis)
1346
+ ).
1347
+
1348
+ compute_scenario_feasibility_analysis(ScenarioId, analysis(Witnesses, Unknowns, ByException, NotApplicable)) :-
1285
1349
  findall(FactId, kb_relationship(assumes, ScenarioId, FactId), Assumed0),
1286
1350
  sort(Assumed0, Assumed),
1287
1351
  ( Assumed == []
@@ -2526,7 +2590,24 @@ check_domain_contradictions_and_witnesses(Violations, Witnesses) :-
2526
2590
  % Exact, JSON-safe evidence for every strict-property, ground-predicate, and
2527
2591
  % safe-rule contradiction result. Rule overlap that cannot be proven or
2528
2592
  % disproven remains status=unresolved rather than becoming consistency.
2593
+ %
2594
+ % kb_check, kb_coverage and the proof context each ask for every witness, so
2595
+ % the result is memoized per KB generation, like the entity property memo.
2596
+ % Inside an RDF transaction (the what-if staging) it is always recomputed.
2529
2597
  check_domain_contradiction_witnesses(Witnesses) :-
2598
+ ( \+ rdf_db:rdf_active_transaction(_),
2599
+ kb:index_verification_key(domain_contradictions, Key)
2600
+ -> ( kb:kb_contradiction_witness_memo(Key, Cached)
2601
+ -> Witnesses = Cached
2602
+ ; compute_domain_contradiction_witnesses(Computed),
2603
+ retractall(kb:kb_contradiction_witness_memo(_, _)),
2604
+ assertz(kb:kb_contradiction_witness_memo(Key, Computed)),
2605
+ Witnesses = Computed
2606
+ )
2607
+ ; compute_domain_contradiction_witnesses(Witnesses)
2608
+ ).
2609
+
2610
+ compute_domain_contradiction_witnesses(Witnesses) :-
2530
2611
  findall(Witness, req_conflict_witness(_ReqA, _ReqB, Witness), GroundWitnesses),
2531
2612
  functional_predicate_declarations(Functional),
2532
2613
  findall(Witness, rule_contradiction_witness(Functional, Witness), RuleWitnesses),
@@ -2701,22 +2782,42 @@ opposing_rule_requirements(ReqA, ReqB, RuleA, RuleB) :-
2701
2782
  % Each unordered pair of (requirement, rule fact) endpoints is compared once,
2702
2783
  % ordered by the requirement-fact pair: ordering requirements and facts
2703
2784
  % independently would never compare REQ-A->FACT-Z with REQ-Z->FACT-A.
2785
+ %
2786
+ % Only current requirements with requires_rule links can pair, so the pairs
2787
+ % are drawn from that list (in current_req/1 order, each with its rule links
2788
+ % in relationship order) rather than from every pair of current
2789
+ % requirements; solutions and their order are unchanged.
2704
2790
  opposing_rule_requirement_facts(ReqA, ReqB, FactA, FactB, RuleA, RuleB) :-
2705
- kb:current_req(ReqA),
2706
- kb:current_req(ReqB),
2791
+ rule_requirement_links(Links),
2792
+ member(ReqA-RulesA, Links),
2793
+ member(ReqB-RulesB, Links),
2707
2794
  ReqA \== ReqB,
2708
- kb_relationship(requires_rule, ReqA, FactA),
2709
- kb_relationship(requires_rule, ReqB, FactB),
2795
+ member(FactA-RuleA, RulesA),
2796
+ member(FactB-RuleB, RulesB),
2710
2797
  FactA \== FactB,
2711
- ReqA-FactA @< ReqB-FactB,
2712
- kb_entity(FactA, fact, PropsA),
2713
- kb_entity(FactB, fact, PropsB),
2714
- memberchk(fact_kind=KindA, PropsA),
2715
- memberchk(fact_kind=KindB, PropsB),
2716
- normalize_term_atom(KindA, rule),
2717
- normalize_term_atom(KindB, rule),
2718
- logic_rule_from_props(PropsA, RuleA),
2719
- logic_rule_from_props(PropsB, RuleB).
2798
+ % mutalog-disable-next-line compare: equal endpoints need ReqA == ReqB, which is excluded above
2799
+ ReqA-FactA @< ReqB-FactB.
2800
+
2801
+ % Each requirement's rule facts with their parsed rules, read once rather
2802
+ % than once per pair: a link to a fact that is not a rule, or whose rule does
2803
+ % not parse, never pairs.
2804
+ rule_requirement_links(Links) :-
2805
+ findall(ReqId-Rules,
2806
+ ( kb:current_req(ReqId),
2807
+ findall(FactId-Rule,
2808
+ ( kb_relationship(requires_rule, ReqId, FactId),
2809
+ rule_fact_rule(FactId, Rule) ),
2810
+ Rules),
2811
+ % mutalog-disable-next-line goal_true,literal: a requirement without rules never pairs; this only prunes
2812
+ Rules \== [] ),
2813
+ Links).
2814
+
2815
+ rule_fact_rule(FactId, Rule) :-
2816
+ kb_entity(FactId, fact, Props),
2817
+ memberchk(fact_kind=Kind, Props),
2818
+ % mutalog-disable-next-line goal_true: only a rule fact carries a rule body to decode
2819
+ normalize_term_atom(Kind, rule),
2820
+ logic_rule_from_props(Props, Rule).
2720
2821
 
2721
2822
  rule_contradiction_witness(Functional, Witness) :-
2722
2823
  opposing_rule_requirement_facts(ReqA, ReqB, FactA, FactB, RuleA, RuleB),
@@ -3472,7 +3573,7 @@ inventory_entries(Raw, Entries) :-
3472
3573
  ( is_list(Raw) -> Entries = Raw
3473
3574
  ; Raw = ^^(Value, _) -> inventory_entries(Value, Entries)
3474
3575
  ; (atom(Raw) ; string(Raw)),
3475
- catch(atom_json_dict(Raw, JsonEntries, [value_string_as(string)]), _, fail),
3576
+ json_text_term(Raw, JsonEntries),
3476
3577
  is_list(JsonEntries)
3477
3578
  -> Entries = JsonEntries
3478
3579
  ; (atom(Raw) ; string(Raw)),
@@ -3748,6 +3849,66 @@ check_all_json_with_options(JsonString, RequireAdr) :-
3748
3849
  JsonString
3749
3850
  ).
3750
3851
 
3852
+ %% check_all_json_file(+Path, +RequireAdr)
3853
+ % Writes the JSON check_all_json_with_options/2 would answer to Path.
3854
+ % The answer itself carries no violations, so a large KB's report is never
3855
+ % bounded by the engine's output capacity; the caller reads the file.
3856
+ check_all_json_file(Path, RequireAdr) :-
3857
+ check_all_with_options(ViolationsDict, RequireAdr),
3858
+ violations_json_to_file(ViolationsDict, Path).
3859
+
3860
+ %% check_selected_json_file(+Rules, +Path)
3861
+ % File-transport form of check_selected_json/2.
3862
+ check_selected_json_file(Rules, Path) :-
3863
+ check_selected(Rules, ViolationsDict),
3864
+ violations_json_to_file(ViolationsDict, Path).
3865
+
3866
+ %% check_all_json_transport(+Path, +RequireAdr, -Transport, -JsonString)
3867
+ % Writes the violations JSON to Path (Transport = file, JsonString = ''), or
3868
+ % answers it as JsonString (Transport = answer) when Path is `none` or the
3869
+ % file cannot be written: a full or read-only temp folder, or an engine that
3870
+ % cannot reach the caller's temp folder, must not fail a check that fits in
3871
+ % an answer.
3872
+ check_all_json_transport(Path, RequireAdr, Transport, JsonString) :-
3873
+ check_all_with_options(ViolationsDict, RequireAdr),
3874
+ violations_json_transport(ViolationsDict, Path, Transport, JsonString).
3875
+
3876
+ %% check_selected_json_transport(+Rules, +Path, -Transport, -JsonString)
3877
+ % check_all_json_transport/4 for the named rules only.
3878
+ check_selected_json_transport(Rules, Path, Transport, JsonString) :-
3879
+ check_selected(Rules, ViolationsDict),
3880
+ violations_json_transport(ViolationsDict, Path, Transport, JsonString).
3881
+
3882
+ violations_json_transport(ViolationsDict, Path, Transport, JsonString) :-
3883
+ ( Path \== none,
3884
+ catch(violations_json_to_file(ViolationsDict, Path), Error,
3885
+ ( file_transport_error(Error)
3886
+ -> fail
3887
+ ; throw(Error)
3888
+ ))
3889
+ -> Transport = file,
3890
+ JsonString = ''
3891
+ ; Transport = answer,
3892
+ violations_dict_to_json(ViolationsDict, JsonDict),
3893
+ with_output_to_string(
3894
+ json_write_dict(current_output, JsonDict, [width(0)]),
3895
+ JsonString
3896
+ )
3897
+ ).
3898
+
3899
+ file_transport_error(error(permission_error(_, _, _), _)).
3900
+ file_transport_error(error(existence_error(_, _), _)).
3901
+ file_transport_error(error(resource_error(_), _)).
3902
+ file_transport_error(error(io_error(_, _), _)).
3903
+
3904
+ violations_json_to_file(ViolationsDict, Path) :-
3905
+ violations_dict_to_json(ViolationsDict, JsonDict),
3906
+ setup_call_cleanup(
3907
+ open(Path, write, Stream, [encoding(utf8)]),
3908
+ json_write_dict(Stream, JsonDict, [width(0)]),
3909
+ close(Stream)
3910
+ ).
3911
+
3751
3912
  %% check_all_with_options(-ViolationsDict, +RequireAdr)
3752
3913
  % Returns a dict with all violations, respecting options.
3753
3914
  check_all_with_options(ViolationsDict, RequireAdr) :-