kibi-core 0.26.1 → 0.28.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/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
@@ -22,6 +26,9 @@
22
26
  check_adr_proposed/1,
23
27
  check_scenario_feasibility/1,
24
28
  check_scenario_feasibility_unknown/1,
29
+ check_scenario_outcome_mismatch/1,
30
+ negative_scenario_witness/3,
31
+ opposing_assumption/2,
25
32
  check_exception_claim_keys/1,
26
33
  check_numeric_string_values/1,
27
34
  check_rule_key_arguments_missing/1,
@@ -107,6 +114,7 @@ check_all(ViolationsDict) :-
107
114
  check_adr_proposed(AdrProposed),
108
115
  check_scenario_feasibility(ScenarioFeasibility),
109
116
  check_scenario_feasibility_unknown(ScenarioFeasibilityUnknown),
117
+ check_scenario_outcome_mismatch(ScenarioOutcomeMismatch),
110
118
  check_exception_claim_keys(ExceptionClaimKeys),
111
119
  check_numeric_string_values(NumericStringValues),
112
120
  check_domain_contradictions(Contradictions),
@@ -144,6 +152,7 @@ check_all(ViolationsDict) :-
144
152
  adr_proposed: AdrProposed,
145
153
  scenario_feasibility: ScenarioFeasibility,
146
154
  scenario_feasibility_unknown: ScenarioFeasibilityUnknown,
155
+ scenario_outcome_mismatch: ScenarioOutcomeMismatch,
147
156
  exception_claim_keys: ExceptionClaimKeys,
148
157
  numeric_string_value: NumericStringValues,
149
158
  domain_contradictions: Contradictions,
@@ -470,7 +479,7 @@ parse_json_object(Dict, Dict) :-
470
479
  !.
471
480
  parse_json_object(Raw, Dict) :-
472
481
  (atom(Raw) ; string(Raw)),
473
- catch(atom_json_dict(Raw, Dict, [value_string_as(string)]), _, fail),
482
+ json_text_term(Raw, Dict),
474
483
  is_dict(Dict).
475
484
 
476
485
  parse_json_list(Raw, Entries) :-
@@ -482,9 +491,48 @@ parse_json_list(^^(Value, _), Entries) :-
482
491
  parse_json_list(Value, Entries).
483
492
  parse_json_list(Raw, Entries) :-
484
493
  (atom(Raw) ; string(Raw)),
485
- catch(atom_json_dict(Raw, Entries, [value_string_as(string)]), _, fail),
494
+ json_text_term(Raw, Entries),
486
495
  is_list(Entries).
487
496
 
497
+ % The same JSON text (a rule body, a claim inventory, a receipt history) is
498
+ % parsed by several rules and by every requirement that links the fact, so
499
+ % the parsed term is kept by its text. Parsing is a pure function of the
500
+ % text, so the memo never goes stale; it is bounded so a long-lived engine
501
+ % cannot grow it without limit.
502
+ :- dynamic json_text_memo/2.
503
+ :- dynamic json_text_memo_size/1.
504
+
505
+ % mutalog-disable-next-line literal: the bound is a tuning value; any size gives the same answers
506
+ json_text_memo_limit(16384).
507
+
508
+ json_text_term(Raw, Term) :-
509
+ atom_string(Text, Raw),
510
+ ( json_text_memo(Text, Stored)
511
+ -> Stored = parsed(Term)
512
+ ; ( catch(atom_json_dict(Text, Parsed, [value_string_as(string)]), _, fail)
513
+ -> Stored = parsed(Parsed)
514
+ ; Stored = invalid
515
+ ),
516
+ json_text_remember(Text, Stored),
517
+ Stored = parsed(Term)
518
+ ).
519
+
520
+ json_text_remember(Text, Stored) :-
521
+ ( json_text_memo_size(Size)
522
+ -> true
523
+ ; Size = 0
524
+ ),
525
+ json_text_memo_limit(Limit),
526
+ ( Size >= Limit
527
+ -> retractall(json_text_memo(_, _)),
528
+ retractall(json_text_memo_size(_)),
529
+ Next = 1
530
+ ; retractall(json_text_memo_size(_)),
531
+ Next is Size + 1
532
+ ),
533
+ assertz(json_text_memo(Text, Stored)),
534
+ assertz(json_text_memo_size(Next)).
535
+
488
536
  %% check_no_dangling_refs(-Violations)
