1use std::collections::BTreeSet;
4
5use sim_kernel::{
6 Claim, ClaimKind, ClaimPattern, Cx, Datum, DatumStore, Error, Ref, Result, Symbol,
7 card::card_kind_predicate, standard::standard_evidence_predicate,
8};
9
10use crate::{
11 BoundedLane, CanonicalFailure, CanonicalObservation, CanonicalOutcome, FailureLocation,
12 ScenarioObservationLane, ScenarioSpec,
13};
14
15pub fn characterization_capture_kind() -> Symbol {
17 Symbol::qualified("standard", "characterization-capture/v1")
18}
19
20pub fn characterization_capture_predicate() -> Symbol {
22 Symbol::qualified("standard", "characterization-capture")
23}
24
25#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct CharacterizationCapture {
28 pub schema: Symbol,
30 pub projection: Symbol,
32 pub observation: CanonicalObservation,
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct CaptureComparisonProjection {
43 pub identity: Symbol,
45 pub unstable_fields: BTreeSet<String>,
47}
48
49impl CaptureComparisonProjection {
50 pub fn new(identity: Symbol) -> Self {
52 Self {
53 identity,
54 unstable_fields: BTreeSet::new(),
55 }
56 }
57
58 pub fn ignoring(mut self, path: impl Into<String>) -> Self {
60 self.unstable_fields.insert(path.into());
61 self
62 }
63}
64
65#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct CaptureDifference {
68 pub path: String,
70 pub left: Datum,
72 pub right: Datum,
74}
75
76#[derive(Clone, Debug, PartialEq, Eq)]
78pub struct CaptureComparison {
79 pub projection: Symbol,
81 pub differences: Vec<CaptureDifference>,
83}
84
85impl CaptureComparison {
86 pub fn is_same(&self) -> bool {
88 self.differences.is_empty()
89 }
90}
91
92impl CharacterizationCapture {
93 pub fn new(projection: Symbol, observation: CanonicalObservation) -> Self {
95 Self {
96 schema: characterization_capture_kind(),
97 projection,
98 observation,
99 }
100 }
101}
102
103pub fn compare_characterization_captures(
109 left_scenario: &ScenarioSpec,
110 left: &CharacterizationCapture,
111 right_scenario: &ScenarioSpec,
112 right: &CharacterizationCapture,
113 projection: &CaptureComparisonProjection,
114) -> Result<CaptureComparison> {
115 if left.projection != projection.identity || right.projection != projection.identity {
116 return Err(Error::Eval(format!(
117 "capture comparison projection {} is not recorded by both captures",
118 projection.identity
119 )));
120 }
121 let left = comparison_datum(left_scenario, left);
122 let right = comparison_datum(right_scenario, right);
123 for path in &projection.unstable_fields {
124 if path == "$@tag" || path == "$.projection" {
125 return Err(Error::Eval(format!(
126 "capture comparison projection cannot ignore protected field {path}"
127 )));
128 }
129 if !datum_path_exists(&left, "$", path) || !datum_path_exists(&right, "$", path) {
130 return Err(Error::Eval(format!(
131 "capture comparison projection {} declares non-two-sided field {path}",
132 projection.identity
133 )));
134 }
135 }
136 let mut differences = Vec::new();
137 compare_datum(
138 "$",
139 &left,
140 &right,
141 &projection.unstable_fields,
142 &mut differences,
143 );
144 Ok(CaptureComparison {
145 projection: projection.identity.clone(),
146 differences,
147 })
148}
149
150pub fn publish_characterization_capture(
155 cx: &mut Cx,
156 scenario: &ScenarioSpec,
157 capture: &CharacterizationCapture,
158) -> Result<Ref> {
159 validate_capture(scenario, capture)?;
160 let capture_ref = cx
161 .datum_store_mut()
162 .intern(capture_datum(scenario, capture))
163 .map(Ref::Content)?;
164
165 insert_observed_once(
166 cx,
167 capture_ref.clone(),
168 card_kind_predicate(),
169 Ref::Symbol(characterization_capture_kind()),
170 )?;
171 insert_observed_once(
172 cx,
173 Ref::Symbol(scenario.id.clone()),
174 characterization_capture_predicate(),
175 capture_ref.clone(),
176 )?;
177 insert_observed_once(
178 cx,
179 Ref::Symbol(scenario.id.clone()),
180 standard_evidence_predicate(),
181 capture_ref.clone(),
182 )?;
183 Ok(capture_ref)
184}
185
186fn validate_capture(scenario: &ScenarioSpec, capture: &CharacterizationCapture) -> Result<()> {
187 if capture.schema != characterization_capture_kind() {
188 return Err(Error::Eval(format!(
189 "unsupported characterization capture schema {}",
190 capture.schema
191 )));
192 }
193 let limits = scenario
194 .limits
195 .ok_or_else(|| Error::Eval(format!("scenario {} is missing limits", scenario.id)))?;
196
197 validate_outcome_lane(scenario, &capture.observation)?;
198 let mut observed = usize::from(capture.observation.outcome.is_some());
199 observed += validate_lane(
200 scenario,
201 ScenarioObservationLane::Events,
202 &capture.observation.events,
203 )?;
204 observed += validate_lane(
205 scenario,
206 ScenarioObservationLane::Receipts,
207 &capture.observation.receipts,
208 )?;
209 observed += validate_lane(
210 scenario,
211 ScenarioObservationLane::Browse,
212 &capture.observation.browse,
213 )?;
214 if observed > limits.max_observations {
215 return Err(Error::Eval(format!(
216 "scenario {} capture exceeds its observation bound",
217 scenario.id
218 )));
219 }
220 Ok(())
221}
222
223fn validate_outcome_lane(
224 scenario: &ScenarioSpec,
225 observation: &CanonicalObservation,
226) -> Result<()> {
227 let selected = scenario
228 .observation_lanes
229 .contains(&ScenarioObservationLane::ValueOrFailure);
230 if selected != observation.outcome.is_some() {
231 return Err(Error::Eval(format!(
232 "scenario {} capture has incomplete value-or-failure observations",
233 scenario.id
234 )));
235 }
236 Ok(())
237}
238
239fn validate_lane<T>(
240 scenario: &ScenarioSpec,
241 lane: ScenarioObservationLane,
242 observed: &BoundedLane<T>,
243) -> Result<usize> {
244 let selected = scenario.observation_lanes.contains(&lane);
245 match (selected, observed) {
246 (false, BoundedLane::Absent) => Ok(0),
247 (true, BoundedLane::Complete(items)) => Ok(items.len()),
248 (true, BoundedLane::Truncated { .. }) => Err(Error::Eval(format!(
249 "scenario {} capture contains truncated {lane:?} observations",
250 scenario.id
251 ))),
252 _ => Err(Error::Eval(format!(
253 "scenario {} capture has incomplete {lane:?} observations",
254 scenario.id
255 ))),
256 }
257}
258
259fn capture_datum(scenario: &ScenarioSpec, capture: &CharacterizationCapture) -> Datum {
260 Datum::Node {
261 tag: capture.schema.clone(),
262 fields: vec![
263 ("scenario", Datum::Symbol(scenario.id.clone())),
264 ("setup", Datum::Symbol(scenario.setup.clone())),
265 (
266 "inputs",
267 Datum::List(
268 scenario
269 .inputs
270 .iter()
271 .map(|input| Datum::Node {
272 tag: Symbol::qualified("standard", "characterization-input/v1"),
273 fields: vec![
274 ("id", Datum::Symbol(input.id.clone())),
275 ("authority", Datum::Symbol(input.authority.clone())),
276 ("datum", input.datum.clone()),
277 ]
278 .into_iter()
279 .map(symbol_field)
280 .collect(),
281 })
282 .collect(),
283 ),
284 ),
285 ("projection", Datum::Symbol(capture.projection.clone())),
286 ("observation", observation_datum(&capture.observation)),
287 ]
288 .into_iter()
289 .map(symbol_field)
290 .collect(),
291 }
292}
293
294fn comparison_datum(scenario: &ScenarioSpec, capture: &CharacterizationCapture) -> Datum {
295 let mut datum = capture_datum(scenario, capture);
296 let Datum::Node { fields, .. } = &mut datum else {
297 unreachable!("capture datum is always a node")
298 };
299 fields.insert(
300 3,
301 (
302 Symbol::new("selected-lanes"),
303 Datum::List(
304 scenario
305 .observation_lanes
306 .iter()
307 .map(|lane| Datum::Symbol(observation_lane_symbol(*lane)))
308 .collect(),
309 ),
310 ),
311 );
312 datum
313}
314
315fn observation_lane_symbol(lane: ScenarioObservationLane) -> Symbol {
316 let name = match lane {
317 ScenarioObservationLane::ValueOrFailure => "value-or-failure",
318 ScenarioObservationLane::Events => "events",
319 ScenarioObservationLane::Receipts => "receipts",
320 ScenarioObservationLane::Browse => "browse",
321 };
322 Symbol::qualified("standard/characterization-lane", name)
323}
324
325fn datum_path_exists(datum: &Datum, path: &str, wanted: &str) -> bool {
326 if path == wanted {
327 return true;
328 }
329 match datum {
330 Datum::Node { fields, .. } => {
331 wanted == format!("{path}@tag")
332 || fields.iter().any(|(name, value)| {
333 datum_path_exists(value, &format!("{path}.{name}"), wanted)
334 })
335 }
336 Datum::List(items) | Datum::Vector(items) | Datum::Set(items) => items
337 .iter()
338 .enumerate()
339 .any(|(index, value)| datum_path_exists(value, &format!("{path}[{index}]"), wanted)),
340 Datum::Map(entries) => entries.iter().enumerate().any(|(index, (key, value))| {
341 datum_path_exists(key, &format!("{path}.keys[{index}]"), wanted)
342 || datum_path_exists(value, &format!("{path}.values[{index}]"), wanted)
343 }),
344 _ => false,
345 }
346}
347
348fn compare_datum(
349 path: &str,
350 left: &Datum,
351 right: &Datum,
352 ignored: &BTreeSet<String>,
353 differences: &mut Vec<CaptureDifference>,
354) {
355 if ignored.contains(path) || left == right {
356 return;
357 }
358 match (left, right) {
359 (
360 Datum::Node {
361 tag: left_tag,
362 fields: left_fields,
363 },
364 Datum::Node {
365 tag: right_tag,
366 fields: right_fields,
367 },
368 ) if left_fields
369 .iter()
370 .map(|(name, _)| name)
371 .eq(right_fields.iter().map(|(name, _)| name)) =>
372 {
373 if left_tag != right_tag {
374 push_capture_difference(
375 format!("{path}@tag"),
376 Datum::Symbol(left_tag.clone()),
377 Datum::Symbol(right_tag.clone()),
378 ignored,
379 differences,
380 );
381 }
382 for ((name, left), (_, right)) in left_fields.iter().zip(right_fields) {
383 compare_datum(&format!("{path}.{name}"), left, right, ignored, differences);
384 }
385 }
386 (Datum::List(left), Datum::List(right)) | (Datum::Vector(left), Datum::Vector(right)) => {
387 let absent = absent_datum();
388 for index in 0..left.len().max(right.len()) {
389 compare_datum(
390 &format!("{path}[{index}]"),
391 left.get(index).unwrap_or(&absent),
392 right.get(index).unwrap_or(&absent),
393 ignored,
394 differences,
395 );
396 }
397 }
398 (Datum::Set(left), Datum::Set(right)) => {
399 let left = canonical_items(left);
400 let right = canonical_items(right);
401 let absent = absent_datum();
402 for index in 0..left.len().max(right.len()) {
403 compare_datum(
404 &format!("{path}[{index}]"),
405 left.get(index).copied().unwrap_or(&absent),
406 right.get(index).copied().unwrap_or(&absent),
407 ignored,
408 differences,
409 );
410 }
411 }
412 (Datum::Map(left), Datum::Map(right)) => {
413 let left = canonical_entries(left);
414 let right = canonical_entries(right);
415 let absent = absent_datum();
416 for index in 0..left.len().max(right.len()) {
417 let left = left.get(index).copied();
418 let right = right.get(index).copied();
419 compare_datum(
420 &format!("{path}.keys[{index}]"),
421 left.map_or(&absent, |entry| &entry.0),
422 right.map_or(&absent, |entry| &entry.0),
423 ignored,
424 differences,
425 );
426 compare_datum(
427 &format!("{path}.values[{index}]"),
428 left.map_or(&absent, |entry| &entry.1),
429 right.map_or(&absent, |entry| &entry.1),
430 ignored,
431 differences,
432 );
433 }
434 }
435 _ => push_capture_difference(
436 path.to_owned(),
437 left.clone(),
438 right.clone(),
439 ignored,
440 differences,
441 ),
442 }
443}
444
445fn push_capture_difference(
446 path: String,
447 left: Datum,
448 right: Datum,
449 ignored: &BTreeSet<String>,
450 differences: &mut Vec<CaptureDifference>,
451) {
452 if !ignored.contains(&path) {
453 differences.push(CaptureDifference { path, left, right });
454 }
455}
456
457fn absent_datum() -> Datum {
458 Datum::Node {
459 tag: Symbol::qualified("standard/capture-diff", "absent"),
460 fields: Vec::new(),
461 }
462}
463
464fn canonical_items(items: &[Datum]) -> Vec<&Datum> {
465 let mut items = items.iter().collect::<Vec<_>>();
466 items.sort_by_cached_key(|item| item.canonical_bytes().unwrap_or_default());
467 items
468}
469
470fn canonical_entries(entries: &[(Datum, Datum)]) -> Vec<&(Datum, Datum)> {
471 let mut entries = entries.iter().collect::<Vec<_>>();
472 entries.sort_by_cached_key(|(key, _)| key.canonical_bytes().unwrap_or_default());
473 entries
474}
475
476fn observation_datum(observation: &CanonicalObservation) -> Datum {
477 Datum::Node {
478 tag: Symbol::qualified("standard", "characterization-observation/v1"),
479 fields: vec![
480 (
481 "outcome",
482 optional_outcome_datum(observation.outcome.as_ref()),
483 ),
484 ("events", lane_datum(&observation.events)),
485 ("receipts", lane_datum(&observation.receipts)),
486 ("browse", lane_datum(&observation.browse)),
487 ]
488 .into_iter()
489 .map(symbol_field)
490 .collect(),
491 }
492}
493
494fn optional_outcome_datum(outcome: Option<&CanonicalOutcome>) -> Datum {
495 match outcome {
496 None => Datum::Node {
497 tag: Symbol::qualified("standard/capture", "absent"),
498 fields: Vec::new(),
499 },
500 Some(CanonicalOutcome::Success(value)) => Datum::Node {
501 tag: Symbol::qualified("standard/capture", "success"),
502 fields: vec![(Symbol::new("value"), value.clone())],
503 },
504 Some(CanonicalOutcome::Failure(failure)) => failure_datum(failure),
505 }
506}
507
508fn failure_datum(failure: &CanonicalFailure) -> Datum {
509 Datum::Node {
510 tag: Symbol::qualified("standard/capture", "failure"),
511 fields: vec![
512 ("class", Datum::Symbol(failure.class.clone())),
513 ("detail", failure.detail.clone()),
514 ("location", location_datum(failure.location.as_ref())),
515 ]
516 .into_iter()
517 .map(symbol_field)
518 .collect(),
519 }
520}
521
522fn location_datum(location: Option<&FailureLocation>) -> Datum {
523 match location {
524 None => Datum::Nil,
525 Some(location) => Datum::Node {
526 tag: Symbol::qualified("standard/capture", "location"),
527 fields: vec![
528 (
529 Symbol::new("source"),
530 Datum::Symbol(location.source.clone()),
531 ),
532 (
533 Symbol::new("start"),
534 Datum::String(location.start.to_string()),
535 ),
536 (Symbol::new("end"), Datum::String(location.end.to_string())),
537 ],
538 },
539 }
540}
541
542fn lane_datum(lane: &BoundedLane<Datum>) -> Datum {
543 match lane {
544 BoundedLane::Absent => Datum::Node {
545 tag: Symbol::qualified("standard/capture", "absent"),
546 fields: Vec::new(),
547 },
548 BoundedLane::Complete(items) => Datum::Node {
549 tag: Symbol::qualified("standard/capture", "complete"),
550 fields: vec![(Symbol::new("items"), Datum::List(items.clone()))],
551 },
552 BoundedLane::Truncated { items, omitted } => Datum::Node {
553 tag: Symbol::qualified("standard/capture", "truncated"),
554 fields: vec![
555 (Symbol::new("items"), Datum::List(items.clone())),
556 (Symbol::new("omitted"), Datum::String(omitted.to_string())),
557 ],
558 },
559 }
560}
561
562fn symbol_field((name, datum): (&str, Datum)) -> (Symbol, Datum) {
563 (Symbol::new(name), datum)
564}
565
566fn insert_observed_once(cx: &mut Cx, subject: Ref, predicate: Symbol, object: Ref) -> Result<()> {
567 if cx
568 .query_facts(ClaimPattern::exact(
569 subject.clone(),
570 predicate.clone(),
571 object.clone(),
572 ))?
573 .is_empty()
574 {
575 cx.insert_fact(Claim::public(subject, predicate, object).with_kind(ClaimKind::Observed))?;
576 }
577 Ok(())
578}