kibi-core 0.22.4 → 0.23.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.
@@ -0,0 +1,756 @@
1
+ % Module: certificate_check
2
+ % implements REQ-kibi-kb-certificates
3
+ %
4
+ % The independent checker behind `kibi verify`. It states each KB theorem
5
+ % over the facts current requirements name and re-checks a certificate for
6
+ % it. It never searches: a model is checked by evaluating every constraint
7
+ % against the model's value, a contradiction core by re-running the interval
8
+ % decision procedure on the core alone (plus one model per dropped fact for
9
+ % minimality), and a stratification by checking every dependency edge
10
+ % against the stratum numbers. It does not load checks.pl or the emitter
11
+ % (certificates.pl), so a bug in the search that finds certificates cannot
12
+ % make a wrong certificate pass.
13
+ %
14
+ % Theorems and their certificates (kibi.certificates.v1):
15
+ %
16
+ % properties_consistent One property_model per (subject, property, scope
17
+ % context): a value that satisfies every fact in the
18
+ % context. Otherwise a property_contradiction (an
19
+ % irreducible core with one model per dropped fact),
20
+ % or property_unresolved when the core's validity
21
+ % windows never overlap.
22
+ % predicates_consistent One predicate_model per ground atom (true or
23
+ % false), or a predicate_contradiction pair.
24
+ % decisions_consistent One decision_model per decision key (allow or
25
+ % deny), or a decision_contradiction pair.
26
+ % rules_stratified A rule_strata assignment, or a rule_negation_cycle.
27
+ %
28
+ % A context is a set of facts on one subject and property that can be
29
+ % compared: an unscoped fact belongs to every scope context, a scoped fact
30
+ % only to its own. A context the checker cannot compare (mixed value types,
31
+ % mixed units, an unsupported operator or polarity) is unresolved and needs
32
+ % no certificate; it never counts as consistent.
33
+
34
+ :- module(certificate_check, [
35
+ certificate_property_contexts/1,
36
+ certificate_predicate_groups/1,
37
+ certificate_decision_groups/1,
38
+ certificate_rule_edges/1,
39
+ certificate_member_holds/3,
40
+ certificate_members_satisfiable/2,
41
+ verify_certificates/2,
42
+ kb_verify_certificates_file/2
43
+ ]).
44
+
45
+ :- encoding(utf8).
46
+
47
+ :- use_module(library(http/json)).
48
+ :- use_module(library(apply)).
49
+ :- use_module(library(lists)).
50
+ :- use_module(library(pairs)).
51
+ :- use_module('kb.pl', [kb_entity/3, normalize_term_atom/2]).
52
+ :- use_module('intervals.pl', [
53
+ numeric_constraints_satisfiable/2,
54
+ numeric_constraint_holds/3,
55
+ numeric_constraint_negation/2
56
+ ]).
57
+ :- use_module('logic_ir.pl', [logic_rule_from_props/2]).
58
+
59
+ certificates_version('kibi.certificates.v1').
60
+ verification_version('kibi.verification.v1').
61
+
62
+ %% kb_verify_certificates_file(+Path, -JsonString)
63
+ % Read a certificate bundle from Path, verify it against the current KB and
64
+ % return the verification report as a JSON string.
65
+ kb_verify_certificates_file(Path, JsonString) :-
66
+ setup_call_cleanup(
67
+ open(Path, read, Stream, [encoding(utf8)]),
68
+ json_read_dict(Stream, Bundle, [value_string_as(string)]),
69
+ close(Stream)),
70
+ verify_certificates(Bundle, Report),
71
+ with_output_to(string(JsonString), json_write_dict(current_output, Report, [width(0)])).
72
+
73
+ %% verify_certificates(+Bundle, -Report)
74
+ % Bundle is a kibi.certificates.v1 dict as read from JSON. Report has
75
+ % status (verified or rejected), one entry per theorem with its verdict
76
+ % (proved, disproved or unresolved), and the rejected certificates.
77
+ verify_certificates(Bundle, Report) :-
78
+ verification_version(Version),
79
+ ( bundle_certificates(Bundle, Certificates)
80
+ -> numbered(Certificates, Numbered),
81
+ certificate_property_contexts(Contexts),
82
+ certificate_predicate_groups(AtomGroups),
83
+ certificate_decision_groups(DecisionGroups),
84
+ certificate_rule_edges(Edges),
85
+ property_theorem(Contexts, Numbered, PropertyTheorem, _, PropertyRejected),
86
+ group_theorem(predicates_consistent, predicate, AtomGroups, Numbered, PredicateTheorem, _, PredicateRejected),
87
+ group_theorem(decisions_consistent, decision, DecisionGroups, Numbered, DecisionTheorem, _, DecisionRejected),
88
+ rules_theorem(Edges, Numbered, RulesTheorem, _, RulesRejected),
89
+ findall(_{certificate: Index, kind: Kind, reason: "unknown certificate kind"},
90
+ ( member(Index-Certificate, Numbered),
91
+ certificate_kind(Certificate, Kind),
92
+ \+ known_kind(Kind)
93
+ ),
94
+ Unmatched),
95
+ append([PropertyRejected, PredicateRejected, DecisionRejected, RulesRejected, Unmatched], Rejected0),
96
+ sort(certificate, @=<, Rejected0, Rejected),
97
+ Theorems = [PropertyTheorem, PredicateTheorem, DecisionTheorem, RulesTheorem]
98
+ ; Theorems = [],
99
+ Rejected = [_{certificate: 0, kind: bundle, reason: "not a kibi.certificates.v1 bundle"}],
100
+ Numbered = []
101
+ ),
102
+ length(Numbered, Count),
103
+ findall(Index, (member(Failure, Rejected), Index = Failure.certificate, Index > 0), FailedIndexes0),
104
+ sort(FailedIndexes0, FailedIndexes),
105
+ length(FailedIndexes, FailedCount),
106
+ Verified is Count - FailedCount,
107
+ ( Rejected == [] -> Status = verified ; Status = rejected ),
108
+ Report = _{
109
+ version: Version,
110
+ status: Status,
111
+ certificates: Count,
112
+ verified: Verified,
113
+ rejected: Rejected,
114
+ theorems: Theorems,
115
+ notCovered: [rule_conflicts, scenario_feasibility]
116
+ }.
117
+
118
+ bundle_certificates(Bundle, Certificates) :-
119
+ is_dict(Bundle),
120
+ get_dict(version, Bundle, Version),
121
+ certificates_version(Expected),
122
+ text_equal(Version, Expected),
123
+ get_dict(certificates, Bundle, Certificates),
124
+ is_list(Certificates),
125
+ forall(member(Certificate, Certificates), is_dict(Certificate)).
126
+
127
+ numbered(Items, Numbered) :-
128
+ findall(Index-Item, nth1(Index, Items, Item), Numbered).
129
+
130
+ known_kind(Kind) :-
131
+ memberchk(Kind, [property_model, property_contradiction, property_unresolved,
132
+ predicate_model, predicate_contradiction,
133
+ decision_model, decision_contradiction,
134
+ rule_strata, rule_negation_cycle]).
135
+
136
+ certificate_kind(Certificate, Kind) :-
137
+ ( get_dict(kind, Certificate, Raw), text_atom(Raw, Kind) -> true ; Kind = unknown ).
138
+
139
+ kind_is(Certificate, Kind) :-
140
+ certificate_kind(Certificate, Kind0),
141
+ Kind0 == Kind.
142
+
143
+ % ------------------------------------------------------------------
144
+ % Property facts
145
+ % ------------------------------------------------------------------
146
+
147
+ %% certificate_property_contexts(-Contexts)
148
+ % Every comparable context: context(Subject, Property, Scope, Domain, Members)
149
+ % or unresolved(Subject, Property, Scope, Reason). A member is
150
+ % m(FactId, Op, Value, ValidFrom, ValidTo) with the operator already negated
151
+ % for a forbid fact; Domain is int, number, bool or string.
152
+ certificate_property_contexts(Contexts) :-
153
+ findall(Fact-tuple(Subject, Property, Op, Type, Value, Unit, Scope, Polarity, From, To),
154
+ ( kb:current_req(Req),
155
+ kb:effective_req_property_fact(Req, Subject, Fact, Property, Op, ValueType, Value0, Unit0, Scope, Polarity, From, To),
156
+ kb:comparison_quantity(ValueType, Value0, Unit0, Type, Value, Unit)
157
+ ),
158
+ Tuples0),
159
+ sort(1, @<, Tuples0, Tuples),
160
+ findall(Subject-Property, member(_-tuple(Subject, Property, _, _, _, _, _, _, _, _), Tuples), Keys0),
161
+ sort(Keys0, Keys),
162
+ findall(Context,
163
+ ( member(Subject-Property, Keys),
164
+ findall(Fact-Tuple, (member(Fact-Tuple, Tuples), Tuple = tuple(Subject, Property, _, _, _, _, _, _, _, _)), Group),
165
+ property_group_context(Subject, Property, Group, Context)
166
+ ),
167
+ Contexts).
168
+
169
+ property_group_context(Subject, Property, Group, Context) :-
170
+ findall(Scope, (member(_-tuple(_, _, _, _, _, _, Scope, _, _, _), Group), Scope \== ''), Scopes0),
171
+ sort(Scopes0, Scopes),
172
+ ( member(_-tuple(_, _, _, _, _, _, '', _, _, _), Group)
173
+ -> ContextScopes = [''|Scopes]
174
+ ; ContextScopes = Scopes
175
+ ),
176
+ member(Scope, ContextScopes),
177
+ findall(Fact-Tuple,
178
+ ( member(Fact-Tuple, Group),
179
+ Tuple = tuple(_, _, _, _, _, _, FactScope, _, _, _),
180
+ ( FactScope == '' -> true ; FactScope == Scope )
181
+ ),
182
+ Members0),
183
+ ( context_domain(Members0, Domain, Reason)
184
+ -> ( Reason == none
185
+ -> maplist(context_member(Domain), Members0, Members),
186
+ Context = context(Subject, Property, Scope, Domain, Members)
187
+ ; Context = unresolved(Subject, Property, Scope, Reason)
188
+ )
189
+ ; Context = unresolved(Subject, Property, Scope, "the facts cannot be compared")
190
+ ).
191
+
192
+ context_domain(Members, Domain, Reason) :-
193
+ findall(Type, member(_-tuple(_, _, _, Type, _, _, _, _, _, _), Members), Types0),
194
+ sort(Types0, Types),
195
+ ( member(_-tuple(_, _, _, _, _, _, _, Polarity, _, _), Members),
196
+ \+ memberchk(Polarity, [require, forbid])
197
+ -> Domain = none, Reason = "a fact has a polarity other than require or forbid"
198
+ ; subtract(Types, [int, number], []), Types \== []
199
+ -> ( Types == [int] -> Domain = int ; Domain = number ),
200
+ context_numeric_reason(Members, Reason)
201
+ ; Types == [bool]
202
+ -> Domain = bool, context_scalar_reason(Members, Reason)
203
+ ; Types == [string]
204
+ -> Domain = string, context_scalar_reason(Members, Reason)
205
+ ; Types = [_, _|_]
206
+ -> Domain = none, Reason = "the facts use different value types"
207
+ ; Domain = none, Reason = "the value type is not supported"
208
+ ).
209
+
210
+ context_numeric_reason(Members, Reason) :-
211
+ findall(Unit, (member(_-tuple(_, _, _, _, _, Unit, _, _, _, _), Members), Unit \== ''), Units0),
212
+ sort(Units0, Units),
213
+ ( Units = [_, _|_]
214
+ -> Reason = "the facts use different units"
215
+ ; member(_-tuple(_, _, Op, _, Value, _, _, _, _, _), Members),
216
+ ( \+ memberchk(Op, [eq, neq, lt, lte, gt, gte]) ; \+ number(Value) )
217
+ -> Reason = "a fact has an unsupported operator or a non-numeric value"
218
+ ; Reason = none
219
+ ).
220
+
221
+ context_scalar_reason(Members, Reason) :-
222
+ ( member(_-tuple(_, _, Op, _, _, _, _, _, _, _), Members),
223
+ \+ memberchk(Op, [eq, neq])
224
+ -> Reason = "only eq and neq compare non-numeric values"
225
+ ; Reason = none
226
+ ).
227
+
228
+ context_member(_Domain, Fact-tuple(_, _, Op0, _, Value, _, _, Polarity, From, To), m(Fact, Op, Value, From, To)) :-
229
+ ( Polarity == forbid
230
+ -> numeric_constraint_negation(c(Op0, _, Value), c(Op, _, _))
231
+ ; Op = Op0
232
+ ).
233
+
234
+ %% certificate_member_holds(+Domain, +Member, +Value)
235
+ % The member's constraint holds for Value.
236
+ certificate_member_holds(Domain, m(_, Op, Bound, _, _), Value) :-
237
+ numeric_domain(Domain),
238
+ !,
239
+ number(Value),
240
+ ( Domain == int -> integer(Value) ; true ),
241
+ numeric_constraint_holds(Op, Value, Bound).
242
+ certificate_member_holds(Domain, m(_, Op, Bound, _, _), Value) :-
243
+ scalar_value(Domain, Value),
244
+ text_of(Value, ValueText),
245
+ text_of(Bound, BoundText),
246
+ ( Op == eq -> ValueText == BoundText
247
+ ; Op == neq -> ValueText \== BoundText
248
+ ).
249
+
250
+ numeric_domain(int).
251
+ numeric_domain(number).
252
+
253
+ scalar_value(bool, Value) :- !, text_of(Value, Text), memberchk(Text, ["true", "false"]).
254
+ scalar_value(string, Value) :- ( atom(Value) ; string(Value) ), !.
255
+
256
+ %% certificate_members_satisfiable(+Domain, +Members)
257
+ % Decision procedure used only on a contradiction core: intervals.pl for
258
+ % numbers, direct evaluation over the finite candidates for bool and string.
259
+ certificate_members_satisfiable(Domain, Members) :-
260
+ numeric_domain(Domain),
261
+ !,
262
+ member_constraints(Members, _Variable, Constraints),
263
+ ( Domain == int -> Integers = all ; Integers = [] ),
264
+ numeric_constraints_satisfiable(Constraints, Integers).
265
+ certificate_members_satisfiable(Domain, Members) :-
266
+ scalar_candidates(Domain, Members, Candidates),
267
+ member(Value, Candidates),
268
+ forall(member(Member, Members), certificate_member_holds(Domain, Member, Value)),
269
+ !.
270
+
271
+ % One constraint per member, all on the same variable.
272
+ member_constraints([], _Variable, []).
273
+ member_constraints([m(_, Op, Bound, _, _)|Members], Variable, [c(Op, Variable, Bound)|Constraints]) :-
274
+ member_constraints(Members, Variable, Constraints).
275
+
276
+ % Any satisfying value is either a bound some eq names or differs from every
277
+ % bound; one fresh text stands for the latter.
278
+ scalar_candidates(bool, _Members, ["true", "false"]).
279
+ scalar_candidates(string, Members, Candidates) :-
280
+ findall(Text, (member(m(_, _, Bound, _, _), Members), text_of(Bound, Text)), Texts0),
281
+ sort(Texts0, Texts),
282
+ atomic_list_concat(["kibi:fresh"|Texts], '|', FreshAtom),
283
+ atom_string(FreshAtom, Fresh),
284
+ append(Texts, [Fresh], Candidates).
285
+
286
+ property_theorem(Contexts, Numbered, Theorem, Used, Rejected) :-
287
+ findall(Index-Certificate,
288
+ ( member(Index-Certificate, Numbered),
289
+ certificate_kind(Certificate, Kind),
290
+ memberchk(Kind, [property_model, property_contradiction, property_unresolved])
291
+ ),
292
+ Candidates),
293
+ foldl(property_context_result(Candidates), Contexts, [], Results),
294
+ theorem_from_results(properties_consistent, Results, Theorem, Used, Rejected0),
295
+ findall(_{certificate: Index, kind: Kind, reason: "certificate names no comparable property context of this KB"},
296
+ ( member(Index-Certificate, Candidates),
297
+ \+ memberchk(Index, Used),
298
+ certificate_kind(Certificate, Kind)
299
+ ),
300
+ Orphans),
301
+ append(Rejected0, Orphans, Rejected).
302
+
303
+ property_context_result(_Candidates, unresolved(Subject, Property, Scope, Reason), Results,
304
+ [result(unresolved, [], [], _{subjectKey: Subject, propertyKey: Property, scope: Scope, reason: Reason})|Results]) :- !.
305
+ property_context_result(Candidates, context(Subject, Property, Scope, Domain, Members), Results, [Result|Results]) :-
306
+ findall(Index-Certificate,
307
+ ( member(Index-Certificate, Candidates),
308
+ certificate_text(Certificate, subjectKey, Subject),
309
+ certificate_text(Certificate, propertyKey, Property),
310
+ certificate_text(Certificate, scope, Scope)
311
+ ),
312
+ Matches),
313
+ Label = _{subjectKey: Subject, propertyKey: Property, scope: Scope},
314
+ ( Matches == []
315
+ -> Result = result(missing, [], [], Label)
316
+ ; Matches = [Index-Certificate]
317
+ -> property_certificate_check(Certificate, Domain, Members, Verdict, Outcome),
318
+ ( Outcome == ok
319
+ -> Result = result(Verdict, [Index], [], Label)
320
+ ; Outcome = failed(Reason),
321
+ certificate_kind(Certificate, Kind),
322
+ Result = result(missing, [Index], [_{certificate: Index, kind: Kind, reason: Reason}], Label)
323
+ )
324
+ ; findall(Index, member(Index-_, Matches), Indexes),
325
+ findall(_{certificate: Index, kind: property, reason: "more than one certificate for the same context"},
326
+ member(Index, Indexes), Failures),
327
+ Result = result(missing, Indexes, Failures, Label)
328
+ ).
329
+
330
+ certificate_text(Certificate, Key, Expected) :-
331
+ get_dict(Key, Certificate, Value),
332
+ text_equal(Value, Expected).
333
+
334
+ % property_certificate_check(+Certificate, +Domain, +Members, -Verdict, ?Outcome)
335
+ % Outcome is ok when the certificate re-verifies, else failed(Reason) names
336
+ % the first check that failed.
337
+ property_certificate_check(Certificate, Domain, Members, Verdict, Outcome) :-
338
+ certificate_kind(Certificate, Kind),
339
+ ( \+ certificate_text(Certificate, domain, Domain)
340
+ -> Outcome = failed("certificate domain differs from the facts' value domain")
341
+ ; Kind == property_model
342
+ -> Verdict = proved,
343
+ model_check(Certificate, Domain, Members, Outcome)
344
+ ; Kind == property_contradiction
345
+ -> Verdict = disproved,
346
+ core_check(Certificate, Domain, Members, true, Outcome)
347
+ ; Kind == property_unresolved
348
+ -> Verdict = unresolved,
349
+ core_check(Certificate, Domain, Members, false, Outcome)
350
+ ).
351
+
352
+ model_check(Certificate, Domain, Members, Outcome) :-
353
+ findall(Fact, member(m(Fact, _, _, _, _), Members), Facts),
354
+ ( \+ certificate_id_set(Certificate, facts, Facts)
355
+ -> Outcome = failed("model does not list exactly the facts of its context")
356
+ ; \+ get_dict(value, Certificate, _)
357
+ -> Outcome = failed("model has no value")
358
+ ; get_dict(value, Certificate, Value),
359
+ member(Member, Members),
360
+ \+ certificate_member_holds(Domain, Member, Value)
361
+ -> Member = m(Fact, _, _, _, _),
362
+ format(string(Reason), "model value does not satisfy ~w", [Fact]),
363
+ Outcome = failed(Reason)
364
+ ; Outcome = ok
365
+ ).
366
+
367
+ % A contradiction core must be a subset of the context, admit no value, be
368
+ % irreducible (each dropped fact comes with a model of the rest) and, for a
369
+ % contradiction, have validity windows that all overlap. An unresolved
370
+ % certificate is the same core whose windows do not all overlap.
371
+ core_check(Certificate, Domain, Members, WindowsOverlap, Outcome) :-
372
+ ( \+ certificate_ids(Certificate, core, _)
373
+ -> Outcome = failed("core is not a list of fact ids")
374
+ ; certificate_ids(Certificate, core, Core),
375
+ findall(Member, (member(Fact, Core), member(Member, Members), Member = m(Fact, _, _, _, _)), CoreMembers),
376
+ length(Core, CoreCount),
377
+ length(CoreMembers, CoreCount),
378
+ sort(Core, SortedCore),
379
+ length(SortedCore, CoreCount),
380
+ CoreCount > 0
381
+ -> ( certificate_members_satisfiable(Domain, CoreMembers)
382
+ -> Outcome = failed("core admits a value, so it is not a contradiction")
383
+ ; \+ drop_models_valid(Certificate, Domain, CoreMembers)
384
+ -> Outcome = failed("core is not irreducible: a dropped fact has no valid model of the rest")
385
+ ; windows_overlap(CoreMembers, Overlap),
386
+ Overlap \== WindowsOverlap
387
+ -> ( WindowsOverlap == true
388
+ -> Outcome = failed("core facts are never valid at the same time")
389
+ ; Outcome = failed("core facts are valid at the same time, so the core is a contradiction")
390
+ )
391
+ ; Outcome = ok
392
+ )
393
+ ; Outcome = failed("core names a fact outside its context or repeats one")
394
+ ).
395
+
396
+ drop_models_valid(Certificate, Domain, CoreMembers) :-
397
+ get_dict(dropModels, Certificate, DropModels),
398
+ is_list(DropModels),
399
+ forall(member(m(Fact, _, _, _, _), CoreMembers),
400
+ ( member(Drop, DropModels),
401
+ is_dict(Drop),
402
+ certificate_text(Drop, factId, Fact),
403
+ get_dict(value, Drop, Value),
404
+ forall((member(Other, CoreMembers), Other \= m(Fact, _, _, _, _)),
405
+ certificate_member_holds(Domain, Other, Value))
406
+ -> true
407
+ )).
408
+
409
+ windows_overlap(Members, Overlap) :-
410
+ ( forall((member(m(_, _, _, FromA, ToA), Members), member(m(_, _, _, FromB, ToB), Members)),
411
+ kb:intervals_overlap(FromA, ToA, FromB, ToB))
412
+ -> Overlap = true
413
+ ; Overlap = false
414
+ ).
415
+
416
+ certificate_ids(Certificate, Key, Ids) :-
417
+ get_dict(Key, Certificate, Raw),
418
+ is_list(Raw),
419
+ maplist(text_atom, Raw, Ids).
420
+
421
+ certificate_id_set(Certificate, Key, Expected) :-
422
+ certificate_ids(Certificate, Key, Ids),
423
+ msort(Ids, Sorted),
424
+ msort(Expected, Sorted).
425
+
426
+ % ------------------------------------------------------------------
427
+ % Predicate and decision facts
428
+ % ------------------------------------------------------------------
429
+
430
+ %% certificate_predicate_groups(-Groups)
431
+ % group(Label, Members): the facts that assert or deny one ground atom. A
432
+ % member is p(FactId, Value) with Value true for assert and false for deny.
433
+ certificate_predicate_groups(Groups) :-
434
+ findall(Fact-atom(Namespace, Name, Args, Polarity),
435
+ ( kb:current_req(Req),
436
+ kb:effective_req_predicate(Req, Fact, Namespace, Name, Args, Polarity)
437
+ ),
438
+ Facts0),
439
+ sort(1, @<, Facts0, Facts),
440
+ findall(Namespace-Name-Args, member(_-atom(Namespace, Name, Args, _), Facts), Keys0),
441
+ sort(Keys0, Keys),
442
+ findall(Group,
443
+ ( member(Namespace-Name-Args, Keys),
444
+ findall(Fact-Polarity, member(Fact-atom(Namespace, Name, Args, Polarity), Facts), Pairs),
445
+ Label = _{namespace: Namespace, name: Name, args: Args},
446
+ ( member(_-Polarity, Pairs), \+ memberchk(Polarity, [assert, deny])
447
+ -> Group = unresolved(Label, "a fact has a polarity other than assert or deny")
448
+ ; findall(p(Fact, Value), (member(Fact-Polarity, Pairs), polarity_value(Polarity, Value)), Members),
449
+ Group = group(Label, Members)
450
+ )
451
+ ),
452
+ Groups).
453
+
454
+ polarity_value(assert, true).
455
+ polarity_value(deny, false).
456
+
457
+ %% certificate_decision_groups(-Groups)
458
+ % group(Label, Members): the decision-predicate facts that agree on every key
459
+ % argument. A member is p(FactId, Effect) with Effect allow or deny.
460
+ certificate_decision_groups(Groups) :-
461
+ kb:decision_signatures(Signatures),
462
+ findall(Fact-decision(Namespace, Name, Arity, Key, Effect),
463
+ ( kb:current_req(Req),
464
+ kb:effective_req_predicate(Req, Fact, Namespace, Name, Args, Polarity),
465
+ length(Args, Arity),
466
+ memberchk(decision_signature(Namespace, Name, Arity, KeyPositions, DecisionPosition), Signatures),
467
+ findall(Value, (member(Position, KeyPositions), nth1(Position, Args, Value)), Key),
468
+ nth1(DecisionPosition, Args, Decision),
469
+ ( kb:effective_decision(Decision, Polarity, Effect0) -> Effect = Effect0 ; Effect = unknown )
470
+ ),
471
+ Facts0),
472
+ sort(1, @<, Facts0, Facts),
473
+ findall(Namespace-Name-Arity-Key, member(_-decision(Namespace, Name, Arity, Key, _), Facts), Keys0),
474
+ sort(Keys0, Keys),
475
+ findall(Group,
476
+ ( member(Namespace-Name-Arity-Key, Keys),
477
+ findall(p(Fact, Effect), member(Fact-decision(Namespace, Name, Arity, Key, Effect), Facts), Members),
478
+ Label = _{namespace: Namespace, name: Name, arity: Arity, key: Key},
479
+ ( memberchk(p(_, unknown), Members)
480
+ -> Group = unresolved(Label, "a decision is neither allow nor deny")
481
+ ; Group = group(Label, Members)
482
+ )
483
+ ),
484
+ Groups).
485
+
486
+ group_theorem(Theorem, Prefix, Groups, Numbered, Result, Used, Rejected) :-
487
+ atom_concat(Prefix, '_model', ModelKind),
488
+ atom_concat(Prefix, '_contradiction', ContradictionKind),
489
+ findall(Index-Certificate,
490
+ ( member(Index-Certificate, Numbered),
491
+ certificate_kind(Certificate, Kind),
492
+ memberchk(Kind, [ModelKind, ContradictionKind])
493
+ ),
494
+ Candidates),
495
+ foldl(group_result(ModelKind, ContradictionKind, Candidates), Groups, [], Results),
496
+ theorem_from_results(Theorem, Results, Result, Used, Rejected0),
497
+ findall(_{certificate: Index, kind: Kind, reason: "certificate names no group of this KB"},
498
+ ( member(Index-Certificate, Candidates),
499
+ \+ memberchk(Index, Used),
500
+ certificate_kind(Certificate, Kind)
501
+ ),
502
+ Orphans),
503
+ append(Rejected0, Orphans, Rejected).
504
+
505
+ group_result(_ModelKind, _ContradictionKind, _Candidates, unresolved(Label, Reason), Results,
506
+ [result(unresolved, [], [], Label.put(reason, Reason))|Results]) :- !.
507
+ group_result(ModelKind, ContradictionKind, Candidates, group(Label, Members), Results, [Result|Results]) :-
508
+ findall(Index-Certificate,
509
+ ( member(Index-Certificate, Candidates),
510
+ certificate_matches_label(Certificate, Label)
511
+ ),
512
+ Matches),
513
+ ( Matches == []
514
+ -> Result = result(missing, [], [], Label)
515
+ ; Matches = [Index-Certificate]
516
+ -> group_certificate_check(Certificate, ModelKind, ContradictionKind, Members, Verdict, Outcome),
517
+ ( Outcome == ok
518
+ -> Result = result(Verdict, [Index], [], Label)
519
+ ; Outcome = failed(Reason),
520
+ certificate_kind(Certificate, Kind),
521
+ Result = result(missing, [Index], [_{certificate: Index, kind: Kind, reason: Reason}], Label)
522
+ )
523
+ ; findall(Index, member(Index-_, Matches), Indexes),
524
+ findall(_{certificate: Index, kind: group, reason: "more than one certificate for the same group"},
525
+ member(Index, Indexes), Failures),
526
+ Result = result(missing, Indexes, Failures, Label)
527
+ ).
528
+
529
+ certificate_matches_label(Certificate, Label) :-
530
+ forall(get_dict(Key, Label, Expected),
531
+ ( get_dict(Key, Certificate, Actual),
532
+ label_value_equal(Actual, Expected)
533
+ )).
534
+
535
+ label_value_equal(Actual, Expected) :-
536
+ is_list(Expected),
537
+ !,
538
+ is_list(Actual),
539
+ maplist(text_equal, Actual, Expected).
540
+ label_value_equal(Actual, Expected) :-
541
+ integer(Expected),
542
+ !,
543
+ Actual == Expected.
544
+ label_value_equal(Actual, Expected) :-
545
+ text_equal(Actual, Expected).
546
+
547
+ group_certificate_check(Certificate, ModelKind, ContradictionKind, Members, Verdict, Outcome) :-
548
+ certificate_kind(Certificate, Kind),
549
+ findall(Fact, member(p(Fact, _), Members), Facts),
550
+ ( Kind == ModelKind
551
+ -> Verdict = proved,
552
+ ( \+ certificate_id_set(Certificate, facts, Facts)
553
+ -> Outcome = failed("model does not list exactly the facts of its group")
554
+ ; \+ get_dict(value, Certificate, _)
555
+ -> Outcome = failed("model has no value")
556
+ ; get_dict(value, Certificate, Value),
557
+ text_of(Value, ValueText),
558
+ member(p(Fact, Expected), Members),
559
+ text_of(Expected, ExpectedText),
560
+ ValueText \== ExpectedText
561
+ -> format(string(Reason), "model value does not satisfy ~w", [Fact]),
562
+ Outcome = failed(Reason)
563
+ ; Outcome = ok
564
+ )
565
+ ; Kind == ContradictionKind
566
+ -> Verdict = disproved,
567
+ ( certificate_ids(Certificate, core, [FactA, FactB]),
568
+ memberchk(p(FactA, ValueA), Members),
569
+ memberchk(p(FactB, ValueB), Members),
570
+ ValueA \== ValueB
571
+ -> Outcome = ok
572
+ ; Outcome = failed("core is not two facts of the group that disagree")
573
+ )
574
+ ).
575
+
576
+ % ------------------------------------------------------------------
577
+ % Rule stratification
578
+ % ------------------------------------------------------------------
579
+
580
+ %% certificate_rule_edges(-Edges)
581
+ % edge(Head, Body, Sign) for every dependency of every stored rule fact, the
582
+ % same graph logic_rules_stratified/1 checks. Head and Body are
583
+ % predicate(Namespace, Name, Arity).
584
+ certificate_rule_edges(Edges) :-
585
+ findall(edge(Head, Body, Sign),
586
+ ( kb_entity(_FactId, fact, Props),
587
+ memberchk(fact_kind=RawKind, Props),
588
+ normalize_term_atom(RawKind, rule),
589
+ logic_rule_from_props(Props, rule(rule, _Modality, HeadAtom, BodyExpression, _, _, _, _, _, _)),
590
+ atom_signature(HeadAtom, Head),
591
+ body_dependency(BodyExpression, BodyAtom, Sign),
592
+ atom_signature(BodyAtom, Body)
593
+ ),
594
+ Edges0),
595
+ sort(Edges0, Edges).
596
+
597
+ atom_signature(atom(Namespace, Name, Args, _, _), predicate(Namespace, Name, Arity)) :-
598
+ length(Args, Arity).
599
+
600
+ body_dependency(Atom, Atom, positive) :- Atom = atom(_, _, _, _, _).
601
+ body_dependency(not(Atom), Atom, negative).
602
+ body_dependency(all(Items), Atom, Sign) :- member(Item, Items), body_dependency(Item, Atom, Sign).
603
+ body_dependency(any(Items), Atom, Sign) :- member(Item, Items), body_dependency(Item, Atom, Sign).
604
+ body_dependency(count(Atom, _, _), Atom, positive).
605
+
606
+ rules_theorem(Edges, Numbered, Theorem, Used, Rejected) :-
607
+ findall(Index-Certificate,
608
+ ( member(Index-Certificate, Numbered),
609
+ certificate_kind(Certificate, Kind),
610
+ memberchk(Kind, [rule_strata, rule_negation_cycle])
611
+ ),
612
+ Candidates),
613
+ findall(Signature, (member(edge(A, B, _), Edges), member(Signature, [A, B])), Signatures0),
614
+ sort(Signatures0, Signatures),
615
+ length(Signatures, PredicateCount),
616
+ ( Candidates == []
617
+ -> ( Edges == []
618
+ -> Verdict = proved, Used = [], Rejected = []
619
+ ; Verdict = unresolved, Used = [],
620
+ Rejected = [_{certificate: 0, kind: rule_strata, reason: "no stratification certificate for the stored rules"}]
621
+ )
622
+ ; Candidates = [Index-Certificate]
623
+ -> Used = [Index],
624
+ rule_certificate_check(Certificate, Edges, Signatures, Verdict0, Outcome),
625
+ ( Outcome == ok
626
+ -> Verdict = Verdict0, Rejected = []
627
+ ; Outcome = failed(Reason),
628
+ certificate_kind(Certificate, Kind),
629
+ Verdict = unresolved,
630
+ Rejected = [_{certificate: Index, kind: Kind, reason: Reason}]
631
+ )
632
+ ; findall(Index, member(Index-_, Candidates), Used),
633
+ Verdict = unresolved,
634
+ findall(_{certificate: Index, kind: rule_strata, reason: "more than one stratification certificate"},
635
+ member(Index, Used), Rejected)
636
+ ),
637
+ length(Edges, EdgeCount),
638
+ Theorem = _{theorem: rules_stratified, verdict: Verdict, predicates: PredicateCount, edges: EdgeCount}.
639
+
640
+ rule_certificate_check(Certificate, Edges, Signatures, Verdict, Outcome) :-
641
+ certificate_kind(Certificate, Kind),
642
+ ( Kind == rule_strata
643
+ -> Verdict = proved,
644
+ ( get_dict(strata, Certificate, Raw), is_list(Raw),
645
+ maplist(stratum_entry, Raw, Entries)
646
+ -> ( member(Signature, Signatures), \+ memberchk(Signature-_, Entries)
647
+ -> Outcome = failed("strata omit a predicate the rules depend on")
648
+ ; member(edge(Head, Body, Sign), Edges),
649
+ memberchk(Head-HeadStratum, Entries),
650
+ memberchk(Body-BodyStratum, Entries),
651
+ \+ stratum_edge_ok(Sign, HeadStratum, BodyStratum)
652
+ -> Outcome = failed("a rule dependency violates the stratum order")
653
+ ; Outcome = ok
654
+ )
655
+ ; Outcome = failed("strata are not a list of predicates with integer strata")
656
+ )
657
+ ; Kind == rule_negation_cycle
658
+ -> Verdict = disproved,
659
+ ( get_dict(cycle, Certificate, Raw), is_list(Raw), Raw \== [],
660
+ maplist(cycle_edge, Raw, Cycle),
661
+ forall(member(Edge, Cycle), memberchk(Edge, Edges)),
662
+ memberchk(edge(_, _, negative), Cycle),
663
+ cycle_closed(Cycle)
664
+ -> Outcome = ok
665
+ ; Outcome = failed("cycle is not a closed path of stored rule dependencies with a negated edge")
666
+ )
667
+ ).
668
+
669
+ stratum_entry(Entry, predicate(Namespace, Name, Arity)-Stratum) :-
670
+ is_dict(Entry),
671
+ get_dict(namespace, Entry, NamespaceRaw), text_atom(NamespaceRaw, Namespace),
672
+ get_dict(name, Entry, NameRaw), text_atom(NameRaw, Name),
673
+ get_dict(arity, Entry, Arity), integer(Arity),
674
+ get_dict(stratum, Entry, Stratum), integer(Stratum), Stratum >= 0.
675
+
676
+ stratum_edge_ok(positive, Head, Body) :- Head >= Body.
677
+ stratum_edge_ok(negative, Head, Body) :- Head > Body.
678
+
679
+ cycle_edge(Entry, edge(Head, Body, Sign)) :-
680
+ is_dict(Entry),
681
+ get_dict(head, Entry, HeadRaw), signature_from_dict(HeadRaw, Head),
682
+ get_dict(body, Entry, BodyRaw), signature_from_dict(BodyRaw, Body),
683
+ get_dict(sign, Entry, SignRaw), text_atom(SignRaw, Sign).
684
+
685
+ signature_from_dict(Dict, predicate(Namespace, Name, Arity)) :-
686
+ is_dict(Dict),
687
+ get_dict(namespace, Dict, NamespaceRaw), text_atom(NamespaceRaw, Namespace),
688
+ get_dict(name, Dict, NameRaw), text_atom(NameRaw, Name),
689
+ get_dict(arity, Dict, Arity), integer(Arity).
690
+
691
+ % Consecutive edges chain head to body (a head depends on its body) and the
692
+ % last edge's body is the first edge's head.
693
+ cycle_closed([edge(First, Next, _)|Rest]) :-
694
+ cycle_chain(Rest, Next, First).
695
+
696
+ cycle_chain([], Last, First) :- Last == First.
697
+ cycle_chain([edge(Head, Body, _)|Rest], Previous, First) :-
698
+ Head == Previous,
699
+ cycle_chain(Rest, Body, First).
700
+
701
+ % ------------------------------------------------------------------
702
+ % Theorem summaries
703
+ % ------------------------------------------------------------------
704
+
705
+ % result(Verdict, CertificateIndexes, Rejected, Label), Verdict one of
706
+ % proved, disproved, unresolved, missing. A theorem is disproved by any
707
+ % re-verified contradiction, unresolved while any context is unresolved or
708
+ % missing, and proved only when every context has a re-verified model.
709
+ theorem_from_results(Theorem, Results, Summary, Used, Rejected) :-
710
+ findall(Index, (member(result(_, Indexes, _, _), Results), member(Index, Indexes)), Used),
711
+ findall(Failure, (member(result(_, _, Failures, _), Results), member(Failure, Failures)), Rejected1),
712
+ findall(Label, member(result(missing, [], [], Label), Results), MissingLabels),
713
+ missing_reasons(MissingLabels, MissingRejected),
714
+ append(Rejected1, MissingRejected, Rejected),
715
+ count_verdict(proved, Results, Proved),
716
+ count_verdict(disproved, Results, Disproved),
717
+ findall(Label, member(result(unresolved, _, _, Label), Results), Unresolved),
718
+ findall(Label, member(result(disproved, _, _, Label), Results), Contradictions),
719
+ length(Results, Total),
720
+ ( Disproved > 0 -> Verdict = disproved
721
+ ; Proved =:= Total -> Verdict = proved
722
+ ; Verdict = unresolved
723
+ ),
724
+ Summary = _{
725
+ theorem: Theorem,
726
+ verdict: Verdict,
727
+ groups: Total,
728
+ models: Proved,
729
+ contradictions: Contradictions,
730
+ unresolved: Unresolved
731
+ }.
732
+
733
+ missing_reasons(Labels, Rejected) :-
734
+ findall(_{certificate: 0, kind: missing, reason: Reason, group: Label},
735
+ ( member(Label, Labels),
736
+ Reason = "no certificate for this group"
737
+ ),
738
+ Rejected).
739
+
740
+ count_verdict(Verdict, Results, Count) :-
741
+ aggregate_all(count, member(result(Verdict, _, _, _), Results), Count).
742
+
743
+ % ------------------------------------------------------------------
744
+ % Text helpers: JSON gives strings, the KB gives atoms.
745
+ % ------------------------------------------------------------------
746
+
747
+ text_of(Value, Text) :- string(Value), !, Text = Value.
748
+ text_of(Value, Text) :- atom(Value), !, atom_string(Value, Text).
749
+ text_of(Value, Text) :- number(Value), !, number_string(Value, Text).
750
+
751
+ text_atom(Value, Atom) :- atom(Value), !, Atom = Value.
752
+ text_atom(Value, Atom) :- string(Value), atom_string(Atom, Value).
753
+
754
+ text_equal(Left, Right) :-
755
+ text_of(Left, Text),
756
+ text_of(Right, Text).