489
537
  % Finds all relationships referencing non-existent entities.
490
538
  check_no_dangling_refs(Violations) :-
@@ -1281,7 +1329,28 @@ analysis_outcome(analysis([], [], false, []), feasible).
1281
1329
  % advisory reasons in reporting order; ByException is true when some check is
1282
1330
  % satisfiable only because an approved exception waived a constraint;
1283
1331
  % NotApplicable is non-empty only when no assumption is governed at all.
1284
- scenario_feasibility_analysis(ScenarioId, analysis(Witnesses, Unknowns, ByException, NotApplicable)) :-
1332
+ % The analysis of a scenario is kept per KB generation: the blocking rule,
1333
+ % the unknown-feasibility rule and every requirement's proof ladder ask for
1334
+ % the same scenarios. Inside a transaction (what-if staging) the KB is not
1335
+ % the generation's, so the analysis is computed afresh.
1336
+ scenario_feasibility_analysis(ScenarioId, Analysis) :-
1337
+ ( nonvar(ScenarioId),
1338
+ \+ rdf_db:rdf_active_transaction(_),
1339
+ kb:index_verification_key(scenario_feasibility, Key)
1340
+ -> ( kb:kb_scenario_feasibility_memo(ScenarioId, Key, Analysis0)
1341
+ -> Analysis = Analysis0
1342
+ ; compute_scenario_feasibility_analysis(ScenarioId, Analysis),
1343
+ ( once(kb:kb_scenario_feasibility_memo(_, Stored, _)),
1344
+ Stored \== Key
1345
+ -> retractall(kb:kb_scenario_feasibility_memo(_, _, _))
1346
+ ; true
1347
+ ),
1348
+ assertz(kb:kb_scenario_feasibility_memo(ScenarioId, Key, Analysis))
1349
+ )
1350
+ ; compute_scenario_feasibility_analysis(ScenarioId, Analysis)
1351
+ ).
1352
+
1353
+ compute_scenario_feasibility_analysis(ScenarioId, analysis(Witnesses, Unknowns, ByException, NotApplicable)) :-
1285
1354
  findall(FactId, kb_relationship(assumes, ScenarioId, FactId), Assumed0),
1286
1355
  sort(Assumed0, Assumed),
