@knolo/cli 6.2.0 → 6.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -3
- package/bin/flight.mjs +367 -0
- package/bin/knolo.mjs +52 -0
- package/bin/setup.mjs +260 -0
- package/bin/skill.mjs +1308 -0
- package/package.json +2 -2
- package/templates/icp-knowledge-canister/canisters/knolo-core-rust/src/kar/closure.rs +395 -0
- package/templates/icp-knowledge-canister/canisters/knolo-core-rust/src/kar/cover.rs +692 -0
- package/templates/icp-knowledge-canister/canisters/knolo-core-rust/src/kar/evaluate.rs +343 -0
- package/templates/icp-knowledge-canister/canisters/knolo-core-rust/src/kar/json.rs +486 -0
- package/templates/icp-knowledge-canister/canisters/knolo-core-rust/src/kar/mod.rs +14 -0
- package/templates/icp-knowledge-canister/canisters/knolo-core-rust/src/kar/model.rs +1068 -0
- package/templates/icp-knowledge-canister/canisters/knolo-core-rust/src/kar/session.rs +1156 -0
- package/templates/icp-knowledge-canister/canisters/knolo-core-rust/src/lib.rs +7 -1
|
@@ -0,0 +1,1156 @@
|
|
|
1
|
+
//! V5 KAR session, verification, and dossier rendering.
|
|
2
|
+
//! SATISFIED means the evidence requirements were met. It does not mean a claim is proven.
|
|
3
|
+
|
|
4
|
+
use crate::kar::closure::{close_frontiers, witness_json};
|
|
5
|
+
use crate::kar::cover::covers_plan;
|
|
6
|
+
use crate::kar::evaluate::{certificate_json, evaluate_bound, status_code, BoundInputs, KarResult};
|
|
7
|
+
use crate::kar::json::{canonicalize, digest_json, parse_json, stringify, Json};
|
|
8
|
+
use crate::kar::model::{
|
|
9
|
+
build_indexes, graph_json, is_root, only_keys, plan_json, prepare_graph, prepare_image,
|
|
10
|
+
semantic_root_of, validate_plan, Graph, Indexes, Plan, PreparedImage, FRONTIERS, KAR_API,
|
|
11
|
+
KAR_VERSION,
|
|
12
|
+
};
|
|
13
|
+
use crate::{mount_knowledge_image, CborValue};
|
|
14
|
+
|
|
15
|
+
const DOSSIER_FORMAT: &str = "kar-evidence-dossier-1";
|
|
16
|
+
const STATUSES: [&str; 8] = [
|
|
17
|
+
"SATISFIED",
|
|
18
|
+
"UNSATISFIED_EVIDENCE_REQUIREMENTS",
|
|
19
|
+
"SEARCH_BOUND_EXCEEDED",
|
|
20
|
+
"CLOSURE_BOUND_EXCEEDED",
|
|
21
|
+
"ANCHOR_REJECTED",
|
|
22
|
+
"GRAPH_NOT_BOUND",
|
|
23
|
+
"GRAPH_INVALID",
|
|
24
|
+
"PLAN_INVALID",
|
|
25
|
+
];
|
|
26
|
+
const ROOT_FIELDS: [&str; 10] = [
|
|
27
|
+
"knowledgeRoot",
|
|
28
|
+
"semanticRoot",
|
|
29
|
+
"queryRoot",
|
|
30
|
+
"planRoot",
|
|
31
|
+
"anchorRoot",
|
|
32
|
+
"frontierRoot",
|
|
33
|
+
"frontierWitnessRoot",
|
|
34
|
+
"evidenceSetRoot",
|
|
35
|
+
"decisionRoot",
|
|
36
|
+
"karRoot",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
#[derive(Debug, Clone)]
|
|
40
|
+
pub struct KarRunError {
|
|
41
|
+
pub code: String,
|
|
42
|
+
pub message: String,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#[derive(Debug, Clone)]
|
|
46
|
+
pub struct KarEvaluation {
|
|
47
|
+
pub status: String,
|
|
48
|
+
pub code: Option<String>,
|
|
49
|
+
pub proposition: String,
|
|
50
|
+
pub anchor_mode: Option<String>,
|
|
51
|
+
pub frontiers: Vec<(String, Vec<String>)>,
|
|
52
|
+
pub selected_len: usize,
|
|
53
|
+
pub knowledge_root: String,
|
|
54
|
+
pub semantic_root: String,
|
|
55
|
+
pub kar_root: String,
|
|
56
|
+
pub json: String,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#[derive(Debug, Clone)]
|
|
60
|
+
pub struct KarVerification {
|
|
61
|
+
pub ok: bool,
|
|
62
|
+
pub code: String,
|
|
63
|
+
pub reason: String,
|
|
64
|
+
pub status: Option<String>,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#[derive(Debug, Clone)]
|
|
68
|
+
pub struct KarDossier {
|
|
69
|
+
pub status_statement: String,
|
|
70
|
+
pub markdown: String,
|
|
71
|
+
pub manifest_json: String,
|
|
72
|
+
pub certificate_json: String,
|
|
73
|
+
pub evaluation_json: String,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
struct ObjectRow {
|
|
77
|
+
id: String,
|
|
78
|
+
text: String,
|
|
79
|
+
source: Option<String>,
|
|
80
|
+
metadata: Json,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
struct Session {
|
|
84
|
+
image: Json,
|
|
85
|
+
graph: Graph,
|
|
86
|
+
prepared: PreparedImage,
|
|
87
|
+
indexes: Indexes,
|
|
88
|
+
objects: Vec<ObjectRow>,
|
|
89
|
+
semantic_root: String,
|
|
90
|
+
plan_input: Json,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
pub fn evaluate_kar_v5(
|
|
94
|
+
image: &[u8],
|
|
95
|
+
sidecar_json: &str,
|
|
96
|
+
proposition: &str,
|
|
97
|
+
plan_json_text: &str,
|
|
98
|
+
) -> Result<KarEvaluation, KarRunError> {
|
|
99
|
+
let session = open_session(image, sidecar_json, plan_json_text)?;
|
|
100
|
+
Ok(evaluation_from(&session, proposition))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
pub fn verify_kar_v5(
|
|
104
|
+
image: &[u8],
|
|
105
|
+
sidecar_json: &str,
|
|
106
|
+
proposition: &str,
|
|
107
|
+
plan_json_text: &str,
|
|
108
|
+
claimed_json: &str,
|
|
109
|
+
) -> KarVerification {
|
|
110
|
+
let session = match open_session(image, sidecar_json, plan_json_text) {
|
|
111
|
+
Ok(session) => session,
|
|
112
|
+
Err(error) => {
|
|
113
|
+
return KarVerification {
|
|
114
|
+
ok: false,
|
|
115
|
+
code: error.code,
|
|
116
|
+
reason: error.message,
|
|
117
|
+
status: None,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
let actual = evaluation_from(&session, proposition);
|
|
122
|
+
compare_claim(&actual, claimed_json, proposition)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
pub fn create_kar_dossier_v5(
|
|
126
|
+
image: &[u8],
|
|
127
|
+
sidecar_json: &str,
|
|
128
|
+
proposition: &str,
|
|
129
|
+
plan_json_text: &str,
|
|
130
|
+
) -> Result<KarDossier, KarRunError> {
|
|
131
|
+
let session = open_session(image, sidecar_json, plan_json_text)?;
|
|
132
|
+
Ok(dossier_from(&session, proposition))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
pub fn render_kar_dossier(dossier: &KarDossier) -> String {
|
|
136
|
+
dossier.markdown.clone()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
pub fn verify_kar_dossier_v5(
|
|
140
|
+
image: &[u8],
|
|
141
|
+
sidecar_json: &str,
|
|
142
|
+
proposition: &str,
|
|
143
|
+
plan_json_text: &str,
|
|
144
|
+
manifest_json: &str,
|
|
145
|
+
certificate_json_text: &str,
|
|
146
|
+
evaluation_json: &str,
|
|
147
|
+
evidence_markdown: Option<&str>,
|
|
148
|
+
) -> KarVerification {
|
|
149
|
+
let verdict = verify_kar_v5(
|
|
150
|
+
image,
|
|
151
|
+
sidecar_json,
|
|
152
|
+
proposition,
|
|
153
|
+
plan_json_text,
|
|
154
|
+
certificate_json_text,
|
|
155
|
+
);
|
|
156
|
+
if !verdict.ok {
|
|
157
|
+
return verdict;
|
|
158
|
+
}
|
|
159
|
+
let actual = match create_kar_dossier_v5(image, sidecar_json, proposition, plan_json_text) {
|
|
160
|
+
Ok(dossier) => dossier,
|
|
161
|
+
Err(error) => {
|
|
162
|
+
return KarVerification {
|
|
163
|
+
ok: false,
|
|
164
|
+
code: error.code,
|
|
165
|
+
reason: error.message,
|
|
166
|
+
status: None,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
let claimed_manifest = match parse_json(manifest_json) {
|
|
171
|
+
Ok(value) => value,
|
|
172
|
+
Err(error) => {
|
|
173
|
+
return KarVerification {
|
|
174
|
+
ok: false,
|
|
175
|
+
code: "KAR_RESULT_MISMATCH".into(),
|
|
176
|
+
reason: error,
|
|
177
|
+
status: Some(actual.status_statement.clone()),
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
let actual_manifest = parse_json(&actual.manifest_json).expect("dossier manifest is JSON");
|
|
182
|
+
if canonicalize(&claimed_manifest) != canonicalize(&actual_manifest) {
|
|
183
|
+
return KarVerification {
|
|
184
|
+
ok: false,
|
|
185
|
+
code: "KAR_RESULT_MISMATCH".into(),
|
|
186
|
+
reason: "dossier manifest mismatch".into(),
|
|
187
|
+
status: Some(status_of(&actual)),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
let claimed_certificate = match parse_json(certificate_json_text) {
|
|
191
|
+
Ok(value) => value,
|
|
192
|
+
Err(error) => {
|
|
193
|
+
return KarVerification {
|
|
194
|
+
ok: false,
|
|
195
|
+
code: "KAR_RESULT_MISMATCH".into(),
|
|
196
|
+
reason: error,
|
|
197
|
+
status: Some(status_of(&actual)),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
let actual_certificate =
|
|
202
|
+
parse_json(&actual.certificate_json).expect("dossier certificate is JSON");
|
|
203
|
+
if canonicalize(&claimed_certificate) != canonicalize(&actual_certificate) {
|
|
204
|
+
return KarVerification {
|
|
205
|
+
ok: false,
|
|
206
|
+
code: "KAR_RESULT_MISMATCH".into(),
|
|
207
|
+
reason: "dossier certificate mismatch".into(),
|
|
208
|
+
status: Some(status_of(&actual)),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
let claimed_evaluation = match parse_json(evaluation_json) {
|
|
212
|
+
Ok(value) => value,
|
|
213
|
+
Err(error) => {
|
|
214
|
+
return KarVerification {
|
|
215
|
+
ok: false,
|
|
216
|
+
code: "KAR_RESULT_MISMATCH".into(),
|
|
217
|
+
reason: error,
|
|
218
|
+
status: Some(status_of(&actual)),
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
let actual_evaluation =
|
|
223
|
+
parse_json(&actual.evaluation_json).expect("dossier evaluation is JSON");
|
|
224
|
+
if canonicalize(&claimed_evaluation) != canonicalize(&actual_evaluation) {
|
|
225
|
+
return KarVerification {
|
|
226
|
+
ok: false,
|
|
227
|
+
code: "KAR_RESULT_MISMATCH".into(),
|
|
228
|
+
reason: "dossier result mismatch".into(),
|
|
229
|
+
status: Some(status_of(&actual)),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if let Some(markdown) = evidence_markdown {
|
|
233
|
+
if markdown != actual.markdown {
|
|
234
|
+
return KarVerification {
|
|
235
|
+
ok: false,
|
|
236
|
+
code: "KAR_RESULT_MISMATCH".into(),
|
|
237
|
+
reason: "dossier rendering mismatch".into(),
|
|
238
|
+
status: Some(status_of(&actual)),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
KarVerification {
|
|
243
|
+
ok: true,
|
|
244
|
+
code: "VERIFIED".into(),
|
|
245
|
+
reason: String::new(),
|
|
246
|
+
status: Some(status_of(&actual)),
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
fn status_of(dossier: &KarDossier) -> String {
|
|
251
|
+
parse_json(&dossier.evaluation_json)
|
|
252
|
+
.ok()
|
|
253
|
+
.and_then(|value| {
|
|
254
|
+
value
|
|
255
|
+
.get("status")
|
|
256
|
+
.and_then(Json::as_str)
|
|
257
|
+
.map(str::to_string)
|
|
258
|
+
})
|
|
259
|
+
.unwrap_or_default()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
fn open_session(
|
|
263
|
+
image: &[u8],
|
|
264
|
+
sidecar_json: &str,
|
|
265
|
+
plan_json_text: &str,
|
|
266
|
+
) -> Result<Session, KarRunError> {
|
|
267
|
+
let mounted = mount_knowledge_image(image).map_err(|error| KarRunError {
|
|
268
|
+
code: "KAR_IMAGE_REJECTED".into(),
|
|
269
|
+
message: error.to_string(),
|
|
270
|
+
})?;
|
|
271
|
+
let sidecar = parse_json(sidecar_json).map_err(|error| KarRunError {
|
|
272
|
+
code: "KAR_SIDECAR_INVALID".into(),
|
|
273
|
+
message: error,
|
|
274
|
+
})?;
|
|
275
|
+
let plan_input = parse_json(plan_json_text).map_err(|error| KarRunError {
|
|
276
|
+
code: "KAR_PLAN_INVALID".into(),
|
|
277
|
+
message: error,
|
|
278
|
+
})?;
|
|
279
|
+
let sidecar_value = validate_sidecar(&sidecar)?;
|
|
280
|
+
let mut rows = Vec::new();
|
|
281
|
+
for object in &mounted.objects {
|
|
282
|
+
let text = decode_object_text(&object.bytes);
|
|
283
|
+
let meta = cbor_to_json(&object.meta).map_err(|error| KarRunError {
|
|
284
|
+
code: "KAR_IMAGE_REJECTED".into(),
|
|
285
|
+
message: error,
|
|
286
|
+
})?;
|
|
287
|
+
let source = object_source(&object.kind, &meta);
|
|
288
|
+
rows.push(ObjectRow {
|
|
289
|
+
id: object.id.clone(),
|
|
290
|
+
text,
|
|
291
|
+
source,
|
|
292
|
+
metadata: Json::object(vec![
|
|
293
|
+
("kind", Json::String(object.kind.clone())),
|
|
294
|
+
("meta", meta),
|
|
295
|
+
]),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
rows.sort_by(|left, right| crate::kar::json::utf16_cmp(&left.id, &right.id));
|
|
299
|
+
let prepared = prepare_image(
|
|
300
|
+
rows.iter()
|
|
301
|
+
.map(|row| (row.id.clone(), row.text.clone()))
|
|
302
|
+
.collect(),
|
|
303
|
+
);
|
|
304
|
+
let binding = binding_error(
|
|
305
|
+
&sidecar_value,
|
|
306
|
+
&prepared,
|
|
307
|
+
&mounted.state_root,
|
|
308
|
+
&mounted.commit.object_root,
|
|
309
|
+
&mounted.commit_digest,
|
|
310
|
+
);
|
|
311
|
+
if let Some(error) = binding {
|
|
312
|
+
return Err(error);
|
|
313
|
+
}
|
|
314
|
+
let indexes = build_indexes(&sidecar_value.graph);
|
|
315
|
+
let semantic_root = semantic_root_of(&sidecar_value.graph);
|
|
316
|
+
let image_identity = Json::object(vec![
|
|
317
|
+
("commitDigest", Json::String(mounted.commit_digest.clone())),
|
|
318
|
+
(
|
|
319
|
+
"knowledgeRoot",
|
|
320
|
+
Json::String(prepared.knowledge_root.clone()),
|
|
321
|
+
),
|
|
322
|
+
(
|
|
323
|
+
"objectRoot",
|
|
324
|
+
Json::String(mounted.commit.object_root.clone()),
|
|
325
|
+
),
|
|
326
|
+
("stateRoot", Json::String(mounted.state_root.clone())),
|
|
327
|
+
]);
|
|
328
|
+
Ok(Session {
|
|
329
|
+
image: image_identity,
|
|
330
|
+
graph: sidecar_value.graph,
|
|
331
|
+
prepared,
|
|
332
|
+
indexes,
|
|
333
|
+
objects: rows,
|
|
334
|
+
semantic_root,
|
|
335
|
+
plan_input,
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
struct Sidecar {
|
|
340
|
+
state_root: String,
|
|
341
|
+
object_root: String,
|
|
342
|
+
commit_digest: String,
|
|
343
|
+
knowledge_root: String,
|
|
344
|
+
graph: Graph,
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
fn validate_sidecar(input: &Json) -> Result<Sidecar, KarRunError> {
|
|
348
|
+
let Some(raw) = input.as_object() else {
|
|
349
|
+
return Err(KarRunError {
|
|
350
|
+
code: "KAR_SIDECAR_INVALID".into(),
|
|
351
|
+
message: "A KAR sidecar must be an object.".into(),
|
|
352
|
+
});
|
|
353
|
+
};
|
|
354
|
+
if !only_keys(
|
|
355
|
+
raw,
|
|
356
|
+
&[
|
|
357
|
+
"version",
|
|
358
|
+
"stateRoot",
|
|
359
|
+
"objectRoot",
|
|
360
|
+
"commitDigest",
|
|
361
|
+
"knowledgeRoot",
|
|
362
|
+
"graph",
|
|
363
|
+
],
|
|
364
|
+
) || input.get("version").and_then(Json::as_int) != Some(1)
|
|
365
|
+
{
|
|
366
|
+
return Err(KarRunError {
|
|
367
|
+
code: "KAR_SIDECAR_INVALID".into(),
|
|
368
|
+
message: "Sidecar version must be 1.".into(),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
for field in ["stateRoot", "objectRoot", "commitDigest", "knowledgeRoot"] {
|
|
372
|
+
let Some(value) = input.get(field).and_then(Json::as_str) else {
|
|
373
|
+
return Err(KarRunError {
|
|
374
|
+
code: "KAR_SIDECAR_INVALID".into(),
|
|
375
|
+
message: format!("{field} must be a sha256 digest."),
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
if !is_root(value) {
|
|
379
|
+
return Err(KarRunError {
|
|
380
|
+
code: "KAR_SIDECAR_INVALID".into(),
|
|
381
|
+
message: format!("{field} must be a sha256 digest."),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
let Some(graph_value) = input.get("graph") else {
|
|
386
|
+
return Err(KarRunError {
|
|
387
|
+
code: "KAR_GRAPH_INVALID".into(),
|
|
388
|
+
message: "The committed evidence graph is not valid.".into(),
|
|
389
|
+
});
|
|
390
|
+
};
|
|
391
|
+
let Some(graph) = prepare_graph(graph_value) else {
|
|
392
|
+
return Err(KarRunError {
|
|
393
|
+
code: "KAR_GRAPH_INVALID".into(),
|
|
394
|
+
message: "The committed evidence graph is not valid.".into(),
|
|
395
|
+
});
|
|
396
|
+
};
|
|
397
|
+
let knowledge_root = input
|
|
398
|
+
.get("knowledgeRoot")
|
|
399
|
+
.and_then(Json::as_str)
|
|
400
|
+
.unwrap_or("")
|
|
401
|
+
.to_string();
|
|
402
|
+
if graph.knowledge_root != knowledge_root {
|
|
403
|
+
return Err(KarRunError {
|
|
404
|
+
code: "KAR_GRAPH_NOT_BOUND".into(),
|
|
405
|
+
message:
|
|
406
|
+
"Sidecar knowledgeRoot does not equal the committed evidence graph knowledgeRoot."
|
|
407
|
+
.into(),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
Ok(Sidecar {
|
|
411
|
+
state_root: input
|
|
412
|
+
.get("stateRoot")
|
|
413
|
+
.and_then(Json::as_str)
|
|
414
|
+
.unwrap_or("")
|
|
415
|
+
.to_string(),
|
|
416
|
+
object_root: input
|
|
417
|
+
.get("objectRoot")
|
|
418
|
+
.and_then(Json::as_str)
|
|
419
|
+
.unwrap_or("")
|
|
420
|
+
.to_string(),
|
|
421
|
+
commit_digest: input
|
|
422
|
+
.get("commitDigest")
|
|
423
|
+
.and_then(Json::as_str)
|
|
424
|
+
.unwrap_or("")
|
|
425
|
+
.to_string(),
|
|
426
|
+
knowledge_root,
|
|
427
|
+
graph,
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
fn binding_error(
|
|
432
|
+
sidecar: &Sidecar,
|
|
433
|
+
prepared: &PreparedImage,
|
|
434
|
+
state_root: &str,
|
|
435
|
+
object_root: &str,
|
|
436
|
+
commit_digest: &str,
|
|
437
|
+
) -> Option<KarRunError> {
|
|
438
|
+
if sidecar.state_root != state_root {
|
|
439
|
+
return Some(KarRunError {
|
|
440
|
+
code: "KAR_GRAPH_NOT_BOUND".into(),
|
|
441
|
+
message: "Sidecar stateRoot does not match the mounted V5 image.".into(),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
if sidecar.object_root != object_root {
|
|
445
|
+
return Some(KarRunError {
|
|
446
|
+
code: "KAR_GRAPH_NOT_BOUND".into(),
|
|
447
|
+
message: "Sidecar objectRoot does not match the mounted V5 image.".into(),
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
if sidecar.commit_digest != commit_digest {
|
|
451
|
+
return Some(KarRunError {
|
|
452
|
+
code: "KAR_GRAPH_NOT_BOUND".into(),
|
|
453
|
+
message: "Sidecar commitDigest does not match the mounted V5 image.".into(),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
if sidecar.knowledge_root != prepared.knowledge_root {
|
|
457
|
+
return Some(KarRunError { code: "KAR_GRAPH_NOT_BOUND".into(), message: format!("Sidecar knowledgeRoot does not match the frozen KAR projection of this image. image {} sidecar {}", prepared.knowledge_root, sidecar.knowledge_root) });
|
|
458
|
+
}
|
|
459
|
+
None
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
fn evaluation_from(session: &Session, proposition: &str) -> KarEvaluation {
|
|
463
|
+
let certificate = evaluate_bound(&BoundInputs {
|
|
464
|
+
prepared: Some(&session.prepared),
|
|
465
|
+
graph: Some(&session.graph),
|
|
466
|
+
indexes: Some(&session.indexes),
|
|
467
|
+
image_input: &Json::Null,
|
|
468
|
+
graph_input: &graph_json(&session.graph),
|
|
469
|
+
query: proposition,
|
|
470
|
+
plan_input: &session.plan_input,
|
|
471
|
+
knowledge_root: Some(&session.prepared.knowledge_root),
|
|
472
|
+
semantic_root: Some(&session.semantic_root),
|
|
473
|
+
});
|
|
474
|
+
let plan = validate_plan(&session.plan_input);
|
|
475
|
+
let json = evaluation_json(session, proposition, plan.as_ref(), &certificate);
|
|
476
|
+
KarEvaluation {
|
|
477
|
+
status: certificate.status.clone(),
|
|
478
|
+
code: status_code(&certificate.status),
|
|
479
|
+
proposition: proposition.to_string(),
|
|
480
|
+
anchor_mode: plan.as_ref().map(anchor_mode),
|
|
481
|
+
frontiers: certificate.frontiers.clone(),
|
|
482
|
+
selected_len: certificate.evidence_ids.len(),
|
|
483
|
+
knowledge_root: certificate.roots.knowledge_root.clone(),
|
|
484
|
+
semantic_root: certificate.roots.semantic_root.clone(),
|
|
485
|
+
kar_root: certificate.roots.kar_root.clone(),
|
|
486
|
+
json: stringify(&json),
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
fn anchor_mode(plan: &Plan) -> String {
|
|
491
|
+
match plan.anchor {
|
|
492
|
+
crate::kar::model::AnchorSpec::Recompute { .. } => "recompute".into(),
|
|
493
|
+
crate::kar::model::AnchorSpec::Supplied { .. } => "supplied".into(),
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
fn evaluation_json(
|
|
498
|
+
session: &Session,
|
|
499
|
+
proposition: &str,
|
|
500
|
+
plan: Option<&Plan>,
|
|
501
|
+
certificate: &KarResult,
|
|
502
|
+
) -> Json {
|
|
503
|
+
let choices = certificate
|
|
504
|
+
.choices
|
|
505
|
+
.iter()
|
|
506
|
+
.map(|choice| (choice.evidence_id.clone(), choice.frontiers.clone()))
|
|
507
|
+
.collect::<Vec<_>>();
|
|
508
|
+
let selected = certificate
|
|
509
|
+
.evidence_ids
|
|
510
|
+
.iter()
|
|
511
|
+
.map(|id| {
|
|
512
|
+
let object = session.objects.iter().find(|row| &row.id == id);
|
|
513
|
+
let frontiers = choices
|
|
514
|
+
.iter()
|
|
515
|
+
.find(|(evidence_id, _)| evidence_id == id)
|
|
516
|
+
.map(|(_, frontiers)| frontiers.clone())
|
|
517
|
+
.unwrap_or_default();
|
|
518
|
+
let mut pairs = vec![
|
|
519
|
+
(
|
|
520
|
+
"frontiers".to_string(),
|
|
521
|
+
Json::Array(frontiers.into_iter().map(Json::String).collect()),
|
|
522
|
+
),
|
|
523
|
+
("id".to_string(), Json::String(id.clone())),
|
|
524
|
+
];
|
|
525
|
+
if let Some(object) = object {
|
|
526
|
+
pairs.push(("metadata".to_string(), object.metadata.clone()));
|
|
527
|
+
if let Some(source) = &object.source {
|
|
528
|
+
pairs.push(("source".to_string(), Json::String(source.clone())));
|
|
529
|
+
}
|
|
530
|
+
pairs.push(("text".to_string(), Json::String(object.text.clone())));
|
|
531
|
+
}
|
|
532
|
+
Json::Object(pairs)
|
|
533
|
+
})
|
|
534
|
+
.collect();
|
|
535
|
+
Json::object(vec![
|
|
536
|
+
("api", Json::String(KAR_API.into())),
|
|
537
|
+
("certificate", certificate_json(certificate)),
|
|
538
|
+
("code", certificate_code(certificate)),
|
|
539
|
+
("experimental", Json::Bool(true)),
|
|
540
|
+
(
|
|
541
|
+
"frontiers",
|
|
542
|
+
crate::kar::model::frontiers_json(&certificate.frontiers),
|
|
543
|
+
),
|
|
544
|
+
("image", session.image.clone()),
|
|
545
|
+
("plan", plan.map(plan_json).unwrap_or(Json::Null)),
|
|
546
|
+
("proposition", Json::String(proposition.to_string())),
|
|
547
|
+
("selectedEvidence", Json::Array(selected)),
|
|
548
|
+
("status", Json::String(certificate.status.clone())),
|
|
549
|
+
("version", Json::String(KAR_VERSION.into())),
|
|
550
|
+
(
|
|
551
|
+
"witnesses",
|
|
552
|
+
Json::Array(certificate.witnesses.iter().map(witness_json).collect()),
|
|
553
|
+
),
|
|
554
|
+
])
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
fn certificate_code(certificate: &KarResult) -> Json {
|
|
558
|
+
match status_code(&certificate.status) {
|
|
559
|
+
Some(code) => Json::String(code),
|
|
560
|
+
None => Json::Null,
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
fn compare_claim(actual: &KarEvaluation, claimed_json: &str, proposition: &str) -> KarVerification {
|
|
565
|
+
let claimed_value = match parse_json(claimed_json) {
|
|
566
|
+
Ok(value) => value,
|
|
567
|
+
Err(_) => {
|
|
568
|
+
return KarVerification {
|
|
569
|
+
ok: false,
|
|
570
|
+
code: "KAR_RESULT_INVALID".into(),
|
|
571
|
+
reason: "The claimed result is not a KAR certificate.".into(),
|
|
572
|
+
status: Some(actual.status.clone()),
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
let Some(claimed) = certificate_of(&claimed_value) else {
|
|
577
|
+
return KarVerification {
|
|
578
|
+
ok: false,
|
|
579
|
+
code: "KAR_RESULT_INVALID".into(),
|
|
580
|
+
reason: "The claimed result is not a KAR certificate.".into(),
|
|
581
|
+
status: Some(actual.status.clone()),
|
|
582
|
+
};
|
|
583
|
+
};
|
|
584
|
+
let actual_certificate = parse_json(&actual.json)
|
|
585
|
+
.ok()
|
|
586
|
+
.and_then(|value| value.get("certificate").cloned())
|
|
587
|
+
.unwrap_or(Json::Null);
|
|
588
|
+
if canonicalize(&actual_certificate) != canonicalize(claimed) {
|
|
589
|
+
return KarVerification {
|
|
590
|
+
ok: false,
|
|
591
|
+
code: "KAR_RESULT_MISMATCH".into(),
|
|
592
|
+
reason: mismatch_reason(&actual_certificate, claimed),
|
|
593
|
+
status: Some(actual.status.clone()),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
let Some(envelope) = claimed_json_object(&claimed_value) else {
|
|
597
|
+
return KarVerification {
|
|
598
|
+
ok: true,
|
|
599
|
+
code: "VERIFIED".into(),
|
|
600
|
+
reason: String::new(),
|
|
601
|
+
status: Some(actual.status.clone()),
|
|
602
|
+
};
|
|
603
|
+
};
|
|
604
|
+
if envelope.get("certificate").is_none() {
|
|
605
|
+
return KarVerification {
|
|
606
|
+
ok: true,
|
|
607
|
+
code: "VERIFIED".into(),
|
|
608
|
+
reason: String::new(),
|
|
609
|
+
status: Some(actual.status.clone()),
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
let actual_value = parse_json(&actual.json).expect("evaluation JSON");
|
|
613
|
+
if let Some(flag) = envelope.get("experimental") {
|
|
614
|
+
if flag != &Json::Bool(true) {
|
|
615
|
+
return mismatch(actual, "experimental flag mismatch");
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if let Some(Json::String(value)) = envelope.get("proposition") {
|
|
619
|
+
if value != proposition {
|
|
620
|
+
return mismatch(actual, "proposition mismatch");
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
for (field, reason) in [
|
|
624
|
+
("plan", "plan mismatch"),
|
|
625
|
+
("image", "image identity mismatch"),
|
|
626
|
+
("selectedEvidence", "selected evidence mismatch"),
|
|
627
|
+
("frontiers", "frontier mismatch"),
|
|
628
|
+
("witnesses", "witness mismatch"),
|
|
629
|
+
] {
|
|
630
|
+
if let Some(value) = envelope.get(field) {
|
|
631
|
+
if canonicalize(value) != canonicalize(actual_value.get(field).unwrap_or(&Json::Null)) {
|
|
632
|
+
return mismatch(actual, reason);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if let Some(code) = envelope.get("code") {
|
|
637
|
+
if code != actual_value.get("code").unwrap_or(&Json::Null) {
|
|
638
|
+
return mismatch(actual, "status code mismatch");
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if let Some(status) = envelope.get("status") {
|
|
642
|
+
if status != actual_value.get("status").unwrap_or(&Json::Null) {
|
|
643
|
+
return mismatch(actual, "status mismatch");
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
KarVerification {
|
|
647
|
+
ok: true,
|
|
648
|
+
code: "VERIFIED".into(),
|
|
649
|
+
reason: String::new(),
|
|
650
|
+
status: Some(actual.status.clone()),
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
fn mismatch(actual: &KarEvaluation, reason: &str) -> KarVerification {
|
|
655
|
+
KarVerification {
|
|
656
|
+
ok: false,
|
|
657
|
+
code: "KAR_RESULT_MISMATCH".into(),
|
|
658
|
+
reason: reason.into(),
|
|
659
|
+
status: Some(actual.status.clone()),
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
fn claimed_json_object(value: &Json) -> Option<&Json> {
|
|
664
|
+
value.as_object().map(|_| value)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
fn certificate_of(input: &Json) -> Option<&Json> {
|
|
668
|
+
if input.get("certificate").and_then(Json::as_object).is_some() {
|
|
669
|
+
return certificate_of(input.get("certificate")?);
|
|
670
|
+
}
|
|
671
|
+
if input.get("version").and_then(Json::as_str) != Some(KAR_VERSION) {
|
|
672
|
+
return None;
|
|
673
|
+
}
|
|
674
|
+
let status = input.get("status").and_then(Json::as_str)?;
|
|
675
|
+
if !STATUSES.contains(&status) {
|
|
676
|
+
return None;
|
|
677
|
+
}
|
|
678
|
+
string_array(input.get("evidenceIds")?)?;
|
|
679
|
+
if !matches!(input.get("choices")?, Json::Array(_))
|
|
680
|
+
|| !matches!(input.get("witnesses")?, Json::Array(_))
|
|
681
|
+
{
|
|
682
|
+
return None;
|
|
683
|
+
}
|
|
684
|
+
let frontiers = input.get("frontiers")?;
|
|
685
|
+
let decision = input.get("decision")?;
|
|
686
|
+
let roots = input.get("roots")?;
|
|
687
|
+
for frontier in FRONTIERS {
|
|
688
|
+
string_array(frontiers.get(frontier)?)?;
|
|
689
|
+
}
|
|
690
|
+
if decision.get("status").and_then(Json::as_str) != Some(status) {
|
|
691
|
+
return None;
|
|
692
|
+
}
|
|
693
|
+
for name in ROOT_FIELDS {
|
|
694
|
+
let root = roots.get(name).and_then(Json::as_str)?;
|
|
695
|
+
if !is_root(root) {
|
|
696
|
+
return None;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
Some(input)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
fn string_array(value: &Json) -> Option<()> {
|
|
703
|
+
let Json::Array(items) = value else {
|
|
704
|
+
return None;
|
|
705
|
+
};
|
|
706
|
+
if items.iter().all(|item| matches!(item, Json::String(_))) {
|
|
707
|
+
Some(())
|
|
708
|
+
} else {
|
|
709
|
+
None
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
fn mismatch_reason(actual: &Json, claimed: &Json) -> String {
|
|
714
|
+
for field in [
|
|
715
|
+
"version",
|
|
716
|
+
"status",
|
|
717
|
+
"evidenceIds",
|
|
718
|
+
"choices",
|
|
719
|
+
"frontiers",
|
|
720
|
+
"witnesses",
|
|
721
|
+
"decision",
|
|
722
|
+
"roots",
|
|
723
|
+
] {
|
|
724
|
+
if canonicalize(actual.get(field).unwrap_or(&Json::Null))
|
|
725
|
+
== canonicalize(claimed.get(field).unwrap_or(&Json::Null))
|
|
726
|
+
{
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if field == "roots" {
|
|
730
|
+
for root in ROOT_FIELDS {
|
|
731
|
+
let left = actual
|
|
732
|
+
.get("roots")
|
|
733
|
+
.and_then(|roots| roots.get(root))
|
|
734
|
+
.and_then(Json::as_str)
|
|
735
|
+
.unwrap_or("");
|
|
736
|
+
let right = claimed
|
|
737
|
+
.get("roots")
|
|
738
|
+
.and_then(|roots| roots.get(root))
|
|
739
|
+
.and_then(Json::as_str)
|
|
740
|
+
.unwrap_or("");
|
|
741
|
+
if left != right {
|
|
742
|
+
return format!("root mismatch {root}");
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if field == "status" {
|
|
747
|
+
let left = actual.get("status").and_then(Json::as_str).unwrap_or("");
|
|
748
|
+
let right = claimed.get("status").and_then(Json::as_str).unwrap_or("");
|
|
749
|
+
return format!("status {left} did not match {right}");
|
|
750
|
+
}
|
|
751
|
+
return format!("field mismatch {field}");
|
|
752
|
+
}
|
|
753
|
+
"certificate mismatch".into()
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
fn dossier_from(session: &Session, proposition: &str) -> KarDossier {
|
|
757
|
+
let evaluation = evaluation_from(session, proposition);
|
|
758
|
+
let evaluation_value = parse_json(&evaluation.json).expect("evaluation JSON");
|
|
759
|
+
let certificate = evaluation_value
|
|
760
|
+
.get("certificate")
|
|
761
|
+
.cloned()
|
|
762
|
+
.unwrap_or(Json::Null);
|
|
763
|
+
let manifest = Json::object(vec![
|
|
764
|
+
("format", Json::String(DOSSIER_FORMAT.into())),
|
|
765
|
+
("image", session.image.clone()),
|
|
766
|
+
("karRoot", Json::String(evaluation.kar_root.clone())),
|
|
767
|
+
(
|
|
768
|
+
"planRoot",
|
|
769
|
+
certificate
|
|
770
|
+
.get("roots")
|
|
771
|
+
.and_then(|roots| roots.get("planRoot"))
|
|
772
|
+
.cloned()
|
|
773
|
+
.unwrap_or(Json::Null),
|
|
774
|
+
),
|
|
775
|
+
(
|
|
776
|
+
"queryDigest",
|
|
777
|
+
digest_json(&Json::String(proposition.to_string())).into_json_string(),
|
|
778
|
+
),
|
|
779
|
+
("resultDigest", digest_json(&certificate).into_json_string()),
|
|
780
|
+
(
|
|
781
|
+
"semanticRoot",
|
|
782
|
+
Json::String(evaluation.semantic_root.clone()),
|
|
783
|
+
),
|
|
784
|
+
]);
|
|
785
|
+
let markdown = render_markdown(session, proposition, &evaluation, &evaluation_value);
|
|
786
|
+
KarDossier {
|
|
787
|
+
status_statement: status_statement(&evaluation.status),
|
|
788
|
+
markdown,
|
|
789
|
+
manifest_json: stringify(&manifest),
|
|
790
|
+
certificate_json: stringify(&certificate),
|
|
791
|
+
evaluation_json: evaluation.json,
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
fn status_statement(status: &str) -> String {
|
|
796
|
+
if status == "SATISFIED" {
|
|
797
|
+
"Evidence requirements satisfied".into()
|
|
798
|
+
} else if status == "UNSATISFIED_EVIDENCE_REQUIREMENTS" {
|
|
799
|
+
"Evidence requirements not satisfied".into()
|
|
800
|
+
} else {
|
|
801
|
+
format!("Evaluation did not decide evidence requirements: {status}")
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
trait JsonString {
|
|
806
|
+
fn into_json_string(self) -> Json;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
impl JsonString for String {
|
|
810
|
+
fn into_json_string(self) -> Json {
|
|
811
|
+
Json::String(self)
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
fn render_markdown(
|
|
816
|
+
session: &Session,
|
|
817
|
+
proposition: &str,
|
|
818
|
+
evaluation: &KarEvaluation,
|
|
819
|
+
value: &Json,
|
|
820
|
+
) -> String {
|
|
821
|
+
let selected = value
|
|
822
|
+
.get("selectedEvidence")
|
|
823
|
+
.and_then(|item| match item {
|
|
824
|
+
Json::Array(rows) => Some(rows),
|
|
825
|
+
_ => None,
|
|
826
|
+
})
|
|
827
|
+
.cloned()
|
|
828
|
+
.unwrap_or_default();
|
|
829
|
+
let mut lines = Vec::new();
|
|
830
|
+
lines.push("PROPOSITION".into());
|
|
831
|
+
lines.push(proposition.to_string());
|
|
832
|
+
lines.push(String::new());
|
|
833
|
+
lines.push("STATUS".into());
|
|
834
|
+
lines.push(evaluation.status.clone());
|
|
835
|
+
lines.push(status_statement(&evaluation.status));
|
|
836
|
+
lines.push(String::new());
|
|
837
|
+
lines.push("SELECTED EVIDENCE".into());
|
|
838
|
+
push_evidence(&mut lines, &selected);
|
|
839
|
+
lines.push(String::new());
|
|
840
|
+
for (title, frontier) in [
|
|
841
|
+
("SUPPORT", "F_S"),
|
|
842
|
+
("OPPOSITION", "F_O"),
|
|
843
|
+
("QUALIFICATIONS", "F_Q"),
|
|
844
|
+
("TEMPORAL/APPLICABILITY", "F_T"),
|
|
845
|
+
("AUTHORITY", "F_A"),
|
|
846
|
+
] {
|
|
847
|
+
lines.push(title.into());
|
|
848
|
+
let ids = evaluation
|
|
849
|
+
.frontiers
|
|
850
|
+
.iter()
|
|
851
|
+
.find(|(name, _)| name == frontier)
|
|
852
|
+
.map(|(_, ids)| ids.clone())
|
|
853
|
+
.unwrap_or_default();
|
|
854
|
+
let rows = ids
|
|
855
|
+
.iter()
|
|
856
|
+
.map(|id| {
|
|
857
|
+
selected
|
|
858
|
+
.iter()
|
|
859
|
+
.find(|row| row.get("id").and_then(Json::as_str) == Some(id.as_str()))
|
|
860
|
+
.cloned()
|
|
861
|
+
.unwrap_or_else(|| evidence_row(session, id, frontier))
|
|
862
|
+
})
|
|
863
|
+
.collect::<Vec<_>>();
|
|
864
|
+
push_evidence(&mut lines, &rows);
|
|
865
|
+
lines.push(String::new());
|
|
866
|
+
}
|
|
867
|
+
lines.push("WHY THESE ITEMS WERE SELECTED".into());
|
|
868
|
+
let reasons = why_selected(session, proposition, value);
|
|
869
|
+
if reasons.is_empty() {
|
|
870
|
+
lines.push("(none)".into());
|
|
871
|
+
}
|
|
872
|
+
for reason in reasons {
|
|
873
|
+
lines.push(reason);
|
|
874
|
+
}
|
|
875
|
+
lines.push(String::new());
|
|
876
|
+
lines.push("CERTIFICATE ROOTS".into());
|
|
877
|
+
let roots = value
|
|
878
|
+
.get("certificate")
|
|
879
|
+
.and_then(|certificate| certificate.get("roots"));
|
|
880
|
+
for (label, field) in [
|
|
881
|
+
("KnowledgeRoot", "knowledgeRoot"),
|
|
882
|
+
("SemanticRoot", "semanticRoot"),
|
|
883
|
+
("PlanRoot", "planRoot"),
|
|
884
|
+
("AnchorRoot", "anchorRoot"),
|
|
885
|
+
("FrontierRoot", "frontierRoot"),
|
|
886
|
+
("EvidenceSetRoot", "evidenceSetRoot"),
|
|
887
|
+
("DecisionRoot", "decisionRoot"),
|
|
888
|
+
("KARRoot", "karRoot"),
|
|
889
|
+
("QueryRoot", "queryRoot"),
|
|
890
|
+
("FrontierWitnessRoot", "frontierWitnessRoot"),
|
|
891
|
+
] {
|
|
892
|
+
let root = roots
|
|
893
|
+
.and_then(|item| item.get(field))
|
|
894
|
+
.and_then(Json::as_str)
|
|
895
|
+
.unwrap_or("");
|
|
896
|
+
lines.push(format!("{label} {root}"));
|
|
897
|
+
}
|
|
898
|
+
if let Some(state_root) = session.image.get("stateRoot").and_then(Json::as_str) {
|
|
899
|
+
lines.push(format!("V5StateRoot {state_root}"));
|
|
900
|
+
}
|
|
901
|
+
lines.push(String::new());
|
|
902
|
+
lines.push("HOW TO VERIFY".into());
|
|
903
|
+
lines.push("Recompute the frozen KAR certificate from the Knowledge Image, the Committed Evidence Graph, the plan, and the proposition.".into());
|
|
904
|
+
lines.push("knolo kar dossier verify <dossier-directory>".into());
|
|
905
|
+
lines.push(
|
|
906
|
+
"A change to the certificate, manifest, selected evidence, or this rendering fails closed."
|
|
907
|
+
.into(),
|
|
908
|
+
);
|
|
909
|
+
lines.push(String::new());
|
|
910
|
+
lines.join("\n")
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
fn push_evidence(lines: &mut Vec<String>, rows: &[Json]) {
|
|
914
|
+
if rows.is_empty() {
|
|
915
|
+
lines.push("(none)".into());
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
for row in rows {
|
|
919
|
+
let id = row.get("id").and_then(Json::as_str).unwrap_or("");
|
|
920
|
+
let source = row
|
|
921
|
+
.get("source")
|
|
922
|
+
.and_then(Json::as_str)
|
|
923
|
+
.filter(|source| !source.is_empty())
|
|
924
|
+
.map(|source| format!(" source {source}"))
|
|
925
|
+
.unwrap_or_default();
|
|
926
|
+
let frontiers = match row.get("frontiers") {
|
|
927
|
+
Some(Json::Array(items)) => items
|
|
928
|
+
.iter()
|
|
929
|
+
.filter_map(Json::as_str)
|
|
930
|
+
.collect::<Vec<_>>()
|
|
931
|
+
.join(","),
|
|
932
|
+
_ => String::new(),
|
|
933
|
+
};
|
|
934
|
+
let frontier_text = if frontiers.is_empty() {
|
|
935
|
+
String::new()
|
|
936
|
+
} else {
|
|
937
|
+
format!(" frontiers {frontiers}")
|
|
938
|
+
};
|
|
939
|
+
lines.push(format!("{id}{source}{frontier_text}"));
|
|
940
|
+
if let Some(text) = row.get("text").and_then(Json::as_str) {
|
|
941
|
+
lines.push(text.to_string());
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
fn evidence_row(session: &Session, id: &str, frontier: &str) -> Json {
|
|
947
|
+
let object = session.objects.iter().find(|row| row.id == id);
|
|
948
|
+
let mut pairs = vec![
|
|
949
|
+
(
|
|
950
|
+
"frontiers".to_string(),
|
|
951
|
+
Json::Array(vec![Json::String(frontier.to_string())]),
|
|
952
|
+
),
|
|
953
|
+
("id".to_string(), Json::String(id.to_string())),
|
|
954
|
+
];
|
|
955
|
+
if let Some(object) = object {
|
|
956
|
+
pairs.push(("metadata".to_string(), object.metadata.clone()));
|
|
957
|
+
if let Some(source) = &object.source {
|
|
958
|
+
pairs.push(("source".to_string(), Json::String(source.clone())));
|
|
959
|
+
}
|
|
960
|
+
pairs.push(("text".to_string(), Json::String(object.text.clone())));
|
|
961
|
+
}
|
|
962
|
+
Json::Object(pairs)
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
fn why_selected(session: &Session, proposition: &str, evaluation: &Json) -> Vec<String> {
|
|
966
|
+
let Some(plan) = evaluation
|
|
967
|
+
.get("plan")
|
|
968
|
+
.and_then(|value| validate_plan(value))
|
|
969
|
+
else {
|
|
970
|
+
return Vec::new();
|
|
971
|
+
};
|
|
972
|
+
let Some(certificate) = evaluation.get("certificate") else {
|
|
973
|
+
return Vec::new();
|
|
974
|
+
};
|
|
975
|
+
let status = certificate
|
|
976
|
+
.get("status")
|
|
977
|
+
.and_then(Json::as_str)
|
|
978
|
+
.unwrap_or("");
|
|
979
|
+
let evidence_ids = match certificate.get("evidenceIds") {
|
|
980
|
+
Some(Json::Array(items)) => items
|
|
981
|
+
.iter()
|
|
982
|
+
.filter_map(Json::as_str)
|
|
983
|
+
.map(str::to_string)
|
|
984
|
+
.collect::<Vec<_>>(),
|
|
985
|
+
_ => Vec::new(),
|
|
986
|
+
};
|
|
987
|
+
let node_ids = session.indexes.node_ids.clone();
|
|
988
|
+
let (commitment, rejected) =
|
|
989
|
+
crate::kar::model::resolve_anchor(proposition, &node_ids, &plan.anchor);
|
|
990
|
+
if rejected {
|
|
991
|
+
return Vec::new();
|
|
992
|
+
}
|
|
993
|
+
let Ok(closed) = close_frontiers(
|
|
994
|
+
&session.graph,
|
|
995
|
+
&plan,
|
|
996
|
+
&commitment.nodes,
|
|
997
|
+
Some(&session.indexes),
|
|
998
|
+
) else {
|
|
999
|
+
return Vec::new();
|
|
1000
|
+
};
|
|
1001
|
+
let witnesses_match = canonicalize(&Json::Array(
|
|
1002
|
+
closed.witnesses.iter().map(witness_json).collect(),
|
|
1003
|
+
)) == canonicalize(certificate.get("witnesses").unwrap_or(&Json::Null));
|
|
1004
|
+
let mut lines = Vec::new();
|
|
1005
|
+
let mut seen = Vec::new();
|
|
1006
|
+
for witness in &closed.witnesses {
|
|
1007
|
+
let key = format!("{}\0{}", witness.frontier, witness.evidence_id);
|
|
1008
|
+
if seen.iter().any(|existing| existing == &key) {
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
seen.push(key);
|
|
1012
|
+
if !evidence_ids.iter().any(|id| id == &witness.evidence_id) {
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
let requirements = if witnesses_match {
|
|
1016
|
+
let mut found = Vec::new();
|
|
1017
|
+
for admission in &closed.admissions {
|
|
1018
|
+
if admission.evidence_id == witness.evidence_id
|
|
1019
|
+
&& admission.frontier == witness.frontier
|
|
1020
|
+
&& admission.applicable
|
|
1021
|
+
{
|
|
1022
|
+
for requirement in &admission.requirements {
|
|
1023
|
+
if !found.iter().any(|item| item == requirement) {
|
|
1024
|
+
found.push(requirement.clone());
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
found.sort_by(|left, right| crate::kar::json::utf16_cmp(left, right));
|
|
1030
|
+
found
|
|
1031
|
+
} else {
|
|
1032
|
+
Vec::new()
|
|
1033
|
+
};
|
|
1034
|
+
let necessary = if witnesses_match && status == "SATISFIED" {
|
|
1035
|
+
let remaining = evidence_ids
|
|
1036
|
+
.iter()
|
|
1037
|
+
.filter(|id| *id != &witness.evidence_id)
|
|
1038
|
+
.cloned()
|
|
1039
|
+
.collect::<Vec<_>>();
|
|
1040
|
+
Some(!covers_plan(
|
|
1041
|
+
&plan,
|
|
1042
|
+
&closed.admissions,
|
|
1043
|
+
&session.prepared.texts,
|
|
1044
|
+
&remaining,
|
|
1045
|
+
))
|
|
1046
|
+
} else {
|
|
1047
|
+
None
|
|
1048
|
+
};
|
|
1049
|
+
let reason = evidence_reason(status, necessary, &requirements);
|
|
1050
|
+
lines.push(format!(
|
|
1051
|
+
"{} {} {reason}",
|
|
1052
|
+
witness.evidence_id, witness.frontier
|
|
1053
|
+
));
|
|
1054
|
+
}
|
|
1055
|
+
lines
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
fn evidence_reason(status: &str, necessary: Option<bool>, requirements: &[String]) -> String {
|
|
1059
|
+
if status != "SATISFIED" {
|
|
1060
|
+
return "listed on a frontier while the decision did not select a set".into();
|
|
1061
|
+
}
|
|
1062
|
+
match necessary {
|
|
1063
|
+
None => "selected; necessity was not recomputed".into(),
|
|
1064
|
+
Some(true) if !requirements.is_empty() => format!(
|
|
1065
|
+
"required for this set; contributes {}",
|
|
1066
|
+
requirements.join(",")
|
|
1067
|
+
),
|
|
1068
|
+
Some(true) => "required for this set".into(),
|
|
1069
|
+
Some(false) => "kept by the profile order; the floors still pass without it".into(),
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
fn decode_object_text(bytes: &[u8]) -> String {
|
|
1074
|
+
// `TextDecoder` is non-fatal. Invalid UTF-8 becomes U+FFFD.
|
|
1075
|
+
String::from_utf8_lossy(bytes).into_owned()
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
fn object_source(kind: &str, meta: &Json) -> Option<String> {
|
|
1079
|
+
if let Some(source) = meta.get("source").and_then(Json::as_str) {
|
|
1080
|
+
if !source.is_empty() {
|
|
1081
|
+
return Some(source.to_string());
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
if kind.is_empty() {
|
|
1085
|
+
None
|
|
1086
|
+
} else {
|
|
1087
|
+
Some(kind.to_string())
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
fn cbor_to_json(value: &CborValue) -> Result<Json, String> {
|
|
1092
|
+
match value {
|
|
1093
|
+
CborValue::Null => Ok(Json::Null),
|
|
1094
|
+
CborValue::Bool(item) => Ok(Json::Bool(*item)),
|
|
1095
|
+
CborValue::UInt(item) => i64::try_from(*item)
|
|
1096
|
+
.map(Json::Int)
|
|
1097
|
+
.map_err(|_| "KAR metadata integer is out of range".to_string()),
|
|
1098
|
+
CborValue::NInt(item) => Ok(Json::Int(*item)),
|
|
1099
|
+
CborValue::Text(item) => Ok(Json::String(item.clone())),
|
|
1100
|
+
// A byte string is a Uint8Array in the TypeScript projection.
|
|
1101
|
+
// JSON.stringify and canonicalize both turn that into decimal index keys.
|
|
1102
|
+
CborValue::Bytes(bytes) => Ok(Json::Object(
|
|
1103
|
+
bytes
|
|
1104
|
+
.iter()
|
|
1105
|
+
.enumerate()
|
|
1106
|
+
.map(|(index, byte)| (index.to_string(), Json::Int(i64::from(*byte))))
|
|
1107
|
+
.collect(),
|
|
1108
|
+
)),
|
|
1109
|
+
CborValue::Array(items) => items
|
|
1110
|
+
.iter()
|
|
1111
|
+
.map(cbor_to_json)
|
|
1112
|
+
.collect::<Result<Vec<_>, _>>()
|
|
1113
|
+
.map(Json::Array),
|
|
1114
|
+
CborValue::Map(entries) => {
|
|
1115
|
+
let mut pairs = Vec::new();
|
|
1116
|
+
for (key, child) in entries {
|
|
1117
|
+
pairs.push((key.clone(), cbor_to_json(child)?));
|
|
1118
|
+
}
|
|
1119
|
+
Ok(Json::Object(pairs))
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
#[cfg(test)]
|
|
1125
|
+
mod tests {
|
|
1126
|
+
use super::*;
|
|
1127
|
+
|
|
1128
|
+
#[test]
|
|
1129
|
+
fn object_text_replaces_invalid_utf8() {
|
|
1130
|
+
assert_eq!(decode_object_text(&[0xe2, 0x82, 0xac]), "€");
|
|
1131
|
+
assert_eq!(decode_object_text(&[0xc2, 0x20]), "\u{FFFD} ");
|
|
1132
|
+
assert_eq!(
|
|
1133
|
+
decode_object_text(&[0xed, 0xa0, 0x80]),
|
|
1134
|
+
"\u{FFFD}\u{FFFD}\u{FFFD}"
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
#[test]
|
|
1139
|
+
fn metadata_bytes_project_like_uint8array() {
|
|
1140
|
+
let mut bytes = vec![0; 11];
|
|
1141
|
+
bytes[10] = 7;
|
|
1142
|
+
let value = cbor_to_json(&CborValue::Bytes(bytes)).expect("bytes");
|
|
1143
|
+
assert_eq!(
|
|
1144
|
+
canonicalize(&value),
|
|
1145
|
+
r#"{"0":0,"1":0,"10":7,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0}"#
|
|
1146
|
+
);
|
|
1147
|
+
assert_eq!(
|
|
1148
|
+
stringify(&value),
|
|
1149
|
+
r#"{"0":0,"1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":7}"#
|
|
1150
|
+
);
|
|
1151
|
+
assert_eq!(
|
|
1152
|
+
stringify(&cbor_to_json(&CborValue::Bytes(Vec::new())).expect("empty")),
|
|
1153
|
+
"{}"
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
}
|