Skip to main content

rustev_eval/explain/
diff.rs

1//! Ordered comparisons of evidence, before policy redaction.
2use super::*;
3use facts::{CORE, J};
4use rustev_contract::definition::{Cond, OutcomeDecl, ParamSource};
5use rustev_contract::judgment::Derivation;
6use rustev_contract::redaction::FieldClass;
7use rustev_contract::{Document, limits::REPLAY_V1};
8use serde_json::{Value, json};
9use std::collections::{BTreeMap, BTreeSet};
10
11#[derive(Clone)]
12struct Operand {
13    value: Value,
14    compared: Value,
15    digest: bool,
16    state: Value,
17    cites: Vec<EvidencePath>,
18    derivation: Option<FactDerivation>,
19    class: FieldClass,
20}
21fn prefix(u: &mut Unavailable, side: &str) {
22    if let Unavailable::Retention { item, .. } = u {
23        *item =
24            EvidencePath::parse(&format!("{side}.{}", item.as_str())).expect("valid prefixed path");
25    }
26}
27fn read(set: &evidence::Set, side: &str, role: &str, path: &str, class: FieldClass) -> Operand {
28    let value = set.get(role, path);
29    let mut cites = vec![];
30    let state = if value.is_some() {
31        cites.push(EvidencePath::parse(&format!("{side}.{role}:{path}")).expect("evidence path"));
32        json!("present")
33    } else if let Some(mut u) = set.absent.get(role).cloned().or_else(|| {
34        (role == "record" && path.starts_with(CORE))
35            .then(|| set.judgment_absent())
36            .flatten()
37    }) {
38        prefix(&mut u, side);
39        if let Unavailable::Retention { item, .. } = &u {
40            cites.push(item.clone());
41        }
42        json!({"not_available": u})
43    } else {
44        json!("absent")
45    };
46    Operand {
47        digest: false,
48        compared: value.cloned().unwrap_or(Value::Null),
49        value: value.cloned().unwrap_or(Value::Null),
50        state,
51        cites,
52        derivation: None,
53        class,
54    }
55}
56fn array<'a>(set: &'a evidence::Set, role: &str, path: &str) -> &'a [Value] {
57    set.get(role, path)
58        .and_then(Value::as_array)
59        .map(Vec::as_slice)
60        .unwrap_or(&[])
61}
62fn key(v: &Value) -> (String, Vec<String>) {
63    (
64        v["step"].as_str().unwrap_or_default().into(),
65        facts::strings(&v["instance"]),
66    )
67}
68fn subject(k: &(String, Vec<String>)) -> String {
69    format!("step:{}#{}", k.0, k.1.join(","))
70}
71fn builder(set: &evidence::Set) -> facts::Builder<'_> {
72    facts::Builder {
73        set,
74        policy: None,
75        facts: vec![],
76        total: 0,
77        limit: usize::MAX,
78        bytes: 0,
79        bounds: bounds::Bounds::default(),
80    }
81}
82fn annotate(op: &mut Operand, set: &evidence::Set, input: Option<&Value>, step: Option<&Value>) {
83    if op.state != "present" {
84        return;
85    }
86    op.derivation = if let Some(input) = input {
87        Some(FactDerivation {
88            class: if input
89                .pointer("/status/resolved/provenance")
90                .and_then(Value::as_str)
91                == Some("model-derived")
92            {
93                Derivation::ModelDerived
94            } else {
95                Derivation::ExactDerived
96            },
97            bound: DerivationBound::Exact,
98        })
99    } else if set.plan.is_some() && step.is_some() {
100        step.and_then(|s| s.pointer("/status/resolved/derivation"))
101            .and_then(|d| serde_json::from_value(d.clone()).ok())
102            .map(|class| FactDerivation {
103                class,
104                bound: DerivationBound::Exact,
105            })
106    } else {
107        set.get("record", &format!("{J}/derivation"))
108            .and_then(|d| serde_json::from_value(d.clone()).ok())
109            .map(|class| FactDerivation {
110                class,
111                bound: DerivationBound::Judgment,
112            })
113    };
114}
115struct Walk<'a> {
116    sets: [&'a evidence::Set; 2],
117    options: &'a ExplainOptions<'a>,
118    facts: Vec<Fact>,
119    differences: Vec<FactId>,
120    observations: Vec<FactId>,
121    not_compared: Vec<FactId>,
122    earlier: Vec<(String, FactId)>,
123    total: u64,
124    why: Option<Divergence>,
125}
126impl Walk<'_> {
127    fn emit(
128        &mut self,
129        name: &str,
130        mut ops: [Operand; 2],
131        claim: Claim,
132        why: Divergence,
133        refs: &BTreeSet<String>,
134    ) {
135        if claim != Claim::OutputNotCompared
136            && ops[0].compared == ops[1].compared
137            && ((ops[0].digest && ops[1].digest)
138                || ops[0].state == ops[1].state
139                || (ops[0].state.get("not_available").is_some()
140                    && ops[1].state.get("not_available").is_some()
141                    && ops[0].state["not_available"]["reason"]
142                        == ops[1].state["not_available"]["reason"]
143                    && ops[0].state["not_available"]["availability"]
144                        == ops[1].state["not_available"]["availability"]))
145        {
146            return;
147        }
148        if claim == Claim::Difference {
149            self.total += 1;
150            if self.total > 10_000 {
151                return;
152            }
153            self.why.get_or_insert(why);
154        }
155        let derivation = if claim == Claim::Difference
156            && (name.starts_with("input:") || name.starts_with("step:") || name == "judgment")
157        {
158            let ds: Vec<_> = ops.iter().filter_map(|o| o.derivation.as_ref()).collect();
159            (!ds.is_empty()).then(|| FactDerivation {
160                class: if ds.iter().all(|d| d.class == Derivation::ExactDerived) {
161                    Derivation::ExactDerived
162                } else {
163                    Derivation::MixedDerived
164                },
165                bound: if ds.iter().all(|d| d.bound == DerivationBound::Exact) {
166                    DerivationBound::Exact
167                } else {
168                    DerivationBound::Judgment
169                },
170            })
171        } else {
172            None
173        };
174        let mut truncated = None;
175        for op in &mut ops {
176            if op.state == "present" {
177                match redact::apply(&mut op.value, op.class, self.options.redaction, true) {
178                    Ok(t) => truncated = t.or(truncated),
179                    Err(u) => {
180                        op.value = Value::Null;
181                        op.state = json!({"not_available": u});
182                    }
183                }
184            }
185        }
186        let mut name_value = json!(name);
187        if name.starts_with("input:") || name.starts_with("step:") {
188            // Subjects include authored names and instance identifiers.
189            let class = if name.starts_with("step:")
190                && name.split_once('#').is_some_and(|(_, i)| !i.is_empty())
191            {
192                FieldClass::Id
193            } else {
194                FieldClass::PlanMetadata
195            };
196            if let Err(u) = redact::apply(&mut name_value, class, self.options.redaction, false) {
197                name_value = json!({"not_available":u});
198            }
199            if let Some(u) = redact::withheld(self.options.redaction, FieldClass::PlanMetadata) {
200                name_value = json!({"not_available":u});
201            }
202        }
203        let mut value = json!({"subject":name_value, "a":ops[0].value, "b":ops[1].value, "a_state":ops[0].state, "b_state":ops[1].state});
204        if claim == Claim::OutputNotCompared {
205            value.as_object_mut().unwrap().remove("a");
206            value.as_object_mut().unwrap().remove("b");
207        }
208        if claim == Claim::Difference {
209            let (follows, unexplained) = if self.sets.iter().any(|s| s.plan.is_none()) {
210                (
211                    json!({"not_available":Unavailable::PlanNotSupplied {}}),
212                    Value::Null,
213                )
214            } else if self.sets[0].side.plan_id != self.sets[1].side.plan_id {
215                (
216                    json!(
217                        self.earlier
218                            .first()
219                            .map(|(_, id)| vec![id])
220                            .unwrap_or_default()
221                    ),
222                    Value::Null,
223                )
224            } else {
225                let ids: Vec<_> = self
226                    .earlier
227                    .iter()
228                    .filter(|(s, _)| refs.contains(s))
229                    .map(|(_, id)| id)
230                    .collect();
231                let unexplained =
232                    ids.is_empty() && (name.starts_with("step:") || name == "judgment");
233                (json!(ids), json!(unexplained))
234            };
235            value["follows_from"] = follows;
236            value["unexplained"] = unexplained;
237        }
238        let id = FactId(format!("f{}", self.facts.len() + 1));
239        let mut cites: Vec<_> = ops.into_iter().flat_map(|o| o.cites).collect();
240        cites.sort();
241        cites.dedup();
242        self.facts.push(Fact {
243            id: id.0.clone(),
244            claim,
245            value,
246            availability: Availability::Available,
247            derivation,
248            lineage: None,
249            truncated,
250            cites,
251        });
252        match claim {
253            Claim::Difference => {
254                self.earlier.push((name.into(), id.clone()));
255                self.differences.push(id);
256            }
257            Claim::ObservationDifference => self.observations.push(id),
258            _ => self.not_compared.push(id),
259        }
260    }
261    fn pair(&self, role: &str, paths: [&str; 2], class: FieldClass) -> [Operand; 2] {
262        [
263            read(self.sets[0], "a", role, paths[0], class),
264            read(self.sets[1], "b", role, paths[1], class),
265        ]
266    }
267    fn compare(&mut self) -> Result<(), ExplainError> {
268        for (member, class, why) in [
269            ("plan_id", FieldClass::PlanMetadata, Divergence::PlanChanged),
270            (
271                "snapshot_id",
272                FieldClass::ContentDigest,
273                Divergence::SnapshotChanged,
274            ),
275            (
276                "evaluation_time_ms",
277                FieldClass::Number,
278                Divergence::EvaluationTimeChanged,
279            ),
280        ] {
281            let path = if member == "plan_id" {
282                format!("/{member}")
283            } else {
284                format!("{CORE}/{member}")
285            };
286            self.emit(
287                member,
288                self.pair("record", [&path, &path], class),
289                Claim::Difference,
290                why,
291                &BTreeSet::new(),
292            );
293        }
294        let inputs = self
295            .sets
296            .map(|s| array(s, "record", &format!("{CORE}/inputs")));
297        let input_indices = inputs.map(|a| {
298            let mut indices = BTreeMap::new();
299            for (i, v) in a.iter().enumerate() {
300                if let Some(field) = v["field"].as_str() {
301                    indices.entry(field).or_insert(i);
302                }
303            }
304            indices
305        });
306        let fields: BTreeSet<_> = input_indices
307            .iter()
308            .flat_map(|a| a.keys().copied())
309            .collect();
310        for field in fields {
311            let indices = input_indices.each_ref().map(|a| a.get(field).copied());
312            for member in ["presence", "status"] {
313                let paths = indices.map(|i| {
314                    format!(
315                        "{CORE}/inputs/{}/{member}",
316                        i.map(|i| i.to_string()).unwrap_or_else(|| "missing".into())
317                    )
318                });
319                let mut ops = self.pair("record", [&paths[0], &paths[1]], FieldClass::Enum);
320                for i in 0..2 {
321                    annotate(
322                        &mut ops[i],
323                        self.sets[i],
324                        indices[i].map(|n| &inputs[i][n]),
325                        None,
326                    );
327                }
328                self.emit(
329                    &format!("input:{field}"),
330                    ops,
331                    Claim::Difference,
332                    Divergence::InputChanged,
333                    &["snapshot_id".into(), "evaluation_time_ms".into()].into(),
334                );
335            }
336        }
337        let steps = self
338            .sets
339            .map(|s| array(s, "record", &format!("{CORE}/steps")));
340        let requests = self.sets.map(|s| array(s, "record", "/requests"));
341        let mut keys = vec![];
342        let mut seen = BTreeSet::new();
343        for side in 0..2 {
344            for v in steps[side].iter().chain(requests[side]) {
345                let k = key(v);
346                if seen.insert(k.clone()) {
347                    keys.push(k);
348                }
349            }
350        }
351        for k in &keys {
352            let name = subject(k);
353            let refs = self.references(Some(&k.0), &keys)?;
354            let indices = steps.map(|a| a.iter().position(|v| key(v) == *k));
355            let request_indices = requests.map(|a| a.iter().position(|v| key(v) == *k));
356            if request_indices.iter().any(Option::is_some) {
357                self.supplied(
358                    k,
359                    request_indices,
360                    [0, 1].map(|i| indices[i].map(|n| &steps[i][n])),
361                    &refs,
362                )?;
363            }
364            let paths = indices.map(|i| {
365                format!(
366                    "{CORE}/steps/{}/status",
367                    i.map(|i| i.to_string()).unwrap_or_else(|| "missing".into())
368                )
369            });
370            let mut ops = self.pair("record", [&paths[0], &paths[1]], FieldClass::Enum);
371            for i in 0..2 {
372                annotate(
373                    &mut ops[i],
374                    self.sets[i],
375                    None,
376                    indices[i].map(|n| &steps[i][n]),
377                );
378            }
379            let mut refs = refs;
380            refs.insert(name.clone());
381            self.emit(
382                &name,
383                ops,
384                Claim::Difference,
385                Divergence::StepChanged,
386                &refs,
387            );
388        }
389        let refs = self.references(None, &keys)?;
390        for member in ["outcome", "derivation", "lineage", "notices", "trace"] {
391            let path = format!("{J}/{member}");
392            let mut ops = self.pair(
393                "record",
394                [&path, &path],
395                if member == "lineage" || member == "trace" {
396                    FieldClass::PlanMetadata
397                } else {
398                    FieldClass::Enum
399                },
400            );
401            for (i, op) in ops.iter_mut().enumerate() {
402                if member == "outcome" {
403                    op.class = builder(self.sets[i])
404                        .value_class(builder(self.sets[i]).lineage().as_ref(), false);
405                }
406                annotate(op, self.sets[i], None, None);
407            }
408            self.emit(
409                "judgment",
410                ops,
411                Claim::Difference,
412                Divergence::OutcomeChanged,
413                &refs,
414            );
415        }
416        for (member, class) in [
417            ("decision_id", FieldClass::Id),
418            ("timing", FieldClass::Number),
419            ("cost", FieldClass::Number),
420        ] {
421            let path = format!("/{member}");
422            self.emit(
423                member,
424                self.pair("record", [&path, &path], class),
425                Claim::ObservationDifference,
426                Divergence::StepChanged,
427                &BTreeSet::new(),
428            );
429        }
430        for k in &keys {
431            let indices = requests.map(|a| a.iter().position(|v| key(v) == *k));
432            let counts = [0, 1].map(|i| {
433                indices[i]
434                    .and_then(|n| requests[i][n]["attempts"].as_array())
435                    .map_or(0, Vec::len)
436            });
437            for n in 0..counts[0].max(counts[1]) {
438                for (member, class) in [
439                    ("attempt_id", FieldClass::Id),
440                    ("dispatched_ms", FieldClass::Number),
441                    ("ended_ms", FieldClass::Number),
442                    ("cost", FieldClass::Number),
443                ] {
444                    let paths = indices.map(|i| {
445                        format!(
446                            "/requests/{}/attempts/{n}/{member}",
447                            i.map(|i| i.to_string()).unwrap_or_else(|| "missing".into())
448                        )
449                    });
450                    self.emit(
451                        &format!("{}/{n}/{member}", subject(k)),
452                        self.pair("record", [&paths[0], &paths[1]], class),
453                        Claim::ObservationDifference,
454                        Divergence::StepChanged,
455                        &BTreeSet::new(),
456                    );
457                }
458            }
459        }
460        Ok(())
461    }
462    fn references(
463        &self,
464        root: Option<&str>,
465        keys: &[(String, Vec<String>)],
466    ) -> Result<BTreeSet<String>, ExplainError> {
467        let Some(plan) = &self.sets[0].plan else {
468            return Ok(BTreeSet::new());
469        };
470        let mut refs = vec![];
471        if let Some(root) = root {
472            refs.push(format!("step:{root}"));
473        } else {
474            let p = &plan.definition.policy;
475            for r in &p.rules {
476                cond_refs(&r.when, &mut refs);
477                match &r.then {
478                    OutcomeDecl::Propose { params, .. } => {
479                        for p in params {
480                            if let ParamSource::Ref(r) = &p.value {
481                                refs.push(r.clone());
482                            }
483                        }
484                    }
485                    OutcomeDecl::MissingEvidenceFrom { step } => refs.push(format!("step:{step}")),
486                    _ => {}
487                }
488            }
489            for a in &p.adjustments {
490                cond_refs(&a.when, &mut refs);
491            }
492            for h in &p.on_unresolved {
493                refs.push(h.target.clone());
494            }
495        }
496        let mut result = BTreeSet::new();
497        for r in refs {
498            let r = rustev_core::expr::base_ref_v2(&r);
499            if let Some(s) = r.strip_prefix("step:") {
500                let Some(l) = facts::closure(plan, s).or_else(|| builder(self.sets[0]).lineage())
501                else {
502                    continue;
503                };
504                for i in l.inputs {
505                    result.insert(format!("input:{i}"));
506                }
507                let mut steps: BTreeSet<_> = l.steps.into_iter().collect();
508                if root.is_none() {
509                    steps.insert(s.into());
510                }
511                for k in keys {
512                    if steps.contains(&k.0) {
513                        result.insert(subject(k));
514                    }
515                }
516            } else {
517                result.insert(r.to_string());
518            }
519        }
520        Ok(result)
521    }
522    fn supplied(
523        &mut self,
524        k: &(String, Vec<String>),
525        indices: [Option<usize>; 2],
526        steps: [Option<&Value>; 2],
527        refs: &BTreeSet<String>,
528    ) -> Result<(), ExplainError> {
529        let name = subject(k);
530        let requests = self.sets.map(|s| array(s, "record", "/requests"));
531        let supplies = self.sets.map(|s| array(s, "bundle", "/supplies"));
532        let supply_indices = supplies.map(|s| s.iter().position(|v| key(v) == *k));
533        let bundles = self.sets.map(|s| s.side.form == EvidenceForm::Bundle);
534        let paths = indices.map(|i| {
535            format!(
536                "/requests/{}/result",
537                i.map(|i| i.to_string()).unwrap_or_else(|| "missing".into())
538            )
539        });
540        let mut ops = self.pair("record", [&paths[0], &paths[1]], FieldClass::Enum);
541        let mut identities = [BTreeMap::new(), BTreeMap::new()];
542        for i in 0..2 {
543            if let Some(n) = indices[i] {
544                let attempts = requests[i][n]["attempts"].as_array();
545                if let Some(last) = attempts.and_then(|a| a.len().checked_sub(1)) {
546                    for m in ["backend_id", "artifact"] {
547                        let path = format!("/requests/{n}/attempts/{last}/{m}");
548                        let o = read(
549                            self.sets[i],
550                            ["a", "b"][i],
551                            "record",
552                            &path,
553                            FieldClass::PlanMetadata,
554                        );
555                        identities[i].insert(m, o);
556                    }
557                }
558            }
559        }
560        let backend_changed = ["backend_id", "artifact"].iter().any(|m| {
561            identities[0].get(m).map(|o| &o.value) != identities[1].get(m).map(|o| &o.value)
562        });
563        let same_backend = !backend_changed && identities.iter().all(|m| m.len() == 2);
564        let outputs = [0, 1].map(|i| ops[i].value.get("output").is_some());
565        let mixed = bundles[0] != bundles[1];
566        for i in 0..2 {
567            if bundles[i]
568                && (!mixed || outputs.iter().all(|v| *v))
569                && let Some(n) = supply_indices[i]
570            {
571                let side = ["a", "b"][i];
572                let mode = supplies[i][n]["value"]["retention"]
573                    .as_object()
574                    .and_then(|m| m.keys().next())
575                    .map(String::as_str)
576                    .unwrap_or("digest_only");
577                let digest = read(
578                    self.sets[i],
579                    side,
580                    "bundle",
581                    &format!("/supplies/{n}/value/retention/{mode}/digest"),
582                    FieldClass::ContentDigest,
583                );
584                ops[i] = read(
585                    self.sets[i],
586                    side,
587                    &format!("supply/{n}"),
588                    "",
589                    FieldClass::ThirdParty,
590                );
591                if !mixed {
592                    ops[i].compared = digest.value;
593                    ops[i].digest = true;
594                }
595                ops[i].cites.extend(digest.cites);
596            }
597            annotate(&mut ops[i], self.sets[i], None, steps[i]);
598            let b = builder(self.sets[i]);
599            let lineage = self.sets[i]
600                .plan
601                .as_ref()
602                .and_then(|p| facts::closure(p, &k.0));
603            ops[i].class = b.value_class(lineage.as_ref().or(b.lineage().as_ref()), false);
604        }
605        // The current run schema records a target, not the raw output. It must
606        // never be compared with a retained document or its digest.
607        let missing_mixed_output = mixed && outputs.iter().all(|v| *v);
608        if missing_mixed_output {
609            for i in 0..2 {
610                if !bundles[i] {
611                    ops[i].value = Value::Null;
612                    ops[i].state =
613                        json!({"not_available":Unavailable::NotRecorded { facet: None }});
614                }
615            }
616            if !backend_changed {
617                self.emit(
618                    &name,
619                    ops.clone(),
620                    Claim::OutputNotCompared,
621                    Divergence::StepChanged,
622                    refs,
623                );
624            }
625        } else {
626            let why = if backend_changed {
627                Divergence::BackendChanged
628            } else if outputs.iter().all(|v| *v) && same_backend && bundles.iter().all(|b| *b) {
629                Divergence::BackendOutputChanged
630            } else {
631                Divergence::RequestResultChanged
632            };
633            // If either document is unread, both digest paths remain citable.
634            if !mixed
635                && bundles.iter().all(|v| *v)
636                && ops.iter().any(|o| o.state.get("not_available").is_some())
637            {
638                let unavailable = ops
639                    .iter()
640                    .find(|o| o.state.get("not_available").is_some())
641                    .map(|o| o.state.clone())
642                    .unwrap();
643                for o in &mut ops {
644                    o.value = Value::Null;
645                    o.derivation = None;
646                    if o.state == "present" {
647                        // Name the item that prevents showing the value pair;
648                        // do not claim the readable side's item has expired.
649                        o.state = unavailable.clone();
650                    }
651                }
652            }
653            self.emit(&name, ops, Claim::Difference, why, refs);
654        }
655        if bundles.iter().all(|b| *b) {
656            for (m, class) in [
657                ("target", FieldClass::Number),
658                ("attempt_id", FieldClass::Id),
659            ] {
660                let paths = supply_indices.map(|n| {
661                    format!(
662                        "/supplies/{}/{m}",
663                        n.map(|n| n.to_string()).unwrap_or_else(|| "missing".into())
664                    )
665                });
666                let mut ops = self.pair("bundle", [&paths[0], &paths[1]], class);
667                for i in 0..2 {
668                    annotate(&mut ops[i], self.sets[i], None, steps[i]);
669                }
670                self.emit(
671                    &name,
672                    ops,
673                    Claim::Difference,
674                    Divergence::RequestResultChanged,
675                    refs,
676                );
677            }
678        }
679        // Backend identity is distinct from observation-only attempt ids.
680        for m in ["backend_id", "artifact"] {
681            let absent = || Operand {
682                value: Value::Null,
683                compared: Value::Null,
684                digest: false,
685                state: json!("absent"),
686                cites: vec![],
687                derivation: None,
688                class: FieldClass::PlanMetadata,
689            };
690            let mut ops = [
691                identities[0].remove(m).unwrap_or_else(absent),
692                identities[1].remove(m).unwrap_or_else(absent),
693            ];
694            for i in 0..2 {
695                annotate(&mut ops[i], self.sets[i], None, steps[i]);
696            }
697            self.emit(
698                &name,
699                ops,
700                Claim::Difference,
701                Divergence::BackendChanged,
702                refs,
703            );
704        }
705        Ok(())
706    }
707}
708fn cond_refs(c: &Cond, out: &mut Vec<String>) {
709    match c {
710        Cond::Always => {}
711        Cond::TopLabel { step, .. }
712        | Cond::MassAtLeast { step, .. }
713        | Cond::TopMassBelow { step, .. }
714        | Cond::ExpectationAtLeast { step, .. } => out.push(format!("step:{step}")),
715        Cond::Exact(p) => rustev_core::expr::predicate_refs(p, out),
716        Cond::Empty(r) | Cond::Nonempty(r) | Cond::ProvenanceWithin { reference: r, .. } => {
717            out.push(r.clone())
718        }
719        Cond::All(cs) | Cond::Any(cs) => {
720            for c in cs {
721                cond_refs(c, out);
722            }
723        }
724        Cond::Not(c) => cond_refs(c, out),
725    }
726}
727
728/// Compare retained evidence without executing either decision.
729pub fn explain_diff(
730    a: &Evidence<'_>,
731    b: &Evidence<'_>,
732    options: &ExplainOptions<'_>,
733) -> Result<Explanation, ExplainError> {
734    if let Some(p) = options.redaction {
735        p.validate()
736            .map_err(|e| ExplainError::parse("redaction", e))?;
737    }
738    let (a, b) = (evidence::Set::read(a)?, evidence::Set::read(b)?);
739    build(&a, &b, options, REPLAY_V1.max_bytes)
740}
741
742fn build(
743    a: &evidence::Set,
744    b: &evidence::Set,
745    options: &ExplainOptions<'_>,
746    max_bytes: usize,
747) -> Result<Explanation, ExplainError> {
748    let mut sides = vec![a.side.clone(), b.side.clone()];
749    sides[1].name = SideName::B;
750    for (i, side) in sides.iter_mut().enumerate() {
751        for item in [&mut side.decision_id, &mut side.plan_id] {
752            if let Item::Withheld(w) = item {
753                prefix(&mut w.not_available, ["a", "b"][i]);
754            }
755        }
756        redact::side(side, options.redaction);
757    }
758    let mut w = Walk {
759        sets: [a, b],
760        options,
761        facts: vec![],
762        differences: vec![],
763        observations: vec![],
764        not_compared: vec![],
765        earlier: vec![],
766        total: 0,
767        why: None,
768    };
769    let withheld = redact::withheld(options.redaction, FieldClass::Enum);
770    if withheld.is_none() {
771        w.compare()?;
772    }
773    let base = Explanation {
774        schema: rustev_contract::schema::EXPLANATION.into(),
775        explainer: EXPLAINER.into(),
776        facet_sources: vec![FacetSource {
777            name: Facet::ServedIdentity,
778            spec: "040-decision-explanation".into(),
779            path: "supply/<n>:/served".into(),
780        }],
781        sides,
782        redaction: options.redaction.map(|p| Redaction {
783            policy: p.policy_id(),
784        }),
785        query: Query::Diff,
786        answer: Answer::Diff(DiffAnswer::Identical {
787            observations: vec![],
788            not_compared: vec![],
789        }),
790        truncation: None,
791        facts: vec![],
792    };
793    let keyed = w.differences.first().map(|id| id.0.as_str());
794    let total_lists = w.facts.len() - usize::from(keyed.is_some());
795    let construct = |keep: usize| -> Result<Explanation, ExplainError> {
796        let mut doc = base.clone();
797        let mut lists = 0;
798        let mut ids = BTreeMap::new();
799        for f in &w.facts {
800            if Some(f.id.as_str()) != keyed {
801                lists += 1;
802                if lists > keep {
803                    continue;
804                }
805            }
806            let mut f = f.clone();
807            let new_id = format!("f{}", doc.facts.len() + 1);
808            ids.insert(f.id.clone(), FactId(new_id.clone()));
809            f.id = new_id;
810            doc.facts.push(f);
811        }
812        for f in &mut doc.facts {
813            if let Some(refs) = f
814                .value
815                .get_mut("follows_from")
816                .and_then(Value::as_array_mut)
817            {
818                for id in refs {
819                    if let Some(new) = id.as_str().and_then(|id| ids.get(id)) {
820                        *id = json!(new);
821                    }
822                }
823            }
824        }
825        let kept = |a: &[FactId]| {
826            a.iter()
827                .filter_map(|id| ids.get(&id.0).cloned())
828                .collect::<Vec<_>>()
829        };
830        let observations = kept(&w.observations);
831        let not_compared = kept(&w.not_compared);
832        doc.answer = Answer::Diff(if let Some(u) = &withheld {
833            DiffAnswer::Withheld {
834                redacted: u.clone(),
835            }
836        } else if let Some(first) = w.differences.first() {
837            let differences = kept(&w.differences);
838            DiffAnswer::Diverged {
839                first: ids[&first.0].clone(),
840                why: w.why.unwrap(),
841                truncated: w.total - differences.len() as u64,
842                total: w.total,
843                differences,
844                observations,
845                not_compared,
846            }
847        } else {
848            DiffAnswer::Identical {
849                observations,
850                not_compared,
851            }
852        });
853        // The first divergence is keyed and is not counted as a list member.
854        doc.truncation = (keep < total_lists).then_some(Truncation {
855            total: total_lists as u64,
856        });
857        let bytes = doc.record_canonical().map_err(|_| bounds::too_large())?;
858        if bytes.len() > max_bytes
859            || doc.facts.len() > MAX_FACTS
860            || rustev_contract::bounded::scan(&bytes, &REPLAY_V1).is_err()
861        {
862            return Err(bounds::too_large());
863        }
864        doc.validate()
865            .map_err(|e| ExplainError::parse("explanation", e))?;
866        Ok(doc)
867    };
868    let mut best = construct(0)?;
869    let (mut low, mut high) = (0, total_lists.min(MAX_FACTS - usize::from(keyed.is_some())));
870    // Most answers fit at the fact cap. Avoid rebuilding every smaller
871    // prefix when the largest possible prefix already satisfies all bounds.
872    if high > 0 {
873        match construct(high) {
874            Ok(doc) => return Ok(doc),
875            Err(e) if e.code == RefusalCode::ExplanationTooLarge => high -= 1,
876            Err(e) => return Err(e),
877        }
878    }
879    while low < high {
880        let mid = low + (high - low).div_ceil(2);
881        match construct(mid) {
882            Ok(doc) => {
883                best = doc;
884                low = mid;
885            }
886            Err(e) if e.code == RefusalCode::ExplanationTooLarge => high = mid - 1,
887            Err(e) => return Err(e),
888        }
889    }
890    Ok(best)
891}
892
893#[cfg(test)]
894mod tests {
895    use super::*;
896
897    #[test]
898    fn explain_diff_byte_limit_counts_omitted_differences() {
899        let digest = format!("sha256:{}", "0".repeat(64));
900        let set = |presence: &str| {
901            let inputs: Vec<_> = (0..8)
902                .map(|n| json!({"field":format!("field-{n}"), "presence":presence}))
903                .collect();
904            evidence::Set {
905                docs: [(
906                    "record".into(),
907                    json!({"core":{"recorded":{"inputs":inputs}}}),
908                )]
909                .into(),
910                absent: Default::default(),
911                plan: None,
912                side: serde_json::from_value(json!({"name":"a", "form":"record",
913                    "record":{"digest":digest}, "plan":null, "bundle":null,
914                    "capture":null, "decision_id":"d", "plan_id":digest}))
915                .unwrap(),
916            }
917        };
918        let (a, b) = (set("present"), set("missing"));
919        let options = ExplainOptions::default();
920        let full = build(&a, &b, &options, REPLAY_V1.max_bytes).unwrap();
921        assert_eq!(full.facts.len(), 8);
922        assert!(full.truncation.is_none());
923        // Below both count caps: only the byte budget can omit a difference.
924        let max_bytes = full.record_canonical().unwrap().len() - 1;
925        let doc = build(&a, &b, &options, max_bytes).unwrap();
926        doc.validate().unwrap();
927        assert!(doc.record_canonical().unwrap().len() <= max_bytes);
928        assert!(doc.truncation.is_some());
929        let Answer::Diff(DiffAnswer::Diverged {
930            total,
931            truncated,
932            differences,
933            ..
934        }) = &doc.answer
935        else {
936            panic!("expected a divergence");
937        };
938        assert_eq!(*total, 8);
939        assert!(!differences.is_empty());
940        assert_eq!(*truncated, *total - differences.len() as u64);
941        assert!(*truncated > 0);
942    }
943}