1287
1356
  ( Assumed == []
@@ -2068,6 +2137,176 @@ bool_domain_exhausted(bool, Different) :-
2068
2137
  memberchk(true, Different),
2069
2138
  memberchk(false, Different).
2070
2139
 
2140
+ %% negative_scenario_witness(?ScenarioId, ?ReqId, -Witness)
2141
+ % implements REQ-kibi-happy-path-gap
2142
+ % A scenario that expects rejection or error is a targeted negative for a
2143
+ % requirement when its assumptions break that requirement: the feasibility
2144
+ % analysis finds a witness naming it, or (below) an assumption opposes one of
2145
+ % the requirement's claim facts directly. A rejection scenario whose assumptions
2146
+ % the requirements permit, or that an approved exception waives, exercises no
2147
+ % requirement's failure side.
2148
+ negative_scenario_witness(ScenarioId, ReqId,
2149
+ infeasibility(ReqIds, AssumedFacts, ReqFacts, Reason)) :-
2150
+ negative_scenario(ScenarioId),
2151
+ scenario_feasibility_analysis(ScenarioId, analysis(Witnesses, _, _, _)),
2152
+ member(infeasibility(ReqIds, AssumedFacts, ReqFacts, Reason), Witnesses),
2153
+ member(ReqId, ReqIds).
2154
+ % A scenario the requirement specifies may also target one of its claims
2155
+ % directly: it assumes a fact that opposes the claim's fact (a conflicting
2156
+ % property value or polarity, the opposite polarity of the same predicate),
2157
+ % or, for a rule over predicates, a fact instantiating one of the rule's
2158
+ % atoms, so the scenario is about the situation the rule governs.
2159
+ negative_scenario_witness(ScenarioId, ReqId, opposed(AssumedFact, ReqFact)) :-
2160
+ negative_scenario(ScenarioId),
2161
+ kb_relationship(specified_by, ReqId, ScenarioId),
2162
+ kb:current_req(ReqId),
2163
+ kb_relationship(assumes, ScenarioId, AssumedFact),
2164
+ member(Link, [requires_property, requires_predicate, requires_rule]),
2165
+ kb_relationship(Link, ReqId, ReqFact),
2166
+ facts_oppose(AssumedFact, ReqFact).
2167
+
2168
+ facts_oppose(AssumedFact, ReqFact) :-
2169
+ kb:fact_property_tuple(AssumedFact, Subject, Property, OpA, TypeA, ValueA, UnitA, ScopeA, PolarityA),
2170
+ kb:fact_property_tuple(ReqFact, Subject, Property, OpB, TypeB, ValueB, UnitB, ScopeB, PolarityB),
2171
+ !,
2172
+ kb:scope_intersects(ScopeA, ScopeB),
2173
+ ( kb:polarity_conflict(Subject, Property, OpA, TypeA, ValueA, UnitA, ScopeA, PolarityA,
2174
+ OpB, TypeB, ValueB, UnitB, ScopeB, PolarityB, _)
2175
+ -> true
2176
+ ; PolarityA == PolarityB,
2177
+ kb:property_conflict(Subject, Property, OpA, TypeA, ValueA, UnitA, PolarityA,
2178
+ OpB, TypeB, ValueB, UnitB, PolarityB, _)
2179
+ -> true
2180
+ ).
2181
+ facts_oppose(AssumedFact, ReqFact) :-
2182
+ kb:predicate_fact(AssumedFact, Namespace, Name, Args, PolarityA),
2183
+ kb:predicate_fact(ReqFact, Namespace, Name, Args, PolarityB),
2184
+ !,
2185
+ PolarityA \== PolarityB.
2186
+ facts_oppose(AssumedFact, ReqFact) :-
2187
+ kb:predicate_fact(AssumedFact, Namespace, Name, _, _),
2188
+ stored_rule_fact(ReqFact, _, Rule),
2189
+ sub_term(atom(Namespace, Name, _, _, _), Rule),
2190
+ !.
2191
+
2192
+ %% opposing_assumption(+FactId, -Assumption)
2193
+ % implements REQ-kibi-happy-path-gap
2194
+ % A fact a negative scenario can assume to oppose the claim fact FactId, as
2195
+ % the fields of a new fact: the boundary value just past a numeric bound, the
2196
+ % other boolean, a different string, a set missing a required member or
2197
+ % holding an unlisted or excluded one, the opposite polarity of a predicate,
2198
+ % or, for a rule over predicates, its head instantiated with the variable
2199
+ % names. Fails when no such fact can be derived (a matches pattern).
2200
+ opposing_assumption(FactId, Assumption) :-
2201
+ kb:fact_property_tuple(FactId, Subject, Property, Op, Type, Value, Unit, Scope, Polarity),
2202
+ !,
2203
+ ( Polarity == forbid
2204
+ -> Op1 = Op, Value1 = Value
2205
+ ; opposing_value(Type, Op, Value, Op1, Value1)
2206
+ ),
2207
+ Base = _{fact_kind: property_value, subject_key: Subject, property_key: Property,
2208
+ operator: Op1, value_type: Type, value: Value1},
2209
+ ( Unit == '' -> Assumption0 = Base ; Assumption0 = Base.put(unit, Unit) ),
2210
+ ( Scope == '' -> Assumption = Assumption0 ; Assumption = Assumption0.put(scope, Scope) ).
2211
+ opposing_assumption(FactId, _{fact_kind: predicate, predicate_namespace: Namespace,
2212
+ predicate_name: Name, predicate_args: Args, polarity: Opposite}) :-
2213
+ kb:predicate_fact(FactId, Namespace, Name, Args, Polarity),
2214
+ !,
2215
+ kb:opposite_predicate_polarity(Polarity, Opposite).
2216
+ opposing_assumption(FactId, _{fact_kind: predicate, predicate_namespace: Namespace,
2217
+ predicate_name: Name, predicate_args: Args, polarity: assert}) :-
2218
+ stored_rule_fact(FactId, _, rule(_, _, Head, Body, _, _, _, _, _, _)),
2219
+ ( Head = atom(Namespace, Name, Terms, _, _)
2220
+ -> true
2221
+ ; sub_term(atom(Namespace, Name, Terms, _, _), Body)
2222
+ ),
2223
+ !,
2224
+ maplist(term_constant, Terms, Args).
2225
+
2226
+ term_constant(Term, Constant) :-
2227
+ ( Term = var(Name, _)
2228
+ -> downcase_atom(Name, Constant)
2229
+ ; Term = const(Value, _)
2230
+ -> term_to_atom(Value, Constant)
2231
+ ; term_to_atom(Term, Constant)
2232
+ ).
2233
+
2234
+ opposing_value(bool, eq, Value, eq, Opposite) :-
2235
+ !,
2236
+ ( memberchk(Value, [true, "true", 'True']) -> Opposite = false ; Opposite = true ).
2237
+ opposing_value(Type, Op, Value, Op1, Value1) :-
2238
+ kb:is_numeric_type(Type),
2239
+ number(Value),
2240
+ !,
2241
+ opposing_number(Type, Op, Value, Op1, Value1).
2242
+ opposing_value(set, Op, Members, Op1, Value1) :-
2243
+ !,
2244
+ opposing_set(Op, Members, Op1, Value1).
2245
+ opposing_value(_, eq, Value, neq, Value) :- !.
2246
+ opposing_value(_, neq, Value, eq, Value).
2247
+
2248
+ opposing_number(_, gt, Bound, eq, Bound).
2249
+ opposing_number(_, lt, Bound, eq, Bound).
2250
+ opposing_number(int, gte, Bound, eq, Value) :- !, Value is Bound - 1.
2251
+ opposing_number(_, gte, Bound, lt, Bound).
2252
+ opposing_number(int, lte, Bound, eq, Value) :- !, Value is Bound + 1.
2253
+ opposing_number(_, lte, Bound, gt, Bound).
2254
+ opposing_number(_, eq, Value, neq, Value).
2255
+ opposing_number(_, neq, Value, eq, Value).
2256
+
2257
+ opposing_set(eq, Members, eq, Fewer) :- members_but_last(Members, Fewer).
2258
+ opposing_set(includes, Members, eq, Fewer) :- members_but_last(Members, Fewer).
2259
+ opposing_set(subset_of, _, includes, [unlisted_member]).
2260
+ opposing_set(excludes, [First|_], includes, [First]).
2261
+
2262
+ members_but_last(Members, Fewer) :-
2263
+ ( append(Fewer, [_], Members) -> true ; Fewer = [] ).
2264
+
2265
+ negative_scenario(ScenarioId) :-
2266
+ ( var(ScenarioId)
2267
+ -> findall(Id,
2268
+ (scenario_expects(Id, Outcome), memberchk(Outcome, [rejection, error])),
2269
+ Ids0),
2270
+ sort(Ids0, Ids),
2271
+ member(ScenarioId, Ids)
2272
+ ; scenario_expects(ScenarioId, Outcome),
2273
+ memberchk(Outcome, [rejection, error])
2274
+ ).
2275
+
2276
+ %% check_scenario_outcome_mismatch(-Violations)
2277
+ % implements REQ-kibi-happy-path-gap
2278
+ % A scenario that expects rejection or error but whose assumptions current
2279
+ % requirements govern and permit together aims at no failure: the knowledge
2280
+ % base predicts success for it. Only a decided feasible outcome is reported;
2281
+ % a scenario that assumes nothing, or whose assumptions no requirement
2282
+ % governs, is left alone.
2283
+ check_scenario_outcome_mismatch(Violations) :-
2284
+ findall(Violation, scenario_outcome_mismatch_violation(Violation), Unsorted),
2285
+ sort(Unsorted, Violations).
2286
+
2287
+ scenario_outcome_mismatch_violation(violation(
2288
+ 'scenario-outcome-mismatch',
2289
+ ScenarioId,
2290
+ Description,
2291
+ "Assume a value a current requirement forbids, so the scenario exercises that requirement's failure side, or set expects: success when the scenario describes a permitted situation",
2292
+ Source
2293
+ )) :-
2294
+ negative_scenario(ScenarioId),
2295
+ scenario_expects(ScenarioId, Expected),
2296
+ scenario_feasibility_analysis(ScenarioId, Analysis),
2297
+ analysis_outcome(Analysis, feasible),
2298
+ findall(FactId, kb_relationship(assumes, ScenarioId, FactId), FactIds0),
2299
+ sort(FactIds0, FactIds),
2300
+ atomic_list_concat(FactIds, ', ', FactText),
2301
+ format(string(Description),
2302
+ "Scenario expects ~w, but current requirements permit everything it assumes (~w), so the knowledge base predicts success",
2303
+ [Expected, FactText]),
2304
+ ( kb_entity(ScenarioId, scenario, Props),
2305
+ memberchk(source=Source, Props)
2306
+ -> true
2307
+ ; Source = ""
2308
+ ).
2309
+
2071
2310
  %% check_scenario_feasibility_unknown(-Violations)
