1use 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 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 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 !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 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 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
728pub 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 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 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 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}