kibi-core 0.23.0 → 0.26.1

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,706 @@
1
+ % Module: claim_obligations
2
+ % implements REQ-kibi-claim-bound-proofs, REQ-kibi-non-numeric-proof-kinds
3
+ %
4
+ % What a certificate has to establish for it to prove one requirement claim.
5
+ % A claim is grounded by exactly one typed fact; the obligation is derived
6
+ % from that fact, so a certificate proves the claim's own content instead of
7
+ % a label its test chose:
8
+ %
9
+ % set fact (value_type set, or the legacy allowed_values string "a|b|c")
10
+ % exhaustive, typecheck the domain is exactly the fact's set
11
+ % typecheck with property required_fields: every member
12
+ % is a required, non-nullable field of the type
13
+ % rule every member satisfies the predicate the
14
+ % fact's property names
15
+ % state_machine with property transitions, states,
16
+ % initial_states, terminal_states or
17
+ % reachable_states: that projection of the
18
+ % machine is exactly the set
19
+ % numeric fact (value_type int or number, a comparison operator)
20
+ % arithmetic, smt the certificate's claim is the fact's constraint
21
+ % (negated for polarity forbid)
22
+ % rule fact (kibi.logic.v1)
23
+ % rule, state_machine the rule holds for every assignment of its
24
+ % variables over their domains (model_check.pl)
25
+ % predicate fact (a ground atom asserted or denied)
26
+ % rule, state_machine the code decides the atom as the fact says
27
+ % string fact (value_type string, operator eq, neq or matches)
28
+ % regex the code's string or pattern meets the
29
+ % constraint (decided in the CLI, exactly)
30
+ % set fact under source (any operator: eq, includes, subset_of, excludes)
31
+ % source the members Kibi read from the project source
32
+ % stand in the operator's relation to the set;
33
+ % with property required_fields or
34
+ % nullable_fields every selected table or object
35
+ % has each member as a required (or nullable)
36
+ % field; with property middleware every
37
+ % selected route's middleware stands in the
38
+ % relation
39
+ % set fact under state_machine with operator includes, subset_of or
40
+ % excludes, or with property reachable_pairs or required_waypoints
41
+ % state_machine the projection stands in the operator's
42
+ % relation to the set; a required waypoint
43
+ % "from->to via mid" holds when every path from
44
+ % one state to the other passes through mid
45
+ %
46
+ % Any other pairing of a set, numeric or string fact with a certificate kind
47
+ % is incompatible (a set fact under arithmetic, a numeric fact under
48
+ % exhaustive). A fact kind with no derivation for the proof kind (boolean
49
+ % placeholders, a rule under exhaustive) can only be attested: the test
50
+ % author says the certificate expresses the claim, and Kibi never counts that
51
+ % as proved.
52
+
53
+ :- module(claim_obligations, [
54
+ claim_obligation/3,
55
+ fact_obligation/3,
56
+ obligation_equal/2,
57
+ proof_contract_claim_candidates_json/1,
58
+ proof_contract_claim_issues_json/1,
59
+ test_claim_obligations_json/4,
60
+ claim_model_check_json/4
61
+ ]).
62
+
63
+ :- use_module(library(http/json)).
64
+ :- use_module(library(apply)).
65
+ :- use_module(library(lists)).
66
+ :- use_module('kb.pl').
67
+ :- use_module('model_check.pl', [model_check_obligation/4, edge_text/3]).
68
+
69
+ %% fact_obligation(+FactId, +ProofKind, -Result)
70
+ % Result is obligation(Dict), attested, or incompatible(Reason).
71
+ fact_obligation(FactId, ProofKind, Result) :-
72
+ ( fact_set(FactId, Members, Operator)
73
+ -> ( ProofKind == source
74
+ -> source_obligation(FactId, Members, Operator, Result)
75
+ ; ProofKind == state_machine
76
+ -> machine_obligation(FactId, Members, Operator, Result)
77
+ ; Operator == includes
78
+ -> includes_obligation(FactId, Members, ProofKind, Result)
79
+ ; Operator == eq
80
+ -> set_obligation(FactId, Members, ProofKind, Result)
81
+ ; format(string(Reason),
82
+ "~w is a ~w set fact, which only source and state_machine certificates can derive (a ~w certificate compares the whole set)",
83
+ [FactId, Operator, ProofKind]),
84
+ Result = incompatible(Reason)
85
+ )
86
+ ; fact_numeric_constraint(FactId, Variable, ValueType, Operator, Value)
87
+ -> ( memberchk(ProofKind, [arithmetic, smt])
88
+ -> Result = obligation(_{kind: "numeric_constraint", variable: Variable,
89
+ value_type: ValueType, operator: Operator, value: Value})
90
+ ; ( memberchk(ProofKind, [typecheck, source]) -> Article = a ; Article = an ),
91
+ format(string(Reason),
92
+ "~w is a numeric fact, which ~w ~w certificate cannot express (use arithmetic or smt)",
93
+ [FactId, Article, ProofKind]),
94
+ Result = incompatible(Reason)
95
+ )
96
+ ; memberchk(ProofKind, [rule, state_machine]),
97
+ fact_rule_hash(FactId, Hash)
98
+ -> atom_string(FactId, FactString),
99
+ Result = obligation(_{kind: "rule_holds", fact_id: FactString, rule_hash: Hash})
100
+ ; memberchk(ProofKind, [rule, state_machine]),
101
+ predicate_expectation(FactId, Name, Args, Value)
102
+ -> atom_string(Name, NameString),
103
+ maplist(atom_string, Args, ArgStrings),
104
+ Result = obligation(_{kind: "predicate_holds", predicate: NameString,
105
+ args: ArgStrings, value: Value})
106
+ ; fact_string_constraint(FactId, Operator, Value, Polarity)
107
+ -> ( ProofKind == regex
108
+ -> Result = obligation(_{kind: "string_constraint", operator: Operator,
109
+ value: Value, polarity: Polarity})
110
+ ; memberchk(ProofKind, [exhaustive, typecheck, arithmetic, smt])
111
+ -> Result = attested
112
+ ; format(string(Reason),
113
+ "~w is a string fact, which a ~w certificate cannot express (use regex)",
114
+ [FactId, ProofKind]),
115
+ Result = incompatible(Reason)
116
+ )
117
+ ; Result = attested
118
+ ).
119
+
120
+ % Set facts: what each kind derives from the members, by the property the
121
+ % fact names where the kind reads one.
122
+ set_obligation(_FactId, Members, exhaustive, obligation(_{kind: "set_equals", members: Members})) :- !.
123
+ set_obligation(FactId, Members, typecheck, obligation(Obligation)) :- !,
124
+ ( fact_property_key(FactId, required_fields)
125
+ -> Obligation = _{kind: "required_fields", members: Members}
126
+ ; Obligation = _{kind: "set_equals", members: Members}
127
+ ).
128
+ set_obligation(FactId, Members, rule, obligation(_{kind: "every_member", predicate: Predicate, members: Members})) :-
129
+ fact_property_key(FactId, Property),
130
+ atom_string(Property, Predicate),
131
+ !.
132
+ set_obligation(FactId, Members, state_machine, Result) :- !,
133
+ machine_obligation(FactId, Members, eq, Result).
134
+ set_obligation(FactId, _Members, rule, incompatible(Reason)) :- !,
135
+ format(string(Reason),
136
+ "~w is a set fact with no property to read as the predicate every member satisfies",
137
+ [FactId]).
138
+ set_obligation(FactId, _Members, ProofKind, incompatible(Reason)) :-
139
+ ( ProofKind == regex -> Article = a ; Article = an ),
140
+ format(string(Reason),
141
+ "~w is a set fact, which ~w ~w certificate cannot express (use exhaustive, typecheck, rule or state_machine)",
142
+ [FactId, Article, ProofKind]).
143
+
144
+ % includes set facts ("every member of A has P") state containment only, so
145
+ % only the kinds that check each member on its own can derive them: a rule
146
+ % checks the property on every member, and a typecheck checks each field is
147
+ % required. Kinds that compare the whole set would read containment as
148
+ % equality.
149
+ includes_obligation(FactId, Members, rule, Result) :- !,
150
+ set_obligation(FactId, Members, rule, Result).
151
+ includes_obligation(FactId, Members, typecheck, obligation(_{kind: "required_fields", members: Members})) :-
152
+ fact_property_key(FactId, required_fields),
153
+ !.
154
+ includes_obligation(FactId, _Members, ProofKind, incompatible(Reason)) :-
155
+ format(string(Reason),
156
+ "~w is an includes set fact (every member has the property), which a ~w certificate cannot derive: it compares the whole set (use rule, or an eq set fact for exactly these members)",
157
+ [FactId, ProofKind]).
158
+
159
+ % implements REQ-kibi-source-proof-kinds
160
+ % A state machine set fact: the projection its property names stands in the
161
+ % fact operator's relation to the members. eq on a plain projection keeps
162
+ % the machine_set obligation earlier receipts recorded.
163
+ machine_obligation(FactId, Members, Operator, Result) :-
164
+ ( fact_property_key(FactId, Projection),
165
+ memberchk(Projection, [transitions, states, initial_states, terminal_states,
166
+ reachable_states, reachable_pairs, required_waypoints])
167
+ -> ( memberchk(Projection, [transitions, reachable_pairs])
168
+ -> maplist(edge_member, Members, Edges0),
169
+ sort(Edges0, Spelled)
170
+ ; Projection == required_waypoints
171
+ -> maplist(waypoint_member, Members, Spelled0),
172
+ sort(Spelled0, Spelled)
173
+ ; Spelled = Members
174
+ ),
175
+ atom_string(Projection, ProjectionString),
176
+ atom_string(Operator, OperatorString),
177
+ ( Projection == required_waypoints,
178
+ Operator == subset_of
179
+ -> format(string(Reason),
180
+ "~w names required waypoints with subset_of; state each waypoint with includes (they hold) or excludes (they do not)",
181
+ [FactId]),
182
+ Result = incompatible(Reason)
183
+ ; Projection == required_waypoints,
184
+ \+ ( member(M, Spelled), sub_string(M, _, _, _, " via ") )
185
+ -> format(string(Reason),
186
+ "~w names required waypoints that are not spelled \"from->to via state\"",
187
+ [FactId]),
188
+ Result = incompatible(Reason)
189
+ ; Operator == eq,
190
+ Projection \== required_waypoints
191
+ -> Result = obligation(_{kind: "machine_set", projection: ProjectionString, members: Spelled})
192
+ ; Result = obligation(_{kind: "machine_relation", projection: ProjectionString,
193
+ operator: OperatorString, members: Spelled})
194
+ )
195
+ ; format(string(Reason),
196
+ "~w is a set fact whose property is not a machine projection (transitions, states, initial_states, terminal_states, reachable_states, reachable_pairs or required_waypoints)",
197
+ [FactId]),
198
+ Result = incompatible(Reason)
199
+ ).
200
+
201
+ % A required waypoint "from -> to via state", spelled "from->to via state".
202
+ waypoint_member(Member, Spelled) :-
203
+ ( sub_string(Member, Before, _, After, " via ")
204
+ -> sub_string(Member, 0, Before, _, Edge0),
205
+ sub_string(Member, _, After, 0, Via0),
206
+ edge_member(Edge0, Edge),
207
+ normalize_space(atom(Via), Via0),
208
+ format(string(Spelled), "~w via ~w", [Edge, Via])
209
+ ; Spelled = Member
210
+ ).
211
+
212
+ % implements REQ-kibi-source-proof-kinds
213
+ % A set fact under the source kind. The property decides what the members
214
+ % are about: fields every selected table or object has as required or
215
+ % nullable, middleware every selected route runs, or (any other property)
216
+ % the members of the source itself.
217
+ % A required_fields or nullable_fields fact says which fields have the
218
+ % property; with subset_of or excludes it would be decided against whatever
219
+ % members the contract selects, so it is refused instead.
220
+ source_obligation(FactId, Members, Operator, Result) :-
221
+ atom_string(Operator, OperatorString),
222
+ ( fact_property_key(FactId, Property) -> true ; Property = none ),
223
+ ( memberchk(Property-Kind, [required_fields-"required_fields", nullable_fields-"nullable_fields"])
224
+ -> ( memberchk(Operator, [eq, includes])
225
+ -> Result = obligation(_{kind: Kind, members: Members})
226
+ ; format(string(Reason),
227
+ "~w is a ~w fact with operator ~w; a source proof decides ~w only with eq or includes (to bound the fields, use a columns or fields fact)",
228
+ [FactId, Property, Operator, Property]),
229
+ Result = incompatible(Reason)
230
+ )
231
+ ; Property == middleware
232
+ -> Result = obligation(_{kind: "route_middleware", operator: OperatorString, members: Members})
233
+ ; Result = obligation(_{kind: "set_relation", operator: OperatorString, members: Members})
234
+ ).
235
+
236
+ % A transition member "from -> to" (or with U+2192 as the arrow) spelled "from->to".
237
+ edge_member(Member, Spelled) :-
238
+ ( sub_string(Member, Before, _, After, "->")
239
+ -> sub_string(Member, 0, Before, _, From0),
240
+ sub_string(Member, _, After, 0, To0)
241
+ ; sub_string(Member, Before, _, After, "\u2192")
242
+ -> sub_string(Member, 0, Before, _, From0),
243
+ sub_string(Member, _, After, 0, To0)
244
+ ; From0 = Member, To0 = ""
245
+ ),
246
+ normalize_space(atom(From), From0),
247
+ normalize_space(atom(To), To0),
248
+ edge_text(From, To, Spelled).
249
+
250
+ % What a predicate fact says the code decides. A decision predicate (the
251
+ % built-in permission_rule, or a schema with key_arguments and a decision
252
+ % argument) is about the key arguments: the code's function takes them and
253
+ % returns whether the effective decision is allow. Any other predicate is
254
+ % true when asserted and false when denied.
255
+ predicate_expectation(FactId, Name, KeyArgs, Value) :-
256
+ kb:predicate_fact(FactId, Namespace, Name, Args, Polarity),
257
+ length(Args, Arity),
258
+ ( kb:decision_signatures(Signatures),
259
+ memberchk(decision_signature(Namespace, Name, Arity, KeyPositions, DecisionPosition), Signatures)
260
+ -> findall(Arg, ( member(Position, KeyPositions), nth1(Position, Args, Arg) ), KeyArgs),
261
+ nth1(DecisionPosition, Args, Decision),
262
+ kb:effective_decision(Decision, Polarity, Effect),
263
+ ( Effect == allow -> Value = true ; Value = false )
264
+ ; memberchk(Polarity-Value, [assert-true, deny-false]),
265
+ KeyArgs = Args
266
+ ).
267
+
268
+ fact_property_key(FactId, Property) :-
269
+ kb_entity(FactId, fact, Props),
270
+ prop_atom(Props, property_key, Property).
271
+
272
+ fact_rule_hash(FactId, Hash) :-
273
+ kb_entity(FactId, fact, Props),
274
+ prop_atom(Props, fact_kind, rule),
275
+ memberchk(rule_ir=_, Props),
276
+ ( prop_atom(Props, rule_hash, HashAtom)
277
+ -> atom_string(HashAtom, Hash)
278
+ ; Hash = ""
279
+ ).
280
+
281
+ % A string property_value fact: eq, neq or matches on a string value.
282
+ fact_string_constraint(FactId, Operator, Value, Polarity) :-
283
+ kb:fact_property_tuple(FactId, _Subject, Property, Op, string, Raw, _Unit, _Scope, Polarity0),
284
+ Property \== allowed_values,
285
+ memberchk(Op, [eq, neq, matches]),
286
+ text_string(Raw, Value),
287
+ atom_string(Op, Operator),
288
+ atom_string(Polarity0, Polarity).
289
+
290
+ %% claim_obligation(+ReqId, +ClaimKey, +ProofKind, -Result)
291
+ % As fact_obligation/3 for the fact grounding ClaimKey in ReqId, with the
292
+ % fact id: obligation(FactId, Dict), attested(FactId), incompatible(FactId,
293
+ % Reason), or unresolved(Reason) when the claim cannot be named by a proof.
294
+ claim_obligation(ReqId-ClaimKey, ProofKind, Result) :-
295
+ ( \+ requirement_claim_status(ReqId, ClaimKey, _)
296
+ -> format(string(Reason), "~w is not a claim of ~w", [ClaimKey, ReqId]),
297
+ Result = unresolved(Reason)
298
+ ; requirement_claim_status(ReqId, ClaimKey, nonlogical)
299
+ -> format(string(Reason),
300
+ "claim ~w of ~w is nonlogical: a nonlogical span is context, not a claim to prove",
301
+ [ClaimKey, ReqId]),
302
+ Result = unresolved(Reason)
303
+ ; requirement_claim_status(ReqId, ClaimKey, Status),
304
+ Status \== modeled
305
+ -> format(string(Reason), "claim ~w of ~w is ~w; resolve it before proving it",
306
+ [ClaimKey, ReqId, Status]),
307
+ Result = unresolved(Reason)
308
+ ; \+ claim_fact(ReqId, ClaimKey, _)
309
+ -> format(string(Reason), "claim ~w of ~w has no grounding fact", [ClaimKey, ReqId]),
310
+ Result = unresolved(Reason)
311
+ ; claim_fact(ReqId, ClaimKey, FactId),
312
+ fact_obligation(FactId, ProofKind, FactResult),
313
+ ( FactResult = obligation(Dict)
314
+ -> Result = obligation(FactId, Dict)
315
+ ; FactResult == attested
316
+ -> Result = attested(FactId)
317
+ ; FactResult = incompatible(Reason)
318
+ -> Result = incompatible(FactId, Reason)
319
+ )
320
+ ).
321
+
322
+ %% obligation_equal(+Left, +Right)
323
+ % Two obligations (dicts, possibly read back from a receipt) ask the same.
324
+ obligation_equal(Left, Right) :-
325
+ obligation_key(Left, Key),
326
+ obligation_key(Right, Key).
327
+
328
+ obligation_key(Obligation, set(Members)) :-
329
+ dict_text(Obligation, kind, "set_equals"),
330
+ get_dict(members, Obligation, Raw),
331
+ is_list(Raw),
332
+ maplist(text_string, Raw, Strings),
333
+ sort(Strings, Members),
334
+ !.
335
+ obligation_key(Obligation, required_fields(Members)) :-
336
+ dict_text(Obligation, kind, "required_fields"),
337
+ obligation_members(Obligation, Members),
338
+ !.
339
+ obligation_key(Obligation, every(Predicate, Members)) :-
340
+ dict_text(Obligation, kind, "every_member"),
341
+ dict_text(Obligation, predicate, Predicate),
342
+ obligation_members(Obligation, Members),
343
+ !.
344
+ obligation_key(Obligation, nullable_fields(Members)) :-
345
+ dict_text(Obligation, kind, "nullable_fields"),
346
+ obligation_members(Obligation, Members),
347
+ !.
348
+ obligation_key(Obligation, relation(Operator, Members)) :-
349
+ dict_text(Obligation, kind, "set_relation"),
350
+ dict_text(Obligation, operator, Operator),
351
+ obligation_members(Obligation, Members),
352
+ !.
353
+ obligation_key(Obligation, middleware(Operator, Members)) :-
354
+ dict_text(Obligation, kind, "route_middleware"),
355
+ dict_text(Obligation, operator, Operator),
356
+ obligation_members(Obligation, Members),
357
+ !.
358
+ obligation_key(Obligation, machine(Projection, Operator, Members)) :-
359
+ dict_text(Obligation, kind, "machine_relation"),
360
+ dict_text(Obligation, projection, Projection),
361
+ dict_text(Obligation, operator, Operator),
362
+ obligation_members(Obligation, Members),
363
+ !.
364
+ obligation_key(Obligation, machine(Projection, Members)) :-
365
+ dict_text(Obligation, kind, "machine_set"),
366
+ dict_text(Obligation, projection, Projection),
367
+ obligation_members(Obligation, Members),
368
+ !.
369
+ obligation_key(Obligation, rule(FactId, Hash)) :-
370
+ dict_text(Obligation, kind, "rule_holds"),
371
+ dict_text(Obligation, fact_id, FactId),
372
+ dict_text(Obligation, rule_hash, Hash),
373
+ !.
374
+ obligation_key(Obligation, predicate(Name, Args, Value)) :-
375
+ dict_text(Obligation, kind, "predicate_holds"),
376
+ dict_text(Obligation, predicate, Name),
377
+ get_dict(args, Obligation, RawArgs),
378
+ is_list(RawArgs),
379
+ maplist(text_string, RawArgs, Args),
380
+ dict_text(Obligation, value, Value),
381
+ !.
382
+ obligation_key(Obligation, string(Operator, Value, Polarity)) :-
383
+ dict_text(Obligation, kind, "string_constraint"),
384
+ dict_text(Obligation, operator, Operator),
385
+ dict_text(Obligation, value, Value),
386
+ dict_text(Obligation, polarity, Polarity),
387
+ !.
388
+ obligation_key(Obligation, numeric(Variable, Type, Operator, Value)) :-
389
+ dict_text(Obligation, kind, "numeric_constraint"),
390
+ dict_text(Obligation, variable, Variable),
391
+ dict_text(Obligation, value_type, Type),
392
+ dict_text(Obligation, operator, Operator),
393
+ get_dict(value, Obligation, Raw),
394
+ number_value(Raw, Value).
395
+
396
+ obligation_members(Obligation, Members) :-
397
+ get_dict(members, Obligation, Raw),
398
+ is_list(Raw),
399
+ maplist(text_string, Raw, Strings),
400
+ sort(Strings, Members).
401
+
402
+ dict_text(Dict, Key, String) :-
403
+ is_dict(Dict),
404
+ get_dict(Key, Dict, Raw),
405
+ text_string(Raw, String).
406
+
407
+ number_value(Raw, Value) :- number(Raw), !, Value is Raw * 1.0.
408
+ number_value(Raw, Value) :-
409
+ text_string(Raw, String),
410
+ number_string(Number, String),
411
+ Value is Number * 1.0.
412
+
413
+ text_string(Raw, String) :- string(Raw), !, String = Raw.
414
+ text_string(Raw, String) :- atom(Raw), !, atom_string(Raw, String).
415
+ text_string(Raw, String) :- number(Raw), !, number_string(Raw, String).
416
+ text_string(^^(Raw, _), String) :- !, text_string(Raw, String).
417
+
418
+ % The members of a set fact, sorted and unique, and its operator: eq (the
419
+ % set is exactly these members), includes (it contains at least these),
420
+ % subset_of (it contains only these) or excludes (it contains none of them).
421
+ fact_set(FactId, Members, Operator) :-
422
+ kb_entity(FactId, fact, Props),
423
+ prop_atom(Props, fact_kind, property_value),
424
+ ( prop_atom(Props, value_type, set)
425
+ -> memberchk(value_set=Raw, Props),
426
+ kb:normalize_literal_atom_list(Raw, Atoms)
427
+ ; prop_atom(Props, property_key, allowed_values),
428
+ \+ (memberchk(value_type=RawType, Props),
429
+ kb:normalize_term_atom(RawType, Type),
430
+ Type \== string),
431
+ memberchk(value_string=RawValue, Props),
432
+ kb:unwrap_rdf_value(RawValue, Value),
433
+ text_string(Value, Joined),
434
+ split_string(Joined, "|", " ", Parts),
435
+ exclude(==(""), Parts, NonEmpty),
436
+ maplist([S, A]>>atom_string(A, S), NonEmpty, Atoms)
437
+ ),
438
+ ( prop_atom(Props, operator, Operator0) -> Operator = Operator0 ; Operator = eq ),
439
+ memberchk(Operator, [eq, includes, subset_of, excludes]),
440
+ \+ prop_atom(Props, polarity, forbid),
441
+ maplist([A, S]>>atom_string(A, S), Atoms, Strings),
442
+ sort(Strings, Members),
443
+ Members \== [].
444
+
445
+ % A numeric property_value fact as one constraint on its property, negated
446
+ % for polarity forbid (forbid gt 5 means the value must be at most 5).
447
+ fact_numeric_constraint(FactId, Variable, ValueType, Operator, Value) :-
448
+ kb:fact_property_tuple(FactId, _Subject, Property, Op0, Type0, Raw, _Unit, _Scope, Polarity),
449
+ memberchk(Type0-ValueType, [int-"int", number-"real"]),
450
+ memberchk(Op0, [eq, neq, lt, lte, gt, gte]),
451
+ number_value(Raw, Float),
452
+ ( ValueType == "int", Float =:= truncate(Float)
453
+ -> Value is truncate(Float)
454
+ ; Value = Float
455
+ ),
456
+ ( Polarity == forbid
457
+ -> negated_operator(Op0, Op)
458
+ ; Op = Op0
459
+ ),
460
+ atom_string(Op, Operator),
461
+ atom_string(Property, Variable).
462
+
463
+ negated_operator(eq, neq).
464
+ negated_operator(neq, eq).
465
+ negated_operator(lt, gte).
466
+ negated_operator(lte, gt).
467
+ negated_operator(gt, lte).
468
+ negated_operator(gte, lt).
469
+
470
+ prop_atom(Props, Key, Atom) :-
471
+ memberchk(Key=Raw, Props),
472
+ kb:normalize_term_atom(Raw, Atom).
473
+
474
+ % The status of a claim in the requirement's semantic inventory.
475
+ % With ClaimKey unbound it enumerates the claims; the first entry for a key
476
+ % wins.
477
+ requirement_claim_status(ReqId, ClaimKey, Status) :-
478
+ nonvar(ClaimKey),
479
+ !,
480
+ once(inventory_claim_status(ReqId, ClaimKey, Status0)),
481
+ Status = Status0.
482
+ requirement_claim_status(ReqId, ClaimKey, Status) :-
483
+ findall(Key-S, inventory_claim_status(ReqId, Key, S), Pairs),
484
+ first_per_key(Pairs, [], Unique),
485
+ member(ClaimKey-Status, Unique).
486
+
487
+ first_per_key([], Acc, Unique) :- reverse(Acc, Unique).
488
+ first_per_key([Key-S|Rest], Acc, Unique) :-
489
+ ( memberchk(Key-_, Acc)
490
+ -> first_per_key(Rest, Acc, Unique)
491
+ ; first_per_key(Rest, [Key-S|Acc], Unique)
492
+ ).
493
+
494
+ inventory_claim_status(ReqId, ClaimKey, Status) :-
495
+ kb_entity(ReqId, req, Props),
496
+ memberchk(semantic_inventory=Raw, Props),
497
+ inventory_list(Raw, Entries),
498
+ member(Entry, Entries),
499
+ entry_field(Entry, claim_key, RawKey),
500
+ kb:normalize_term_atom(RawKey, ClaimKey),
501
+ entry_field(Entry, status, RawStatus),
502
+ kb:normalize_term_atom(RawStatus, Status).
503
+
504
+ inventory_list(Raw, Entries) :- is_list(Raw), !, Entries = Raw.
505
+ inventory_list(^^(Raw, _), Entries) :- !, inventory_list(Raw, Entries).
506
+ inventory_list(Raw, Entries) :-
507
+ ( string(Raw) ; atom(Raw) ),
508
+ catch(atom_json_dict(Raw, Parsed, [value_string_as(string)]), _, fail),
509
+ is_list(Parsed),
510
+ !,
511
+ Entries = Parsed.
512
+ inventory_list(Raw, Entries) :-
513
+ ( string(Raw) ; atom(Raw) ),
514
+ catch(term_string(Parsed, Raw), _, fail),
515
+ is_list(Parsed),
516
+ !,
517
+ Entries = Parsed.
518
+ inventory_list(_, []).
519
+
520
+ entry_field(Entry, Key, Value) :-
521
+ is_dict(Entry), !,
522
+ get_dict(Key, Entry, Value).
523
+ entry_field(Entry, Key, Value) :-
524
+ is_list(Entry),
525
+ memberchk(Key=Value, Entry).
526
+
527
+ % The fact grounding a claim: the same claim_key, linked from the
528
+ % requirement through requires_property, requires_predicate or requires_rule.
529
+ claim_fact(ReqId, ClaimKey, FactId) :-
530
+ member(Relation, [requires_property, requires_predicate, requires_rule]),
531
+ kb_relationship(Relation, ReqId, FactId),
532
+ kb_entity(FactId, fact, Props),
533
+ prop_atom(Props, claim_key, ClaimKey),
534
+ !.
535
+
536
+ % Current requirements a test reaches through the scenarios it verifies.
537
+ test_requirement(TestId, ReqId) :-
538
+ ( kb_relationship(validates, TestId, ScenarioId)
539
+ ; kb_relationship(verified_by, ScenarioId, TestId)
540
+ ),
541
+ kb_relationship(specified_by, ReqId, ScenarioId),
542
+ kb:current_req(ReqId).
543
+
544
+ %% test_claim_obligations_json(+TestId, +ClaimKeysJson, +ProofKind, -Json)
545
+ % For `kibi prove`: one row per claim key and requirement the test reaches,
546
+ % {claim_key, requirement_id, fact_id?, result, obligation?, reason?}, with
547
+ % result obligation, attested, incompatible or unresolved. A key no reached
548
+ % requirement declares gives one unresolved row without requirement_id.
549
+ test_claim_obligations_json(TestId0, KeysJson, Kind0, Json) :-
550
+ kb:normalize_term_atom(TestId0, TestId),
551
+ kb:normalize_term_atom(Kind0, Kind),
552
+ atom_json_term(KeysJson, Keys0, []),
553
+ maplist([K, A]>>(text_string(K, S), atom_string(A, S)), Keys0, Keys),
554
+ findall(ReqId, test_requirement(TestId, ReqId), ReqIds0),
555
+ sort(ReqIds0, ReqIds),
556
+ foldl(key_rows(Kind, ReqIds), Keys, [], RowsRev),
557
+ reverse(RowsRev, Rows),
558
+ with_output_to(string(Json), json_write_dict(current_output, Rows, [width(0)])).
559
+
560
+ key_rows(Kind, ReqIds, Key, Acc0, Acc) :-
561
+ findall(ReqId,
562
+ (member(ReqId, ReqIds), requirement_claim_status(ReqId, Key, _)),
563
+ Owners),
564
+ atom_string(Key, KeyString),
565
+ ( Owners == []
566
+ -> format(string(Reason),
567
+ "claim ~w is not a claim of any current requirement this test reaches through its scenarios",
568
+ [Key]),
569
+ Acc = [_{claim_key: KeyString, result: "unresolved", reason: Reason}|Acc0]
570
+ ; foldl(owner_row(Kind, Key, KeyString), Owners, Acc0, Acc)
571
+ ).
572
+
573
+ owner_row(Kind, Key, KeyString, ReqId, Acc0, [Row|Acc0]) :-
574
+ atom_string(ReqId, ReqString),
575
+ claim_obligation(ReqId-Key, Kind, Result),
576
+ Base = _{claim_key: KeyString, requirement_id: ReqString},
577
+ ( Result = obligation(FactId, Obligation)
578
+ -> atom_string(FactId, FactString),
579
+ Row = Base.put(_{result: "obligation", fact_id: FactString, obligation: Obligation})
580
+ ; Result = attested(FactId)
581
+ -> atom_string(FactId, FactString),
582
+ Row = Base.put(_{result: "attested", fact_id: FactString})
583
+ ; Result = incompatible(FactId, Reason)
584
+ -> atom_string(FactId, FactString),
585
+ Row = Base.put(_{result: "incompatible", fact_id: FactString, reason: Reason})
586
+ ; Result = unresolved(Reason),
587
+ Row = Base.put(_{result: "unresolved", reason: Reason})
588
+ ).
589
+
590
+ %% proof_contract_claim_issues_json(-Json)
591
+ % For the proof-contract-claims rule: one row {test_id, claim_key?, reason}
592
+ % per certificate-kind proof contract that names no claims in proves, and
593
+ % per claim key it names that no certificate of its kind could prove
594
+ % (not a modeled claim of a reached requirement, no grounding fact, or a
595
+ % fact the kind cannot express). An attested claim is not an issue here:
596
+ % it is reported as attested, and never counts as proved.
597
+ proof_contract_claim_issues_json(Json) :-
598
+ findall(Row, proof_contract_claim_issue(Row), Rows0),
599
+ sort(Rows0, Rows),
600
+ with_output_to(string(Json), json_write_dict(current_output, Rows, [width(0)])).
601
+
602
+ certificate_kind(exhaustive).
603
+ certificate_kind(typecheck).
604
+ certificate_kind(arithmetic).
605
+ certificate_kind(smt).
606
+ certificate_kind(rule).
607
+ certificate_kind(state_machine).
608
+ certificate_kind(regex).
609
+ certificate_kind(source).
610
+
611
+ test_certificate_contract(TestId, Kind, Contract) :-
612
+ kb_entity(TestId, test, Props),
613
+ memberchk(proof_contract=Raw, Props),
614
+ contract_dict(Raw, Contract),
615
+ get_dict(kind, Contract, RawKind),
616
+ text_string(RawKind, KindString),
617
+ atom_string(Kind, KindString),
618
+ certificate_kind(Kind).
619
+
620
+ contract_dict(^^(Raw, _), Dict) :- !, contract_dict(Raw, Dict).
621
+ contract_dict(Dict, Dict) :- is_dict(Dict), !.
622
+ contract_dict(Raw, Dict) :-
623
+ ( atom(Raw) ; string(Raw) ),
624
+ catch(atom_json_dict(Raw, Dict, [value_string_as(string)]), _, fail),
625
+ is_dict(Dict).
626
+
627
+ proof_contract_claim_issue(_{test_id: TestString, reason: Reason}) :-
628
+ test_certificate_contract(TestId, Kind, Contract),
629
+ \+ ( get_dict(proves, Contract, Proves), is_list(Proves), Proves \== [] ),
630
+ atom_string(TestId, TestString),
631
+ format(string(Reason),
632
+ "the ~w proof contract names no claims in proves, so its certificate proves nothing about a requirement",
633
+ [Kind]).
634
+ proof_contract_claim_issue(_{test_id: TestString, claim_key: KeyString, reason: Reason}) :-
635
+ test_certificate_contract(TestId, Kind, Contract),
636
+ get_dict(proves, Contract, Proves),
637
+ is_list(Proves),
638
+ Proves \== [],
639
+ atom_json_term(KeysJson, Proves, []),
640
+ test_claim_obligations_json(TestId, KeysJson, Kind, RowsJson),
641
+ atom_json_dict(RowsJson, Rows, [value_string_as(string)]),
642
+ member(Row, Rows),
643
+ get_dict(result, Row, Result),
644
+ memberchk(Result, ["unresolved", "incompatible"]),
645
+ atom_string(TestId, TestString),
646
+ get_dict(claim_key, Row, KeyString),
647
+ get_dict(reason, Row, Reason).
648
+
649
+ %% proof_contract_claim_candidates_json(-Json)
650
+ % For the schema 12 migration: one row {test_id, kind, candidates} per
651
+ % certificate-kind proof contract without proves. Candidates are the
652
+ % modeled claims {claim_key, requirement_id, fact_id, binding, reason?} of
653
+ % current requirements the test reaches through its scenarios, with the
654
+ % binding a certificate of its kind would get: derived when the grounding
655
+ % fact derives an obligation for the kind, attested when the fact has no
656
+ % derivation, incompatible (with the reason) when the kind cannot express
657
+ % it. The migration pins proves only when exactly one claim is derived;
658
+ % the review lists every candidate so a person can bind the rest.
659
+ proof_contract_claim_candidates_json(Json) :-
660
+ findall(Row, proof_contract_claim_candidates(Row), Rows0),
661
+ sort(Rows0, Rows),
662
+ with_output_to(string(Json), json_write_dict(current_output, Rows, [width(0)])).
663
+
664
+ proof_contract_claim_candidates(_{test_id: TestString, kind: KindString, candidates: Candidates}) :-
665
+ test_certificate_contract(TestId, Kind, Contract),
666
+ \+ ( get_dict(proves, Contract, Proves), is_list(Proves), Proves \== [] ),
667
+ findall(Candidate,
668
+ ( test_requirement(TestId, ReqId),
669
+ requirement_claim_status(ReqId, ClaimKey, modeled),
670
+ claim_obligation(ReqId-ClaimKey, Kind, Result),
671
+ claim_candidate(ReqId, ClaimKey, Result, Candidate)
672
+ ),
673
+ Candidates0),
674
+ sort(Candidates0, Candidates1),
675
+ sort(claim_key, @=<, Candidates1, Candidates),
676
+ atom_string(TestId, TestString),
677
+ atom_string(Kind, KindString).
678
+
679
+ claim_candidate(ReqId, ClaimKey, Result, Candidate) :-
680
+ ( Result = obligation(FactId, _)
681
+ -> Binding = "derived"
682
+ ; Result = attested(FactId)
683
+ -> Binding = "attested"
684
+ ; Result = incompatible(FactId, Reason)
685
+ -> Binding = "incompatible"
686
+ ),
687
+ atom_string(ClaimKey, KeyString),
688
+ atom_string(ReqId, ReqString),
689
+ atom_string(FactId, FactString),
690
+ Base = _{claim_key: KeyString, requirement_id: ReqString, fact_id: FactString, binding: Binding},
691
+ ( var(Reason) -> Candidate = Base ; Candidate = Base.put(reason, Reason) ).
692
+
693
+ %% claim_model_check_json(+Kind, +ObligationJson, +CertificateJson, -Json)
694
+ % For `kibi prove`: whether a rule or state_machine certificate's model meets
695
+ % one derived obligation, {"ok": true} or {"ok": false, "reason": R}.
696
+ claim_model_check_json(Kind0, ObligationJson, CertificateJson, Json) :-
697
+ kb:normalize_term_atom(Kind0, Kind),
698
+ atom_json_dict(ObligationJson, Obligation, [value_string_as(string)]),
699
+ atom_json_dict(CertificateJson, Certificate, [value_string_as(string)]),
700
+ model_check_obligation(Kind, Obligation, Certificate, Result),
701
+ ( Result == ok
702
+ -> Out = _{ok: true}
703
+ ; Result = problem(Reason),
704
+ Out = _{ok: false, reason: Reason}
705
+ ),
706
+ with_output_to(string(Json), json_write_dict(current_output, Out, [width(0)])).