2072
2311
  % implements REQ-kibi-scenario-feasibility-v2
2073
2312
  % Advisory: success scenarios whose feasibility the checker cannot decide.
@@ -2526,7 +2765,24 @@ check_domain_contradictions_and_witnesses(Violations, Witnesses) :-
2526
2765
  % Exact, JSON-safe evidence for every strict-property, ground-predicate, and
2527
2766
  % safe-rule contradiction result. Rule overlap that cannot be proven or
2528
2767
  % disproven remains status=unresolved rather than becoming consistency.
2768
+ %
2769
+ % kb_check, kb_coverage and the proof context each ask for every witness, so
2770
+ % the result is memoized per KB generation, like the entity property memo.
2771
+ % Inside an RDF transaction (the what-if staging) it is always recomputed.
2529
2772
  check_domain_contradiction_witnesses(Witnesses) :-
2773
+ ( \+ rdf_db:rdf_active_transaction(_),
2774
+ kb:index_verification_key(domain_contradictions, Key)
2775
+ -> ( kb:kb_contradiction_witness_memo(Key, Cached)
2776
+ -> Witnesses = Cached
2777
+ ; compute_domain_contradiction_witnesses(Computed),
2778
+ retractall(kb:kb_contradiction_witness_memo(_, _)),
2779
+ assertz(kb:kb_contradiction_witness_memo(Key, Computed)),
2780
+ Witnesses = Computed
2781
+ )
2782
+ ; compute_domain_contradiction_witnesses(Witnesses)
2783
+ ).
2784
+
2785
+ compute_domain_contradiction_witnesses(Witnesses) :-
2530
2786
  findall(Witness, req_conflict_witness(_ReqA, _ReqB, Witness), GroundWitnesses),
