kibi-core 0.22.5 → 0.24.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,378 @@
1
+ % Module: claim_obligations
2
+ % implements REQ-kibi-claim-bound-proofs
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
+ % numeric fact (value_type int or number, a comparison operator)
12
+ % arithmetic, smt the certificate's claim is the fact's constraint
13
+ % (negated for polarity forbid)
14
+ %
15
+ % Any other pairing that appears in the table with a different proof kind is
16
+ % incompatible (a set fact under arithmetic, a numeric fact under
17
+ % exhaustive). A fact kind with no derivation at all (boolean placeholders,
18
+ % predicates, rules, other strings) can only be attested: the test author
19
+ % says the certificate expresses the claim, and Kibi never counts that as
20
+ % proved.
21
+
22
+ :- module(claim_obligations, [
23
+ claim_obligation/3,
24
+ fact_obligation/3,
25
+ obligation_equal/2,
26
+ proof_contract_claim_candidates_json/1,
27
+ proof_contract_claim_issues_json/1,
28
+ test_claim_obligations_json/4
29
+ ]).
30
+
31
+ :- use_module(library(http/json)).
32
+ :- use_module(library(apply)).
33
+ :- use_module(library(lists)).
34
+ :- use_module('kb.pl').
35
+
36
+ %% fact_obligation(+FactId, +ProofKind, -Result)
37
+ % Result is obligation(Dict), attested, or incompatible(Reason).
38
+ fact_obligation(FactId, ProofKind, Result) :-
39
+ ( fact_set(FactId, Members)
40
+ -> ( memberchk(ProofKind, [exhaustive, typecheck])
41
+ -> Result = obligation(_{kind: "set_equals", members: Members})
42
+ ; format(string(Reason),
43
+ "~w is a set fact, which an ~w certificate cannot express (use exhaustive or typecheck)",
44
+ [FactId, ProofKind]),
45
+ Result = incompatible(Reason)
46
+ )
47
+ ; fact_numeric_constraint(FactId, Variable, ValueType, Operator, Value)
48
+ -> ( memberchk(ProofKind, [arithmetic, smt])
49
+ -> Result = obligation(_{kind: "numeric_constraint", variable: Variable,
50
+ value_type: ValueType, operator: Operator, value: Value})
51
+ ; ( ProofKind == typecheck -> Article = a ; Article = an ),
52
+ format(string(Reason),
53
+ "~w is a numeric fact, which ~w ~w certificate cannot express (use arithmetic or smt)",
54
+ [FactId, Article, ProofKind]),
55
+ Result = incompatible(Reason)
56
+ )
57
+ ; Result = attested
58
+ ).
59
+
60
+ %% claim_obligation(+ReqId, +ClaimKey, +ProofKind, -Result)
61
+ % As fact_obligation/3 for the fact grounding ClaimKey in ReqId, with the
62
+ % fact id: obligation(FactId, Dict), attested(FactId), incompatible(FactId,
63
+ % Reason), or unresolved(Reason) when the claim cannot be named by a proof.
64
+ claim_obligation(ReqId-ClaimKey, ProofKind, Result) :-
65
+ ( \+ requirement_claim_status(ReqId, ClaimKey, _)
66
+ -> format(string(Reason), "~w is not a claim of ~w", [ClaimKey, ReqId]),
67
+ Result = unresolved(Reason)
68
+ ; requirement_claim_status(ReqId, ClaimKey, nonlogical)
69
+ -> format(string(Reason),
70
+ "claim ~w of ~w is nonlogical: a nonlogical span is context, not a claim to prove",
71
+ [ClaimKey, ReqId]),
72
+ Result = unresolved(Reason)
73
+ ; requirement_claim_status(ReqId, ClaimKey, Status),
74
+ Status \== modeled
75
+ -> format(string(Reason), "claim ~w of ~w is ~w; resolve it before proving it",
76
+ [ClaimKey, ReqId, Status]),
77
+ Result = unresolved(Reason)
78
+ ; \+ claim_fact(ReqId, ClaimKey, _)
79
+ -> format(string(Reason), "claim ~w of ~w has no grounding fact", [ClaimKey, ReqId]),
80
+ Result = unresolved(Reason)
81
+ ; claim_fact(ReqId, ClaimKey, FactId),
82
+ fact_obligation(FactId, ProofKind, FactResult),
83
+ ( FactResult = obligation(Dict)
84
+ -> Result = obligation(FactId, Dict)
85
+ ; FactResult == attested
86
+ -> Result = attested(FactId)
87
+ ; FactResult = incompatible(Reason)
88
+ -> Result = incompatible(FactId, Reason)
89
+ )
90
+ ).
91
+
92
+ %% obligation_equal(+Left, +Right)
93
+ % Two obligations (dicts, possibly read back from a receipt) ask the same.
94
+ obligation_equal(Left, Right) :-
95
+ obligation_key(Left, Key),
96
+ obligation_key(Right, Key).
97
+
98
+ obligation_key(Obligation, set(Members)) :-
99
+ dict_text(Obligation, kind, "set_equals"),
100
+ get_dict(members, Obligation, Raw),
101
+ is_list(Raw),
102
+ maplist(text_string, Raw, Strings),
103
+ sort(Strings, Members),
104
+ !.
105
+ obligation_key(Obligation, numeric(Variable, Type, Operator, Value)) :-
106
+ dict_text(Obligation, kind, "numeric_constraint"),
107
+ dict_text(Obligation, variable, Variable),
108
+ dict_text(Obligation, value_type, Type),
109
+ dict_text(Obligation, operator, Operator),
110
+ get_dict(value, Obligation, Raw),
111
+ number_value(Raw, Value).
112
+
113
+ dict_text(Dict, Key, String) :-
114
+ is_dict(Dict),
115
+ get_dict(Key, Dict, Raw),
116
+ text_string(Raw, String).
117
+
118
+ number_value(Raw, Value) :- number(Raw), !, Value is Raw * 1.0.
119
+ number_value(Raw, Value) :-
120
+ text_string(Raw, String),
121
+ number_string(Number, String),
122
+ Value is Number * 1.0.
123
+
124
+ text_string(Raw, String) :- string(Raw), !, String = Raw.
125
+ text_string(Raw, String) :- atom(Raw), !, atom_string(Raw, String).
126
+ text_string(Raw, String) :- number(Raw), !, number_string(Raw, String).
127
+ text_string(^^(Raw, _), String) :- !, text_string(Raw, String).
128
+
129
+ % The members of a set fact, sorted and unique.
130
+ fact_set(FactId, Members) :-
131
+ kb_entity(FactId, fact, Props),
132
+ prop_atom(Props, fact_kind, property_value),
133
+ ( prop_atom(Props, value_type, set)
134
+ -> memberchk(value_set=Raw, Props),
135
+ kb:normalize_term_atom_list(Raw, Atoms)
136
+ ; prop_atom(Props, property_key, allowed_values),
137
+ \+ (memberchk(value_type=RawType, Props),
138
+ kb:normalize_term_atom(RawType, Type),
139
+ Type \== string),
140
+ memberchk(value_string=RawValue, Props),
141
+ kb:unwrap_rdf_value(RawValue, Value),
142
+ text_string(Value, Joined),
143
+ split_string(Joined, "|", " ", Parts),
144
+ exclude(==(""), Parts, NonEmpty),
145
+ maplist([S, A]>>atom_string(A, S), NonEmpty, Atoms)
146
+ ),
147
+ ( prop_atom(Props, operator, Operator) -> Operator == eq ; true ),
148
+ \+ prop_atom(Props, polarity, forbid),
149
+ maplist([A, S]>>atom_string(A, S), Atoms, Strings),
150
+ sort(Strings, Members),
151
+ Members \== [].
152
+
153
+ % A numeric property_value fact as one constraint on its property, negated
154
+ % for polarity forbid (forbid gt 5 means the value must be at most 5).
155
+ fact_numeric_constraint(FactId, Variable, ValueType, Operator, Value) :-
156
+ kb:fact_property_tuple(FactId, _Subject, Property, Op0, Type0, Raw, _Unit, _Scope, Polarity),
157
+ memberchk(Type0-ValueType, [int-"int", number-"real"]),
158
+ memberchk(Op0, [eq, neq, lt, lte, gt, gte]),
159
+ number_value(Raw, Float),
160
+ ( ValueType == "int", Float =:= truncate(Float)
161
+ -> Value is truncate(Float)
162
+ ; Value = Float
163
+ ),
164
+ ( Polarity == forbid
165
+ -> negated_operator(Op0, Op)
166
+ ; Op = Op0
167
+ ),
168
+ atom_string(Op, Operator),
169
+ atom_string(Property, Variable).
170
+
171
+ negated_operator(eq, neq).
172
+ negated_operator(neq, eq).
173
+ negated_operator(lt, gte).
174
+ negated_operator(lte, gt).
175
+ negated_operator(gt, lte).
176
+ negated_operator(gte, lt).
177
+
178
+ prop_atom(Props, Key, Atom) :-
179
+ memberchk(Key=Raw, Props),
180
+ kb:normalize_term_atom(Raw, Atom).
181
+
182
+ % The status of a claim in the requirement's semantic inventory.
183
+ % With ClaimKey unbound it enumerates the claims; the first entry for a key
184
+ % wins.
185
+ requirement_claim_status(ReqId, ClaimKey, Status) :-
186
+ nonvar(ClaimKey),
187
+ !,
188
+ once(inventory_claim_status(ReqId, ClaimKey, Status0)),
189
+ Status = Status0.
190
+ requirement_claim_status(ReqId, ClaimKey, Status) :-
191
+ findall(Key-S, inventory_claim_status(ReqId, Key, S), Pairs),
192
+ first_per_key(Pairs, [], Unique),
193
+ member(ClaimKey-Status, Unique).
194
+
195
+ first_per_key([], Acc, Unique) :- reverse(Acc, Unique).
196
+ first_per_key([Key-S|Rest], Acc, Unique) :-
197
+ ( memberchk(Key-_, Acc)
198
+ -> first_per_key(Rest, Acc, Unique)
199
+ ; first_per_key(Rest, [Key-S|Acc], Unique)
200
+ ).
201
+
202
+ inventory_claim_status(ReqId, ClaimKey, Status) :-
203
+ kb_entity(ReqId, req, Props),
204
+ memberchk(semantic_inventory=Raw, Props),
205
+ inventory_list(Raw, Entries),
206
+ member(Entry, Entries),
207
+ entry_field(Entry, claim_key, RawKey),
208
+ kb:normalize_term_atom(RawKey, ClaimKey),
209
+ entry_field(Entry, status, RawStatus),
210
+ kb:normalize_term_atom(RawStatus, Status).
211
+
212
+ inventory_list(Raw, Entries) :- is_list(Raw), !, Entries = Raw.
213
+ inventory_list(^^(Raw, _), Entries) :- !, inventory_list(Raw, Entries).
214
+ inventory_list(Raw, Entries) :-
215
+ ( string(Raw) ; atom(Raw) ),
216
+ catch(atom_json_dict(Raw, Parsed, [value_string_as(string)]), _, fail),
217
+ is_list(Parsed),
218
+ !,
219
+ Entries = Parsed.
220
+ inventory_list(Raw, Entries) :-
221
+ ( string(Raw) ; atom(Raw) ),
222
+ catch(term_string(Parsed, Raw), _, fail),
223
+ is_list(Parsed),
224
+ !,
225
+ Entries = Parsed.
226
+ inventory_list(_, []).
227
+
228
+ entry_field(Entry, Key, Value) :-
229
+ is_dict(Entry), !,
230
+ get_dict(Key, Entry, Value).
231
+ entry_field(Entry, Key, Value) :-
232
+ is_list(Entry),
233
+ memberchk(Key=Value, Entry).
234
+
235
+ % The fact grounding a claim: the same claim_key, linked from the
236
+ % requirement through requires_property, requires_predicate or requires_rule.
237
+ claim_fact(ReqId, ClaimKey, FactId) :-
238
+ member(Relation, [requires_property, requires_predicate, requires_rule]),
239
+ kb_relationship(Relation, ReqId, FactId),
240
+ kb_entity(FactId, fact, Props),
241
+ prop_atom(Props, claim_key, ClaimKey),
242
+ !.
243
+
244
+ % Current requirements a test reaches through the scenarios it verifies.
245
+ test_requirement(TestId, ReqId) :-
246
+ ( kb_relationship(validates, TestId, ScenarioId)
247
+ ; kb_relationship(verified_by, ScenarioId, TestId)
248
+ ),
249
+ kb_relationship(specified_by, ReqId, ScenarioId),
250
+ kb:current_req(ReqId).
251
+
252
+ %% test_claim_obligations_json(+TestId, +ClaimKeysJson, +ProofKind, -Json)
253
+ % For `kibi prove`: one row per claim key and requirement the test reaches,
254
+ % {claim_key, requirement_id, fact_id?, result, obligation?, reason?}, with
255
+ % result obligation, attested, incompatible or unresolved. A key no reached
256
+ % requirement declares gives one unresolved row without requirement_id.
257
+ test_claim_obligations_json(TestId0, KeysJson, Kind0, Json) :-
258
+ kb:normalize_term_atom(TestId0, TestId),
259
+ kb:normalize_term_atom(Kind0, Kind),
260
+ atom_json_term(KeysJson, Keys0, []),
261
+ maplist([K, A]>>(text_string(K, S), atom_string(A, S)), Keys0, Keys),
262
+ findall(ReqId, test_requirement(TestId, ReqId), ReqIds0),
263
+ sort(ReqIds0, ReqIds),
264
+ foldl(key_rows(Kind, ReqIds), Keys, [], RowsRev),
265
+ reverse(RowsRev, Rows),
266
+ with_output_to(string(Json), json_write_dict(current_output, Rows, [width(0)])).
267
+
268
+ key_rows(Kind, ReqIds, Key, Acc0, Acc) :-
269
+ findall(ReqId,
270
+ (member(ReqId, ReqIds), requirement_claim_status(ReqId, Key, _)),
271
+ Owners),
272
+ atom_string(Key, KeyString),
273
+ ( Owners == []
274
+ -> format(string(Reason),
275
+ "claim ~w is not a claim of any current requirement this test reaches through its scenarios",
276
+ [Key]),
277
+ Acc = [_{claim_key: KeyString, result: "unresolved", reason: Reason}|Acc0]
278
+ ; foldl(owner_row(Kind, Key, KeyString), Owners, Acc0, Acc)
279
+ ).
280
+
281
+ owner_row(Kind, Key, KeyString, ReqId, Acc0, [Row|Acc0]) :-
282
+ atom_string(ReqId, ReqString),
283
+ claim_obligation(ReqId-Key, Kind, Result),
284
+ Base = _{claim_key: KeyString, requirement_id: ReqString},
285
+ ( Result = obligation(FactId, Obligation)
286
+ -> atom_string(FactId, FactString),
287
+ Row = Base.put(_{result: "obligation", fact_id: FactString, obligation: Obligation})
288
+ ; Result = attested(FactId)
289
+ -> atom_string(FactId, FactString),
290
+ Row = Base.put(_{result: "attested", fact_id: FactString})
291
+ ; Result = incompatible(FactId, Reason)
292
+ -> atom_string(FactId, FactString),
293
+ Row = Base.put(_{result: "incompatible", fact_id: FactString, reason: Reason})
294
+ ; Result = unresolved(Reason),
295
+ Row = Base.put(_{result: "unresolved", reason: Reason})
296
+ ).
297
+
298
+ %% proof_contract_claim_issues_json(-Json)
299
+ % For the proof-contract-claims rule: one row {test_id, claim_key?, reason}
300
+ % per certificate-kind proof contract that names no claims in proves, and
301
+ % per claim key it names that no certificate of its kind could prove
302
+ % (not a modeled claim of a reached requirement, no grounding fact, or a
303
+ % fact the kind cannot express). An attested claim is not an issue here:
304
+ % it is reported as attested, and never counts as proved.
305
+ proof_contract_claim_issues_json(Json) :-
306
+ findall(Row, proof_contract_claim_issue(Row), Rows0),
307
+ sort(Rows0, Rows),
308
+ with_output_to(string(Json), json_write_dict(current_output, Rows, [width(0)])).
309
+
310
+ certificate_kind(exhaustive).
311
+ certificate_kind(typecheck).
312
+ certificate_kind(arithmetic).
313
+ certificate_kind(smt).
314
+
315
+ test_certificate_contract(TestId, Kind, Contract) :-
316
+ kb_entity(TestId, test, Props),
317
+ memberchk(proof_contract=Raw, Props),
318
+ contract_dict(Raw, Contract),
319
+ get_dict(kind, Contract, RawKind),
320
+ text_string(RawKind, KindString),
321
+ atom_string(Kind, KindString),
322
+ certificate_kind(Kind).
323
+
324
+ contract_dict(^^(Raw, _), Dict) :- !, contract_dict(Raw, Dict).
325
+ contract_dict(Dict, Dict) :- is_dict(Dict), !.
326
+ contract_dict(Raw, Dict) :-
327
+ ( atom(Raw) ; string(Raw) ),
328
+ catch(atom_json_dict(Raw, Dict, [value_string_as(string)]), _, fail),
329
+ is_dict(Dict).
330
+
331
+ proof_contract_claim_issue(_{test_id: TestString, reason: Reason}) :-
332
+ test_certificate_contract(TestId, Kind, Contract),
333
+ \+ ( get_dict(proves, Contract, Proves), is_list(Proves), Proves \== [] ),
334
+ atom_string(TestId, TestString),
335
+ format(string(Reason),
336
+ "the ~w proof contract names no claims in proves, so its certificate proves nothing about a requirement",
337
+ [Kind]).
338
+ proof_contract_claim_issue(_{test_id: TestString, claim_key: KeyString, reason: Reason}) :-
339
+ test_certificate_contract(TestId, Kind, Contract),
340
+ get_dict(proves, Contract, Proves),
341
+ is_list(Proves),
342
+ Proves \== [],
343
+ atom_json_term(KeysJson, Proves, []),
344
+ test_claim_obligations_json(TestId, KeysJson, Kind, RowsJson),
345
+ atom_json_dict(RowsJson, Rows, [value_string_as(string)]),
346
+ member(Row, Rows),
347
+ get_dict(result, Row, Result),
348
+ memberchk(Result, ["unresolved", "incompatible"]),
349
+ atom_string(TestId, TestString),
350
+ get_dict(claim_key, Row, KeyString),
351
+ get_dict(reason, Row, Reason).
352
+
353
+ %% proof_contract_claim_candidates_json(-Json)
354
+ % For the schema 12 migration: one row {test_id, kind, candidates} per
355
+ % certificate-kind proof contract without proves, where candidates are the
356
+ % claims {claim_key, requirement_id, fact_id} of current requirements the
357
+ % test reaches whose grounding fact derives an obligation for its kind. The
358
+ % migration pins proves only when exactly one claim is a candidate.
359
+ proof_contract_claim_candidates_json(Json) :-
360
+ findall(Row, proof_contract_claim_candidates(Row), Rows0),
361
+ sort(Rows0, Rows),
362
+ with_output_to(string(Json), json_write_dict(current_output, Rows, [width(0)])).
363
+
364
+ proof_contract_claim_candidates(_{test_id: TestString, kind: KindString, candidates: Candidates}) :-
365
+ test_certificate_contract(TestId, Kind, Contract),
366
+ \+ ( get_dict(proves, Contract, Proves), is_list(Proves), Proves \== [] ),
367
+ findall(_{claim_key: KeyString, requirement_id: ReqString, fact_id: FactString},
368
+ ( test_requirement(TestId, ReqId),
369
+ requirement_claim_status(ReqId, ClaimKey, modeled),
370
+ claim_obligation(ReqId-ClaimKey, Kind, obligation(FactId, _)),
371
+ atom_string(ClaimKey, KeyString),
372
+ atom_string(ReqId, ReqString),
373
+ atom_string(FactId, FactString)
374
+ ),
375
+ Candidates0),
376
+ sort(Candidates0, Candidates),
377
+ atom_string(TestId, TestString),
378
+ atom_string(Kind, KindString).
@@ -0,0 +1,297 @@
1
+ % Module: code_certificates
2
+ % implements REQ-kibi-code-proof-kinds
3
+ %
4
+ % Re-checks the kibi.code-certificate.v1 a certificate-kind proof contract
5
+ % leaves in its receipt. A receipt counts as proved only when its
6
+ % certificate re-checks here, so a test that claims to have covered every
7
+ % case cannot earn proved by saying so. The checks never search:
8
+ %
9
+ % exhaustive The domain lists at least one member, no member twice, and
10
+ % the results name every member exactly once, each passed.
11
+ % (Kibi checks at ingest that the proof imported the domain
12
+ % from its source instead of hand-listing it.)
13
+ % typecheck The witness names a file and its sha256 and the compiler
14
+ % that accepted it; the outcome is passed. (Kibi checks at
15
+ % ingest that the witness is the file in the workspace and
16
+ % imports the domain.)
17
+ % arithmetic The assumptions on one variable are satisfiable and entail
18
+ % the claim, decided exactly by intervals.pl: the assumptions
19
+ % plus the negated claim admit no value.
20
+ % smt The query asks (check-sat) and the solver answered unsat.
21
+ % (Kibi re-runs the solver on the query at ingest.)
22
+ %
23
+ % The digest a receipt records is the sha256 of the certificate's canonical
24
+ % JSON as canonical_json/2 below spells it. The CLI records the digest this
25
+ % module returns at ingest, so ingest and every later re-check hash the
26
+ % certificate the same way.
27
+
28
+ :- module(code_certificates, [
29
+ code_certificate_check/2,
30
+ code_certificate_digest/2,
31
+ code_certificate_check_json/2
32
+ ]).
33
+
34
+ :- use_module(library(http/json)).
35
+ :- use_module(library(crypto)).
36
+ :- use_module(library(apply)).
37
+ :- use_module(library(lists)).
38
+ :- use_module('intervals.pl', [
39
+ numeric_constraints_satisfiable/2,
40
+ numeric_constraint_entailed/3
41
+ ]).
42
+
43
+ code_certificate_version("kibi.code-certificate.v1").
44
+
45
+ %% code_certificate_check(+Certificate, -Result)
46
+ % Result is ok or rejected(Reason) with Reason a string naming what failed.
47
+ code_certificate_check(Certificate, Result) :-
48
+ ( catch(certificate_problem(Certificate, Reason), _, (Reason = "the certificate is malformed"))
49
+ -> Result = rejected(Reason)
50
+ ; Result = ok
51
+ ).
52
+
53
+ %% code_certificate_digest(+Certificate, -Digest)
54
+ code_certificate_digest(Certificate, Digest) :-
55
+ canonical_json(Certificate, Json),
56
+ crypto_data_hash(Json, Digest, [algorithm(sha256), encoding(utf8)]).
57
+
58
+ %% code_certificate_check_json(+CertificateJson, -ResultJson)
59
+ % Entry point for the CLI: {"accepted": true, "digest": D} or
60
+ % {"accepted": false, "reason": R}.
61
+ code_certificate_check_json(CertificateJson, ResultJson) :-
62
+ ( catch(atom_json_dict(CertificateJson, Certificate, [value_string_as(string)]), _, fail),
63
+ is_dict(Certificate)
64
+ -> code_certificate_check(Certificate, Result),
65
+ code_certificate_digest(Certificate, Digest),
66
+ ( Result == ok
67
+ -> Out = _{accepted: true, digest: Digest}
68
+ ; Result = rejected(Reason),
69
+ Out = _{accepted: false, digest: Digest, reason: Reason}
70
+ )
71
+ ; Out = _{accepted: false, reason: "the certificate is not a JSON object"}
72
+ ),
73
+ with_output_to(string(ResultJson), json_write_dict(current_output, Out, [width(0)])).
74
+
75
+ % certificate_problem(+Certificate, -Reason) succeeds with the first problem.
76
+ certificate_problem(Certificate, Reason) :-
77
+ \+ is_dict(Certificate), !,
78
+ Reason = "the certificate is not an object".
79
+ certificate_problem(Certificate, Reason) :-
80
+ code_certificate_version(Version),
81
+ \+ get_dict(version, Certificate, Version), !,
82
+ format(string(Reason), "the certificate version is not ~w", [Version]).
83
+ certificate_problem(Certificate, Reason) :-
84
+ \+ (get_dict(test_id, Certificate, TestId), nonempty_string(TestId)), !,
85
+ Reason = "the certificate names no test_id".
86
+ certificate_problem(Certificate, Reason) :-
87
+ ( get_dict(kind, Certificate, KindString), string(KindString)
88
+ -> atom_string(Kind, KindString)
89
+ ; Kind = none
90
+ ),
91
+ ( kind_problem(Kind, Certificate, Reason0)
92
+ -> Reason = Reason0
93
+ ; \+ memberchk(Kind, [exhaustive, typecheck, arithmetic, smt])
94
+ -> Reason = "the certificate kind is not exhaustive, typecheck, arithmetic or smt"
95
+ ).
96
+
97
+ kind_problem(exhaustive, Certificate, Reason) :-
98
+ field(Certificate, domain, Domain),
99
+ field(Domain, symbol_id, Symbol),
100
+ field(Domain, members, Members),
101
+ field(Certificate, results, Results),
102
+ ( \+ is_dict(Domain)
103
+ -> Reason = "the certificate has no domain"
104
+ ; \+ nonempty_string(Symbol)
105
+ -> Reason = "the domain names no symbol_id"
106
+ ; \+ (is_list(Members), Members \== [])
107
+ -> Reason = "the domain lists no members"
108
+ ; \+ maplist(nonempty_string, Members)
109
+ -> Reason = "every domain member must be a non-empty string"
110
+ ; msort(Members, Sorted), sort(Members, Unique), Sorted \== Unique
111
+ -> Reason = "the domain lists a member twice"
112
+ ; \+ is_list(Results)
113
+ -> Reason = "the certificate has no results"
114
+ ; \+ maplist(result_row, Results)
115
+ -> Reason = "every result must name a member and an outcome"
116
+ ; findall(M, (member(Row, Results), get_dict(member, Row, M)), Named),
117
+ msort(Named, NamedSorted),
118
+ msort(Members, MembersSorted),
119
+ NamedSorted \== MembersSorted
120
+ -> Reason = "the results do not name every domain member exactly once"
121
+ ; member(Row, Results),
122
+ get_dict(outcome, Row, Outcome),
123
+ Outcome \== "passed"
124
+ -> get_dict(member, Row, Failed),
125
+ format(string(Reason), "member ~w did not pass", [Failed])
126
+ ).
127
+ kind_problem(typecheck, Certificate, Reason) :-
128
+ field(Certificate, witness, Witness),
129
+ field(Witness, path, Path),
130
+ field(Witness, sha256, Hash),
131
+ field(Certificate, domain, Domain),
132
+ field(Domain, symbol_id, Symbol),
133
+ field(Certificate, compiler, Compiler),
134
+ field(Compiler, name, Name),
135
+ field(Compiler, version, CompilerVersion),
136
+ field(Certificate, outcome, Outcome),
137
+ ( \+ nonempty_string(Path)
138
+ -> Reason = "the certificate names no witness path"
139
+ ; \+ sha256_string(Hash)
140
+ -> Reason = "the witness has no sha256"
141
+ ; \+ nonempty_string(Symbol)
142
+ -> Reason = "the certificate names no domain symbol_id"
143
+ ; \+ (nonempty_string(Name), nonempty_string(CompilerVersion))
144
+ -> Reason = "the certificate names no compiler and version"
145
+ ; Outcome \== "passed"
146
+ -> Reason = "the type checker did not accept the witness"
147
+ ).
148
+ kind_problem(arithmetic, Certificate, Reason) :-
149
+ field(Certificate, variable, Variable),
150
+ field(Certificate, value_type, Type),
151
+ field(Certificate, assumptions, Assumptions),
152
+ field(Certificate, claim, Claim),
153
+ ( \+ nonempty_string(Variable)
154
+ -> Reason = "the certificate names no variable"
155
+ ; \+ memberchk(Type, ["int", "real"])
156
+ -> Reason = "value_type must be int or real"
157
+ ; \+ (is_list(Assumptions), Assumptions \== [])
158
+ -> Reason = "the certificate states no assumptions"
159
+ ; \+ maplist(arithmetic_constraint(_), Assumptions, _)
160
+ -> Reason = "every assumption must be {operator, value} with a comparison operator and a number"
161
+ ; \+ arithmetic_constraint(_, Claim, _)
162
+ -> Reason = "the claim must be {operator, value} with a comparison operator and a number"
163
+ ; maplist(arithmetic_constraint(X), Assumptions, Premises),
164
+ arithmetic_constraint(X, Claim, Goal),
165
+ integer_variables(Type, X, Integers),
166
+ ( \+ numeric_constraints_satisfiable(Premises, Integers)
167
+ -> Reason = "the assumptions admit no value, so the claim holds vacuously"
168
+ ; \+ numeric_constraint_entailed(Premises, Goal, Integers)
169
+ -> Reason = "the assumptions do not entail the claim: some value satisfies them and breaks it"
170
+ )
171
+ ).
172
+ kind_problem(smt, Certificate, Reason) :-
173
+ field(Certificate, solver, Solver),
174
+ field(Solver, name, Name),
175
+ field(Solver, version, SolverVersion),
176
+ field(Certificate, query, Query),
177
+ field(Certificate, result, Result),
178
+ ( \+ (nonempty_string(Name), nonempty_string(SolverVersion))
179
+ -> Reason = "the certificate names no solver and version"
180
+ ; \+ nonempty_string(Query)
181
+ -> Reason = "the certificate has no query"
182
+ ; \+ sub_string(Query, _, _, _, "(check-sat)")
183
+ -> Reason = "the query never asks (check-sat)"
184
+ ; Result \== "unsat"
185
+ -> Reason = "the solver did not answer unsat"
186
+ ).
187
+
188
+ % field(+Dict, +Key, -Value): the value, or '$missing' when absent.
189
+ field(Dict, Key, Value) :-
190
+ ( is_dict(Dict), get_dict(Key, Dict, Value0)
191
+ -> Value = Value0
192
+ ; Value = '$missing'
193
+ ).
194
+
195
+ result_row(Row) :-
196
+ is_dict(Row),
197
+ get_dict(member, Row, _),
198
+ get_dict(outcome, Row, _).
199
+
200
+ arithmetic_constraint(X, Row, c(Op, X, Value)) :-
201
+ is_dict(Row),
202
+ get_dict(operator, Row, OpString),
203
+ string(OpString),
204
+ atom_string(Op, OpString),
205
+ memberchk(Op, [eq, neq, lt, lte, gt, gte]),
206
+ get_dict(value, Row, Value),
207
+ number(Value).
208
+
209
+ integer_variables("int", X, [X]).
210
+ integer_variables("real", _, []).
211
+
212
+ nonempty_string(Value) :-
213
+ string(Value),
214
+ Value \== "".
215
+
216
+ sha256_string(Value) :-
217
+ string(Value),
218
+ string_length(Value, 64),
219
+ string_codes(Value, Codes),
220
+ forall(member(C, Codes), (code_type(C, digit) ; between(0'a, 0'f, C))).
221
+
222
+ % The canonical JSON is spelled out here rather than left to json_write, so
223
+ % it does not depend on the stream encoding or on how a key was read (a YAML
224
+ % key and a JSON key for the same text are the same key): object keys sorted
225
+ % by code point, no whitespace, ", \ and the JSON control escapes, other
226
+ % control characters and every non-ASCII character as \u escapes of their
227
+ % UTF-16 code units, integers in decimal.
228
+ canonical_json(Value, Json) :-
229
+ is_dict(Value), !,
230
+ dict_pairs(Value, _, Pairs0),
231
+ maplist(string_keyed, Pairs0, Pairs1),
232
+ keysort(Pairs1, Pairs),
233
+ maplist(canonical_pair, Pairs, Parts),
234
+ atomics_to_string(Parts, ",", Body),
235
+ format(string(Json), "{~s}", [Body]).
236
+ canonical_json(Value, Json) :-
237
+ is_list(Value), !,
238
+ maplist(canonical_json, Value, Parts),
239
+ atomics_to_string(Parts, ",", Body),
240
+ format(string(Json), "[~s]", [Body]).
241
+ canonical_json(Value, Json) :-
242
+ string(Value), !,
243
+ canonical_string(Value, Json).
244
+ canonical_json(Value, Json) :-
245
+ integer(Value), !,
246
+ format(string(Json), "~d", [Value]).
247
+ canonical_json(Value, Json) :-
248
+ memberchk(Value, [true, false, null]), !,
249
+ atom_string(Value, Json).
250
+ canonical_json(Value, Json) :-
251
+ atom(Value), !,
252
+ atom_string(Value, String),
253
+ canonical_string(String, Json).
254
+ canonical_json(Value, Json) :-
255
+ with_output_to(string(Json), json_write(current_output, Value, [width(0)])).
256
+
257
+ % Keys as code lists, so keysort orders them by code point whatever the
258
+ % reader made of them (an atom, or a small integer for a numeric key).
259
+ string_keyed(Key-Value, Codes-Value) :-
260
+ format(string(String), "~w", [Key]),
261
+ string_codes(String, Codes).
262
+
263
+ canonical_pair(Codes-Value, Json) :-
264
+ string_codes(Key, Codes),
265
+ canonical_string(Key, KeyJson),
266
+ canonical_json(Value, ValueJson),
267
+ format(string(Json), "~s:~s", [KeyJson, ValueJson]).
268
+
269
+ canonical_string(String, Json) :-
270
+ string_codes(String, Codes),
271
+ phrase(escaped_codes(Codes), Escaped),
272
+ string_codes(Body, Escaped),
273
+ format(string(Json), "\"~s\"", [Body]).
274
+
275
+ escaped_codes([]) --> [].
276
+ escaped_codes([C|Cs]) --> escaped_code(C), escaped_codes(Cs).
277
+
278
+ escaped_code(0'") --> !, "\\\"".
279
+ escaped_code(0'\\) --> !, "\\\\".
280
+ escaped_code(0'\b) --> !, "\\b".
281
+ escaped_code(0'\f) --> !, "\\f".
282
+ escaped_code(0'\n) --> !, "\\n".
283
+ escaped_code(0'\r) --> !, "\\r".
284
+ escaped_code(0'\t) --> !, "\\t".
285
+ escaped_code(C) --> { C >= 0x20, C =< 0x7e }, !, [C].
286
+ escaped_code(C) -->
287
+ { C > 0xffff }, !,
288
+ { V is C - 0x10000,
289
+ High is 0xd800 + (V >> 10),
290
+ Low is 0xdc00 + (V /\ 0x3ff) },
291
+ unit_escape(High),
292
+ unit_escape(Low).
293
+ escaped_code(C) --> unit_escape(C).
294
+
295
+ unit_escape(Unit) -->
296
+ { format(codes(Codes), "\\u~|~`0t~16r~4+", [Unit]) },
297
+ Codes.