Skip to main content

sim_lib_standard_core/
capture.rs

1//! Immutable, content-addressed characterization captures.
2
3use 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
15/// Schema tag for the first characterization capture datum.
16pub fn characterization_capture_kind() -> Symbol {
17    Symbol::qualified("standard", "characterization-capture/v1")
18}
19
20/// Claim predicate relating a scenario to one immutable capture.
21pub fn characterization_capture_predicate() -> Symbol {
22    Symbol::qualified("standard", "characterization-capture")
23}
24
25/// A capture ready for validation and content-addressed publication.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct CharacterizationCapture {
28    /// Versioned schema tag. Unknown versions fail closed.
29    pub schema: Symbol,
30    /// Stable identity of the projection applied before capture.
31    pub projection: Symbol,
32    /// Complete canonical observations.
33    pub observation: CanonicalObservation,
34}
35
36/// A named, two-sided declaration of capture fields that are intentionally unstable.
37///
38/// Every ignored path must exist in both captures. The projection identity is
39/// itself part of each capture and must match this declaration, so changing a
40/// projection never silently preserves comparison equality.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct CaptureComparisonProjection {
43    /// Stable identity recorded in both captures.
44    pub identity: Symbol,
45    /// Exact field paths omitted from both sides before comparison.
46    pub unstable_fields: BTreeSet<String>,
47}
48
49impl CaptureComparisonProjection {
50    /// Construct a named projection with no unstable fields.
51    pub fn new(identity: Symbol) -> Self {
52        Self {
53            identity,
54            unstable_fields: BTreeSet::new(),
55        }
56    }
57
58    /// Declare one exact, two-sided unstable field path.
59    pub fn ignoring(mut self, path: impl Into<String>) -> Self {
60        self.unstable_fields.insert(path.into());
61        self
62    }
63}
64
65/// One exact behavioral difference between two characterization captures.
66#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct CaptureDifference {
68    /// Stable path within the canonical comparison datum.
69    pub path: String,
70    /// Canonical value from the left capture.
71    pub left: Datum,
72    /// Canonical value from the right capture.
73    pub right: Datum,
74}
75
76/// Strict, located result of comparing two characterization captures.
77#[derive(Clone, Debug, PartialEq, Eq)]
78pub struct CaptureComparison {
79    /// Projection applied symmetrically to both captures.
80    pub projection: Symbol,
81    /// All differences in stable path order.
82    pub differences: Vec<CaptureDifference>,
83}
84
85impl CaptureComparison {
86    /// Whether the projected captures are identical.
87    pub fn is_same(&self) -> bool {
88        self.differences.is_empty()
89    }
90}
91
92impl CharacterizationCapture {
93    /// Construct a capture using the current schema.
94    pub fn new(projection: Symbol, observation: CanonicalObservation) -> Self {
95        Self {
96            schema: characterization_capture_kind(),
97            projection,
98            observation,
99        }
100    }
101}
102
103/// Compare two captures recursively after applying one declared two-sided projection.
104///
105/// Schema, setup, ordered inputs, selected observation lanes, projection
106/// identity, and observations are all compared. A projected path must exist on
107/// both sides and cannot name the schema or projection identity.
108pub 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
150/// Validate, intern, and publish an immutable capture for `scenario`.
151///
152/// Identity is derived solely from the versioned semantic datum. Rendered
153/// cards, debug output, and host formatting never enter the datum.
154pub 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}