2531
2787
  functional_predicate_declarations(Functional),
2532
2788
  findall(Witness, rule_contradiction_witness(Functional, Witness), RuleWitnesses),
@@ -2701,22 +2957,42 @@ opposing_rule_requirements(ReqA, ReqB, RuleA, RuleB) :-
2701
2957
  % Each unordered pair of (requirement, rule fact) endpoints is compared once,
2702
2958
  % ordered by the requirement-fact pair: ordering requirements and facts
2703
2959
  % independently would never compare REQ-A->FACT-Z with REQ-Z->FACT-A.
2960
+ %
2961
+ % Only current requirements with requires_rule links can pair, so the pairs
2962
+ % are drawn from that list (in current_req/1 order, each with its rule links
2963
+ % in relationship order) rather than from every pair of current
2964
+ % requirements; solutions and their order are unchanged.
2704
2965
  opposing_rule_requirement_facts(ReqA, ReqB, FactA, FactB, RuleA, RuleB) :-
2705
- kb:current_req(ReqA),
2706
- kb:current_req(ReqB),
2966
+ rule_requirement_links(Links),
2967
+ member(ReqA-RulesA, Links),
2968
+ member(ReqB-RulesB, Links),
2707
2969
  ReqA \== ReqB,
2708
- kb_relationship(requires_rule, ReqA, FactA),
2709
- kb_relationship(requires_rule, ReqB, FactB),
2970
+ member(FactA-RuleA, RulesA),
2971
+ member(FactB-RuleB, RulesB),
2710
2972
  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).
2973
+ % mutalog-disable-next-line compare: equal endpoints need ReqA == ReqB, which is excluded above
2974
+ ReqA-FactA @< ReqB-FactB.
2975
+
2976
+ % Each requirement's rule facts with their parsed rules, read once rather
2977
+ % than once per pair: a link to a fact that is not a rule, or whose rule does
2978
+ % not parse, never pairs.
2979
+ rule_requirement_links(Links) :-
2980
+ findall(ReqId-Rules,
2981
+ ( kb:current_req(ReqId),
2982
+ findall(FactId-Rule,
2983
+ ( kb_relationship(requires_rule, ReqId, FactId),
2984
+ rule_fact_rule(FactId, Rule) ),
2985
+ Rules),
2986
+ % mutalog-disable-next-line goal_true,literal: a requirement without rules never pairs; this only prunes
2987
+ Rules \== [] ),
2988
+ Links).
2989
+
2990
+ rule_fact_rule(FactId, Rule) :-
2991
+ kb_entity(FactId, fact, Props),
2992
+ memberchk(fact_kind=Kind, Props),
2993
+ % mutalog-disable-next-line goal_true: only a rule fact carries a rule body to decode
2994
+ normalize_term_atom(Kind, rule),
2995
+ logic_rule_from_props(Props, Rule).
2720
2996
 
2721
2997
  rule_contradiction_witness(Functional, Witness) :-
2722
2998
  opposing_rule_requirement_facts(ReqA, ReqB, FactA, FactB, RuleA, RuleB),
@@ -3472,7 +3748,7 @@ inventory_entries(Raw, Entries) :-
3472
3748
  ( is_list(Raw) -> Entries = Raw
3473
3749
  ; Raw = ^^(Value, _) -> inventory_entries(Value, Entries)
3474
3750
  ; (atom(Raw) ; string(Raw)),
3475
- catch(atom_json_dict(Raw, JsonEntries, [value_string_as(string)]), _, fail),
3751
+ json_text_term(Raw, JsonEntries),
3476
3752
  is_list(JsonEntries)
3477
3753
  -> Entries = JsonEntries
3478
3754
  ; (atom(Raw) ; string(Raw)),
@@ -3653,6 +3929,7 @@ check_selected_dispatch(Rules, _{
3653
3929
  adr_proposed: AdrProposed,
3654
3930
  scenario_feasibility: ScenarioFeasibility,
3655
3931
  scenario_feasibility_unknown: ScenarioFeasibilityUnknown,
3932
+ scenario_outcome_mismatch: ScenarioOutcomeMismatch,
3656
3933
  exception_claim_keys: ExceptionClaimKeys,
3657
3934
  numeric_string_value: NumericStringValues,
3658
3935
  domain_contradictions: Contradictions,
@@ -3690,6 +3967,7 @@ check_selected_dispatch(Rules, _{
3690
3967
  selected_rule(Rules, 'adr-proposed', check_adr_proposed, AdrProposed),
3691
3968
  selected_rule(Rules, 'scenario-feasibility', check_scenario_feasibility, ScenarioFeasibility),
3692
3969
  selected_rule(Rules, 'scenario-feasibility-unknown', check_scenario_feasibility_unknown, ScenarioFeasibilityUnknown),
3970
+ selected_rule(Rules, 'scenario-outcome-mismatch', check_scenario_outcome_mismatch, ScenarioOutcomeMismatch),
3693
3971
  selected_rule(Rules, 'exception-claim-keys', check_exception_claim_keys, ExceptionClaimKeys),
3694
3972
  selected_rule(Rules, 'numeric-string-value', check_numeric_string_values, NumericStringValues),
3695
3973
  selected_rule(Rules, 'domain-contradictions', check_domain_contradictions, Contradictions),
@@ -3748,6 +4026,66 @@ check_all_json_with_options(JsonString, RequireAdr) :-
3748
4026
  JsonString
3749
4027
  ).
3750
4028
 
4029
+ %% check_all_json_file(+Path, +RequireAdr)
4030
+ % Writes the JSON check_all_json_with_options/2 would answer to Path.
4031
+ % The answer itself carries no violations, so a large KB's report is never
4032
+ % bounded by the engine's output capacity; the caller reads the file.
4033
+ check_all_json_file(Path, RequireAdr) :-
4034
+ check_all_with_options(ViolationsDict, RequireAdr),
4035
+ violations_json_to_file(ViolationsDict, Path).
4036
+
4037
+ %% check_selected_json_file(+Rules, +Path)
4038
+ % File-transport form of check_selected_json/2.
4039
+ check_selected_json_file(Rules, Path) :-
4040
+ check_selected(Rules, ViolationsDict),
4041
+ violations_json_to_file(ViolationsDict, Path).
4042
+
4043
+ %% check_all_json_transport(+Path, +RequireAdr, -Transport, -JsonString)
4044
+ % Writes the violations JSON to Path (Transport = file, JsonString = ''), or
4045
+ % answers it as JsonString (Transport = answer) when Path is `none` or the
4046
+ % file cannot be written: a full or read-only temp folder, or an engine that
4047
+ % cannot reach the caller's temp folder, must not fail a check that fits in
4048
+ % an answer.
4049
+ check_all_json_transport(Path, RequireAdr, Transport, JsonString) :-
4050
+ check_all_with_options(ViolationsDict, RequireAdr),
4051
+ violations_json_transport(ViolationsDict, Path, Transport, JsonString).
4052
+
4053
+ %% check_selected_json_transport(+Rules, +Path, -Transport, -JsonString)
4054
+ % check_all_json_transport/4 for the named rules only.
4055
+ check_selected_json_transport(Rules, Path, Transport, JsonString) :-
4056
+ check_selected(Rules, ViolationsDict),
4057
+ violations_json_transport(ViolationsDict, Path, Transport, JsonString).
4058
+
4059
+ violations_json_transport(ViolationsDict, Path, Transport, JsonString) :-
4060
+ ( Path \== none,
4061
+ catch(violations_json_to_file(ViolationsDict, Path), Error,
4062
+ ( file_transport_error(Error)
4063
+ -> fail
4064
+ ; throw(Error)
4065
+ ))
4066
+ -> Transport = file,
4067
+ JsonString = ''
4068
+ ; Transport = answer,
4069
+ violations_dict_to_json(ViolationsDict, JsonDict),
4070
+ with_output_to_string(
4071
+ json_write_dict(current_output, JsonDict, [width(0)]),
4072
+ JsonString
4073
+ )
4074
+ ).
4075
+
4076
+ file_transport_error(error(permission_error(_, _, _), _)).
4077
+ file_transport_error(error(existence_error(_, _), _)).
4078
+ file_transport_error(error(resource_error(_), _)).
4079
+ file_transport_error(error(io_error(_, _), _)).
4080
+
4081
+ violations_json_to_file(ViolationsDict, Path) :-
4082
+ violations_dict_to_json(ViolationsDict, JsonDict),
4083
+ setup_call_cleanup(
4084
+ open(Path, write, Stream, [encoding(utf8)]),
4085
+ json_write_dict(Stream, JsonDict, [width(0)]),
4086
+ close(Stream)
4087
+ ).
4088
+
3751
4089
  %% check_all_with_options(-ViolationsDict, +RequireAdr)
3752
4090
  % Returns a dict with all violations, respecting options.
3753
4091
  check_all_with_options(ViolationsDict, RequireAdr) :-
@@ -3764,6 +4102,7 @@ check_all_with_options(ViolationsDict, RequireAdr) :-
3764
4102
  check_adr_proposed(AdrProposed),
3765
4103
  check_scenario_feasibility(ScenarioFeasibility),
3766
4104
  check_scenario_feasibility_unknown(ScenarioFeasibilityUnknown),
4105
+ check_scenario_outcome_mismatch(ScenarioOutcomeMismatch),
3767
4106
  check_exception_claim_keys(ExceptionClaimKeys),
3768
4107
  check_numeric_string_values(NumericStringValues),
3769
4108
  check_domain_contradictions(Contradictions),
@@ -3800,6 +4139,7 @@ check_all_with_options(ViolationsDict, RequireAdr) :-
3800
4139
  adr_proposed: AdrProposed,
3801
4140
  scenario_feasibility: ScenarioFeasibility,
3802
4141
  scenario_feasibility_unknown: ScenarioFeasibilityUnknown,
4142
+ scenario_outcome_mismatch: ScenarioOutcomeMismatch,
3803
4143
  exception_claim_keys: ExceptionClaimKeys,
3804
4144
  numeric_string_value: NumericStringValues,
3805
4145
  domain_contradictions: Contradictions,
@@ -35,7 +35,10 @@
35
35
  % has each member as a required (or nullable)
36
36
  % field; with property middleware every
37
37
  % selected route's middleware stands in the
38
- % relation
38
+ % relation; with property precedes and
39
+ % operator includes each member "A -> B"
40
+ % holds in the list read: A and B are in it
41
+ % and every A comes before every B
39
42
  % set fact under state_machine with operator includes, subset_of or
40
43
  % excludes, or with property reachable_pairs or required_waypoints
41
44
  % state_machine the projection stands in the operator's
@@ -230,6 +233,14 @@ source_obligation(FactId, Members, Operator, Result) :-
230
233
  )
231
234
  ; Property == middleware
232
235
  -> Result = obligation(_{kind: "route_middleware", operator: OperatorString, members: Members})
236
+ ; Property == precedes
237
+ -> ( Operator == includes
238
+ -> Result = obligation(_{kind: "sequence_precedes", members: Members})
239
+ ; format(string(Reason),
240
+ "~w is a precedes fact with operator ~w; a source proof decides precedes only with includes (each member \"A -> B\" holds)",
241
+ [FactId, Operator]),
242
+ Result = incompatible(Reason)
243
+ )
233
244
  ; Result = obligation(_{kind: "set_relation", operator: OperatorString, members: Members})
234
245
  ).
235
246
 
@@ -350,6 +361,10 @@ obligation_key(Obligation, relation(Operator, Members)) :-
350
361
  dict_text(Obligation, operator, Operator),
351
362
  obligation_members(Obligation, Members),
352
363
  !.
364
+ obligation_key(Obligation, precedes(Members)) :-
365
+ dict_text(Obligation, kind, "sequence_precedes"),
366
+ obligation_members(Obligation, Members),
367
+ !.
353
368
  obligation_key(Obligation, middleware(Operator, Members)) :-
354
369
  dict_text(Obligation, kind, "route_middleware"),
355
370
  dict_text(Obligation, operator, Operator),
@@ -291,6 +291,10 @@ kind_problem(source, Certificate, Reason) :-
291
291
  ; get_dict(routes, Model, Routes),
292
292
  \+ ( is_list(Routes), maplist(source_route_ok, Routes) )
293
293
  -> Reason = "every source route must have a method, a path and a middleware list"
294
+ ; get_dict(sequence, Model, Sequence),
295
+ \+ ( is_list(Sequence), forall(member(Item, Sequence), string(Item)),
296
+ sort(Sequence, Members) )
297
+ -> Reason = "the source model's sequence must list the members in the order read"
294
298
  ).
295
299
 
296
300
  sorted_distinct_strings(List) :-