1use std::fmt;
41
42use amberfork_model::{
43 Attribution, AttributionMode, Counterfactual, DiffResult, FieldDiffKind, Payload, Recovery,
44 ResourceDelta, ResourceDeltas, Run, Step,
45};
46use serde::{Deserialize, Serialize};
47
48pub use amberfork_model::{MoveKind, Outcome, StepKind, Warning};
53
54pub const UI_CSS: &str = include_str!("../ui.css");
63
64pub const DOCUMENT_VERSION: &str = "0.2";
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct Document {
75 pub schema_version: String,
77 pub view: ViewModel,
78}
79
80impl Document {
81 #[must_use]
85 pub fn new(mut view: ViewModel) -> Self {
86 envelope(&mut view);
87 Self {
88 schema_version: DOCUMENT_VERSION.to_string(),
89 view,
90 }
91 }
92}
93
94pub const SLOT_TEXT_LIMIT: usize = 4096;
100
101fn envelope(view: &mut ViewModel) {
106 for (row, r) in view.rows.iter_mut().enumerate() {
107 match r {
108 Row::Step(step_row) => envelope_step(row, &mut step_row.step),
109 Row::Fork(fork) => {
110 envelope_step(row, &mut fork.step);
111 fork.side_a.truncate_to(SLOT_TEXT_LIMIT, || SlotAddress {
112 row,
113 kind: SlotKind::ForkSide { side: Side::A },
114 });
115 fork.side_b.truncate_to(SLOT_TEXT_LIMIT, || SlotAddress {
116 row,
117 kind: SlotKind::ForkSide { side: Side::B },
118 });
119 }
120 }
121 }
122}
123
124fn envelope_step(row: usize, step: &mut AlignedStep) {
125 for (side, view) in [(Side::A, &mut step.a), (Side::B, &mut step.b)] {
126 if let Some(view) = view {
127 view.summary.truncate_to(SLOT_TEXT_LIMIT, || SlotAddress {
128 row,
129 kind: SlotKind::StepSummary { side },
130 });
131 }
132 }
133 for fd in &mut step.field_diffs {
134 if let Some(removed) = &mut fd.removed {
135 let path = fd.path.clone();
136 removed.truncate_to(SLOT_TEXT_LIMIT, || SlotAddress {
137 row,
138 kind: SlotKind::FieldRemoved { path },
139 });
140 }
141 if let Some(added) = &mut fd.added {
142 let path = fd.path.clone();
143 added.truncate_to(SLOT_TEXT_LIMIT, || SlotAddress {
144 row,
145 kind: SlotKind::FieldAdded { path },
146 });
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct SlotText {
157 pub text: String,
158 #[serde(default, skip_serializing_if = "is_false")]
161 pub truncated: bool,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub address: Option<SlotAddress>,
166}
167
168impl SlotText {
169 pub fn new(text: impl Into<String>) -> Self {
171 Self {
172 text: text.into(),
173 truncated: false,
174 address: None,
175 }
176 }
177
178 #[must_use]
179 pub fn as_str(&self) -> &str {
180 &self.text
181 }
182
183 fn truncate_to(&mut self, limit: usize, address: impl FnOnce() -> SlotAddress) {
188 if self.text.len() <= limit {
189 return;
190 }
191 let cut = (0..=limit)
192 .rev()
193 .find(|&i| self.text.is_char_boundary(i))
194 .unwrap_or(0);
195 self.text.truncate(cut);
196 self.truncated = true;
197 self.address = Some(address());
198 }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "lowercase")]
205pub enum Side {
206 A,
207 B,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(rename_all = "snake_case")]
213pub enum SlotKind {
214 StepSummary { side: Side },
216 ForkSide { side: Side },
218 FieldRemoved { path: String },
220 FieldAdded { path: String },
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct SlotAddress {
229 pub row: usize,
231 pub kind: SlotKind,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct PayloadResponse {
241 pub text: String,
242}
243
244impl fmt::Display for SlotText {
245 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246 f.write_str(&self.text)
247 }
248}
249
250impl PartialEq<&str> for SlotText {
252 fn eq(&self, other: &&str) -> bool {
253 self.text == *other
254 }
255}
256
257fn is_false(value: &bool) -> bool {
258 !*value
259}
260
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263pub struct ViewModel {
264 pub run_a: RunHeader,
265 pub run_b: RunHeader,
266 pub idx_width: usize,
270 pub rows: Vec<Row>,
271 pub verdict: Verdict,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub attribution: Option<AttributionView>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub deltas: Option<DeltasView>,
279 pub warnings: Vec<Warning>,
282}
283
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
286pub struct RunHeader {
287 pub id: String,
288 pub role: RunRole,
289 pub n_steps: usize,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub outcome: Option<Outcome>,
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
297#[serde(rename_all = "lowercase")]
298pub enum RunRole {
299 Reference,
300 Observed,
301}
302
303impl RunRole {
304 pub fn label(self) -> &'static str {
306 match self {
307 Self::Reference => "reference",
308 Self::Observed => "observed",
309 }
310 }
311}
312
313#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
315#[serde(rename_all = "lowercase")]
316pub enum Row {
317 Step(StepRow),
319 Fork(ForkRow),
321}
322
323impl Row {
324 pub fn step(&self) -> &AlignedStep {
326 match self {
327 Self::Step(row) => &row.step,
328 Self::Fork(row) => &row.step,
329 }
330 }
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(rename_all = "lowercase")]
337pub enum RowRole {
338 Spine,
340 Downstream,
342}
343
344#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
345pub struct StepRow {
346 pub role: RowRole,
347 pub kind: MoveKind,
349 pub step: AlignedStep,
350}
351
352#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
354pub struct ForkRow {
355 pub step: AlignedStep,
356 pub side_a: SlotText,
359 pub side_b: SlotText,
361 pub confidence: String,
363}
364
365#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
369pub struct AlignedStep {
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub a_idx: Option<usize>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub b_idx: Option<usize>,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub a: Option<StepView>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub b: Option<StepView>,
378 #[serde(default, skip_serializing_if = "Vec::is_empty")]
384 pub field_diffs: Vec<FieldDiffView>,
385}
386
387impl AlignedStep {
388 pub fn front(&self) -> Option<&StepView> {
391 self.b.as_ref().or(self.a.as_ref())
392 }
393
394 pub fn display_idx(&self) -> Option<usize> {
396 self.b_idx.or(self.a_idx)
397 }
398}
399
400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
402pub struct StepView {
403 pub kind: StepKind,
404 pub name: String,
405 pub summary: SlotText,
408}
409
410#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
414pub struct FieldDiffView {
415 pub path: String,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub removed: Option<SlotText>,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub added: Option<SlotText>,
420}
421
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
424#[serde(rename_all = "lowercase")]
425pub enum Verdict {
426 Identical {
428 steps: usize,
429 },
430 Absorbed {
432 absorbed: usize,
433 a_steps: usize,
434 b_steps: usize,
435 },
436 Forked,
437}
438
439impl Verdict {
440 pub fn converged_text(&self) -> Option<String> {
443 match *self {
444 Self::Identical { steps } => {
445 Some(format!("converged — identical through {steps} steps"))
446 }
447 Self::Absorbed {
448 absorbed,
449 a_steps,
450 b_steps,
451 } => Some(format!(
452 "converged — no fork ({absorbed} absorbed divergence{} across {a_steps}⇄{b_steps} steps)",
453 if absorbed == 1 { "" } else { "s" },
454 )),
455 Self::Forked => None,
456 }
457 }
458}
459
460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
465pub struct AttributionView {
466 pub mode: String,
469 pub origin: String,
471 pub propagation: String,
473 pub confidence: String,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub verdict: Option<String>,
479}
480
481#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
485pub struct DeltasView {
486 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub total: Option<String>,
489 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub at_fork: Option<String>,
492}
493
494impl ViewModel {
495 pub fn compute(result: &DiffResult, reference: &Run, observed: &Run) -> Self {
498 let max_idx = reference
499 .steps
500 .iter()
501 .chain(&observed.steps)
502 .map(|s| s.idx)
503 .max()
504 .unwrap_or(0);
505 let idx_width = decimal_digits(max_idx).max(2);
506
507 let rows = result
508 .alignment
509 .iter()
510 .enumerate()
511 .map(|(i, mv)| {
512 let step = AlignedStep {
513 a_idx: mv.a_idx,
514 b_idx: mv.b_idx,
515 a: step_view(reference, mv.a_idx),
516 b: step_view(observed, mv.b_idx),
517 field_diffs: field_diff_views(result, i),
520 };
521 match result.fork {
522 Some(fork) if i == fork.index => Row::Fork(ForkRow {
523 step,
524 side_a: side_content(reference, fork.a_step),
525 side_b: side_content(observed, fork.b_step),
526 confidence: confidence_text(fork.confidence),
527 }),
528 Some(fork) if i > fork.index => Row::Step(StepRow {
529 role: RowRole::Downstream,
530 kind: mv.kind,
531 step,
532 }),
533 _ => Row::Step(StepRow {
534 role: RowRole::Spine,
535 kind: mv.kind,
536 step,
537 }),
538 }
539 })
540 .collect();
541
542 let verdict = match result.fork {
543 Some(_) => Verdict::Forked,
544 None => {
545 let absorbed = result
549 .alignment
550 .iter()
551 .filter(|mv| mv.kind != MoveKind::Sync || mv.cost > 0.0)
552 .count();
553 if absorbed == 0 {
554 Verdict::Identical {
555 steps: result.alignment.len(),
556 }
557 } else {
558 Verdict::Absorbed {
559 absorbed,
560 a_steps: reference.steps.len(),
561 b_steps: observed.steps.len(),
562 }
563 }
564 }
565 };
566
567 Self {
568 run_a: RunHeader {
569 id: result.runs.a.id.clone(),
570 role: RunRole::Reference,
571 n_steps: reference.steps.len(),
572 outcome: reference.outcome,
573 },
574 run_b: RunHeader {
575 id: result.runs.b.id.clone(),
576 role: RunRole::Observed,
577 n_steps: observed.steps.len(),
578 outcome: observed.outcome,
579 },
580 idx_width,
581 rows,
582 verdict,
583 attribution: result
584 .attribution
585 .as_ref()
586 .map(|a| attribution_view(a, idx_width)),
587 deltas: result.deltas.as_ref().and_then(deltas_view),
588 warnings: result.warnings.clone(),
589 }
590 }
591
592 #[must_use]
598 pub fn full_text(&self, address: &SlotAddress) -> Option<&str> {
599 let row = self.rows.get(address.row)?;
600 match &address.kind {
601 SlotKind::StepSummary { side } => {
602 let step = match side {
603 Side::A => row.step().a.as_ref(),
604 Side::B => row.step().b.as_ref(),
605 }?;
606 Some(step.summary.as_str())
607 }
608 SlotKind::ForkSide { side } => {
609 let Row::Fork(fork) = row else {
610 return None;
611 };
612 Some(match side {
613 Side::A => fork.side_a.as_str(),
614 Side::B => fork.side_b.as_str(),
615 })
616 }
617 SlotKind::FieldRemoved { path } => row
618 .step()
619 .field_diffs
620 .iter()
621 .find(|fd| &fd.path == path)?
622 .removed
623 .as_ref()
624 .map(SlotText::as_str),
625 SlotKind::FieldAdded { path } => row
626 .step()
627 .field_diffs
628 .iter()
629 .find(|fd| &fd.path == path)?
630 .added
631 .as_ref()
632 .map(SlotText::as_str),
633 }
634 }
635
636 pub fn headline(&self) -> String {
644 if let Some(text) = self.verdict.converged_text() {
645 return text;
646 }
647 let fork = self.rows.iter().find_map(|row| match row {
650 Row::Fork(fork) => Some(fork),
651 Row::Step(_) => None,
652 });
653 let Some(fork) = fork else {
654 return "⑂ forked".to_string();
655 };
656 let idx = match fork.step.display_idx() {
657 Some(i) => format!("{i:0w$}", w = self.idx_width),
658 None => "·".repeat(self.idx_width),
660 };
661 format!("⑂ forked at step {idx} · {}", fork.confidence)
662 }
663}
664
665pub fn kind_label(kind: StepKind) -> &'static str {
667 match kind {
668 StepKind::Llm => "llm",
669 StepKind::Tool => "tool",
670 StepKind::Agent => "agent",
671 StepKind::Other => "other",
672 }
673}
674
675pub fn move_label(kind: MoveKind) -> &'static str {
678 match kind {
679 MoveKind::Sync => "sync",
680 MoveKind::Log => "log-move",
681 MoveKind::Model => "model-move",
682 }
683}
684
685pub fn outcome_label(outcome: Outcome) -> &'static str {
687 match outcome {
688 Outcome::Pass => "pass",
689 Outcome::Fail => "fail",
690 Outcome::Unknown => "unknown",
691 }
692}
693
694pub fn confidence_text(confidence: f64) -> String {
697 if confidence <= f64::EPSILON {
698 "marginal call".to_string()
699 } else {
700 format!("conf {confidence:.2}")
701 }
702}
703
704fn step_view(run: &Run, idx: Option<usize>) -> Option<StepView> {
706 idx.and_then(|i| run.steps.get(i)).map(|s| StepView {
707 kind: s.kind,
708 name: s.name.clone(),
709 summary: SlotText::new(summarize(s)),
710 })
711}
712
713fn side_content(run: &Run, step: Option<usize>) -> SlotText {
716 SlotText::new(match step {
717 Some(idx) => run.steps.get(idx).map_or_else(String::new, summarize),
718 None => "(no aligned step)".to_string(),
719 })
720}
721
722fn field_diff_views(result: &DiffResult, index: usize) -> Vec<FieldDiffView> {
724 result
725 .field_diffs
726 .iter()
727 .filter(|fd| fd.step == index)
728 .map(|fd| FieldDiffView {
729 path: fd.path.clone(),
730 removed: match fd.kind {
731 FieldDiffKind::Added => None,
732 _ => fd.before.as_ref().map(|v| SlotText::new(compact_json(v))),
733 },
734 added: match fd.kind {
735 FieldDiffKind::Removed => None,
736 _ => fd.after.as_ref().map(|v| SlotText::new(compact_json(v))),
737 },
738 })
739 .collect()
740}
741
742fn attribution_view(attribution: &Attribution, idx_width: usize) -> AttributionView {
743 let mode = match attribution.mode {
744 AttributionMode::Static => "static",
745 AttributionMode::Counterfactual => "counterfactual",
746 }
747 .to_string();
748 let origin = attribution.origin_step.map_or_else(
749 || "origin unlocalized".to_string(),
750 |s| format!("origin step {s:0w$}", w = idx_width),
751 );
752 AttributionView {
753 mode,
754 origin,
755 propagation: steps_text(&attribution.propagation, idx_width),
756 confidence: confidence_text(attribution.confidence),
757 verdict: attribution.counterfactual.as_ref().map(verdict_text),
760 }
761}
762
763fn verdict_text(counterfactual: &Counterfactual) -> String {
768 let outcome = match counterfactual.recovered {
769 Recovery::Recovered => "recovered",
770 Recovery::NotRecovered => "not recovered",
771 Recovery::Unverified => "unverified",
772 };
773 let runs = counterfactual.runs;
774 let unit = if runs == 1 { "run" } else { "runs" };
775 format!("{outcome} · {runs} {unit}")
776}
777
778fn deltas_view(deltas: &ResourceDeltas) -> Option<DeltasView> {
781 let total = delta_text(&deltas.total);
782 let at_fork = deltas.at_fork.as_ref().and_then(delta_text);
783 (total.is_some() || at_fork.is_some()).then_some(DeltasView { total, at_fork })
784}
785
786fn delta_text(delta: &ResourceDelta) -> Option<String> {
789 let parts: Vec<String> = [
790 delta.latency_ms.map(latency_text),
791 delta.tokens.map(token_text),
792 ]
793 .into_iter()
794 .flatten()
795 .collect();
796 (!parts.is_empty()).then(|| parts.join(" · "))
797}
798
799fn latency_text(ms: i64) -> String {
802 let sign = if ms < 0 { '-' } else { '+' };
803 let abs = ms.unsigned_abs();
804 if abs >= 1000 {
805 format!("{sign}{:.2}s", abs as f64 / 1000.0)
806 } else {
807 format!("{sign}{abs}ms")
808 }
809}
810
811fn token_text(tokens: i64) -> String {
813 let sign = if tokens < 0 { '-' } else { '+' };
814 format!("{sign}{} tok", tokens.unsigned_abs())
815}
816
817fn steps_text(steps: &[usize], idx_width: usize) -> String {
820 let pad = |s: &usize| format!("{s:0idx_width$}");
821 let contiguous = steps.windows(2).all(|w| w[1] == w[0] + 1);
822 match steps {
823 [] => "none".to_string(),
824 [only] => format!("step {}", pad(only)),
825 [first, .., last] if contiguous => format!("steps {}–{}", pad(first), pad(last)),
826 _ => format!(
827 "steps {}",
828 steps.iter().map(pad).collect::<Vec<_>>().join(", ")
829 ),
830 }
831}
832
833fn summarize(step: &Step) -> String {
835 let payload = step.outputs.as_ref().or(step.inputs.as_ref());
836 match payload {
837 None => "(no content captured)".to_string(),
838 Some(Payload::Text(t)) => t.lines().next().unwrap_or("").to_string(),
839 Some(Payload::Object(map)) => compact_json(&serde_json::Value::Object(map.clone())),
840 Some(Payload::Other(v)) => compact_json(v),
841 }
842}
843
844fn compact_json(value: &serde_json::Value) -> String {
845 serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
846}
847
848fn decimal_digits(n: usize) -> usize {
849 if n == 0 { 1 } else { n.ilog10() as usize + 1 }
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855 use amberfork_model::{
856 Attribution, DiffResult, FieldDiff, Fork, Meta, Move, RunPair, RunRef, Source, Warning,
857 WarningCode, test_support,
858 };
859 use serde_json::json;
860
861 fn step(idx: usize, name: &str, out: &str) -> Step {
865 test_support::step(idx, name).text_output(out).build()
866 }
867
868 fn run(id: &str, outcome: Outcome, steps: Vec<Step>) -> Run {
869 test_support::run(id, steps).outcome(outcome).build()
870 }
871
872 fn result(a: &Run, b: &Run, alignment: Vec<Move>, fork: Option<Fork>) -> DiffResult {
873 DiffResult {
874 runs: RunPair {
875 a: RunRef {
876 id: a.id.clone(),
877 task: None,
878 outcome: a.outcome,
879 n_steps: a.steps.len(),
880 },
881 b: RunRef {
882 id: b.id.clone(),
883 task: None,
884 outcome: b.outcome,
885 n_steps: b.steps.len(),
886 },
887 },
888 alignment,
889 fork,
890 field_diffs: Vec::new(),
891 attribution: None,
892 deltas: None,
893 warnings: Vec::new(),
894 meta: Meta::current(Source::Passive),
895 }
896 }
897
898 fn forked(confidence: f64) -> (Run, Run, DiffResult) {
901 let a = run(
902 "good",
903 Outcome::Pass,
904 vec![
905 step(0, "plan", "search for census data"),
906 step(1, "web.search", "9 results, top census.gov"),
907 step(2, "web.fetch", "census.gov page: population 8,443,000"),
908 ],
909 );
910 let b = run(
911 "bad",
912 Outcome::Fail,
913 vec![
914 step(0, "plan", "search for census data"),
915 step(1, "web.search", "9 results, top census.gov"),
916 step(
917 2,
918 "web.fetch",
919 "blogspot page: the city has grown to 9,100,000",
920 ),
921 step(3, "reader", "blog says about 9,100,000 people"),
922 ],
923 );
924 let alignment = vec![
925 Move::sync(0, 0, 0.02, 0.98),
926 Move::sync(1, 1, 0.05, 0.95),
927 Move::sync(2, 2, 0.82, 0.18),
928 Move::log(3, 0.6, 0.9),
929 ];
930 let fork = Fork {
931 index: 2,
932 a_step: Some(2),
933 b_step: Some(2),
934 confidence,
935 };
936 let mut res = result(&a, &b, alignment, Some(fork));
937 res.field_diffs = vec![FieldDiff {
938 step: 2,
939 path: "outputs".to_string(),
940 before: Some(json!("census.gov page")),
941 after: Some(json!("blogspot page")),
942 kind: FieldDiffKind::Changed,
943 }];
944 res.attribution = Some(Attribution {
945 mode: AttributionMode::Static,
946 origin_step: Some(2),
947 propagation: vec![3],
948 counterfactual: None,
949 cause_label: None,
950 confidence,
951 });
952 (a, b, res)
953 }
954
955 #[test]
956 fn forked_rows_carry_roles_in_alignment_order() {
957 let (a, b, res) = forked(0.47);
958 let view = ViewModel::compute(&res, &a, &b);
959
960 let roles: Vec<&str> = view
961 .rows
962 .iter()
963 .map(|row| match row {
964 Row::Step(s) if s.role == RowRole::Spine => "spine",
965 Row::Step(_) => "downstream",
966 Row::Fork(_) => "fork",
967 })
968 .collect();
969 assert_eq!(roles, ["spine", "spine", "fork", "downstream"]);
970 assert_eq!(view.verdict, Verdict::Forked);
971 assert_eq!(view.verdict.converged_text(), None);
972 }
973
974 #[test]
975 fn fork_row_resolves_sides_confidence_and_evidence() {
976 let (a, b, res) = forked(0.47);
977 let view = ViewModel::compute(&res, &a, &b);
978
979 let Some(Row::Fork(fork)) = view.rows.get(2) else {
980 panic!("row 2 is the fork");
981 };
982 assert_eq!(fork.side_a, "census.gov page: population 8,443,000");
983 assert_eq!(
984 fork.side_b,
985 "blogspot page: the city has grown to 9,100,000"
986 );
987 assert_eq!(fork.confidence, "conf 0.47");
988 assert_eq!(
989 fork.step.field_diffs,
990 [FieldDiffView {
991 path: "outputs".to_string(),
992 removed: Some(SlotText::new("\"census.gov page\"")),
993 added: Some(SlotText::new("\"blogspot page\"")),
994 }]
995 );
996 assert_eq!(fork.step.front().unwrap().name, "web.fetch");
998 assert_eq!(fork.step.display_idx(), Some(2));
999 assert!(fork.step.a.is_some() && fork.step.b.is_some());
1000 }
1001
1002 #[test]
1003 fn attribution_parts_follow_the_reading_order() {
1004 let (a, b, res) = forked(0.47);
1005 let view = ViewModel::compute(&res, &a, &b);
1006
1007 assert_eq!(
1008 view.attribution,
1009 Some(AttributionView {
1010 mode: "static".to_string(),
1011 origin: "origin step 02".to_string(),
1012 propagation: "step 03".to_string(),
1013 confidence: "conf 0.47".to_string(),
1014 verdict: None,
1017 })
1018 );
1019 }
1020
1021 #[test]
1024 fn a_counterfactual_attribution_adds_the_recovery_verdict() {
1025 let attribution = Attribution {
1026 mode: AttributionMode::Counterfactual,
1027 origin_step: Some(2),
1028 propagation: vec![3],
1029 counterfactual: Some(Counterfactual {
1030 recovered: Recovery::Recovered,
1031 runs: 3,
1032 }),
1033 cause_label: None,
1034 confidence: 0.86,
1035 };
1036 let view = attribution_view(&attribution, 2);
1037 assert_eq!(view.mode, "counterfactual");
1038 assert_eq!(view.verdict.as_deref(), Some("recovered · 3 runs"));
1039 }
1040
1041 #[test]
1044 fn each_recovery_state_has_its_designed_verdict_wording() {
1045 let verdict = |recovered, runs| verdict_text(&Counterfactual { recovered, runs });
1046 assert_eq!(verdict(Recovery::Recovered, 3), "recovered · 3 runs");
1047 assert_eq!(verdict(Recovery::NotRecovered, 3), "not recovered · 3 runs");
1048 assert_eq!(verdict(Recovery::Unverified, 3), "unverified · 3 runs");
1049 assert_eq!(verdict(Recovery::Recovered, 1), "recovered · 1 run");
1050 }
1051
1052 #[test]
1053 fn compute_carries_deltas_from_the_result_into_the_view() {
1054 let (a, b, mut res) = forked(0.47);
1055 res.deltas = Some(ResourceDeltas {
1056 total: ResourceDelta {
1057 latency_ms: Some(5_200),
1058 tokens: Some(300),
1059 },
1060 at_fork: Some(ResourceDelta {
1061 latency_ms: Some(1_200),
1062 tokens: None,
1063 }),
1064 });
1065 let view = ViewModel::compute(&res, &a, &b);
1066
1067 assert_eq!(
1068 view.deltas,
1069 Some(DeltasView {
1070 total: Some("+5.20s · +300 tok".to_string()),
1071 at_fork: Some("+1.20s".to_string()),
1072 })
1073 );
1074 }
1075
1076 #[test]
1077 fn compute_leaves_deltas_none_when_the_result_has_none() {
1078 let (a, b, res) = forked(0.47);
1079 let view = ViewModel::compute(&res, &a, &b);
1080 assert_eq!(view.deltas, None);
1081 }
1082
1083 #[test]
1084 fn latency_deltas_switch_units_at_one_second() {
1085 assert_eq!(latency_text(120), "+120ms");
1086 assert_eq!(latency_text(-40), "-40ms");
1087 assert_eq!(latency_text(5_200), "+5.20s");
1088 assert_eq!(latency_text(-1_000), "-1.00s");
1089 assert_eq!(latency_text(0), "+0ms");
1090 }
1091
1092 #[test]
1093 fn token_deltas_are_signed() {
1094 assert_eq!(token_text(300), "+300 tok");
1095 assert_eq!(token_text(-40), "-40 tok");
1096 }
1097
1098 #[test]
1099 fn a_delta_with_only_tokens_omits_the_latency_segment() {
1100 let delta = ResourceDelta {
1101 latency_ms: None,
1102 tokens: Some(300),
1103 };
1104 assert_eq!(delta_text(&delta).as_deref(), Some("+300 tok"));
1105 }
1106
1107 #[test]
1108 fn an_empty_delta_has_no_text() {
1109 let delta = ResourceDelta {
1110 latency_ms: None,
1111 tokens: None,
1112 };
1113 assert_eq!(delta_text(&delta), None);
1114 }
1115
1116 #[test]
1117 fn deltas_view_joins_total_and_at_fork() {
1118 let deltas = ResourceDeltas {
1119 total: ResourceDelta {
1120 latency_ms: Some(5_200),
1121 tokens: Some(300),
1122 },
1123 at_fork: Some(ResourceDelta {
1124 latency_ms: Some(1_200),
1125 tokens: None,
1126 }),
1127 };
1128 let view = deltas_view(&deltas).expect("both granularities carry data");
1129 assert_eq!(view.total.as_deref(), Some("+5.20s · +300 tok"));
1130 assert_eq!(view.at_fork.as_deref(), Some("+1.20s"));
1131 }
1132
1133 #[test]
1134 fn deltas_view_is_none_when_nothing_has_data() {
1135 let deltas = ResourceDeltas {
1136 total: ResourceDelta {
1137 latency_ms: None,
1138 tokens: None,
1139 },
1140 at_fork: None,
1141 };
1142 assert!(deltas_view(&deltas).is_none());
1143 }
1144
1145 #[test]
1146 fn every_sync_pair_carries_its_own_field_diff() {
1147 let a = run(
1152 "good",
1153 Outcome::Pass,
1154 vec![
1155 step(0, "plan", "same"),
1156 step(1, "lookup", "reference value"),
1157 step(2, "send", "reference tail"),
1158 ],
1159 );
1160 let b = run(
1161 "bad",
1162 Outcome::Fail,
1163 vec![
1164 step(0, "plan", "same"),
1165 step(1, "lookup", "observed value"),
1166 step(2, "send", "observed tail"),
1167 ],
1168 );
1169 let alignment = vec![
1172 Move::sync(0, 0, 0.0, 1.0),
1173 Move::sync(1, 1, 0.8, 0.2),
1174 Move::sync(2, 2, 0.8, 0.2),
1175 ];
1176 let fork = Fork {
1177 index: 1,
1178 a_step: Some(1),
1179 b_step: Some(1),
1180 confidence: 0.8,
1181 };
1182 let mut res = result(&a, &b, alignment, Some(fork));
1185 res.field_diffs = vec![
1186 FieldDiff {
1187 step: 1,
1188 path: "outputs".to_string(),
1189 before: Some(json!("reference value")),
1190 after: Some(json!("observed value")),
1191 kind: FieldDiffKind::Changed,
1192 },
1193 FieldDiff {
1194 step: 2,
1195 path: "outputs".to_string(),
1196 before: Some(json!("reference tail")),
1197 after: Some(json!("observed tail")),
1198 kind: FieldDiffKind::Changed,
1199 },
1200 ];
1201 let view = ViewModel::compute(&res, &a, &b);
1202
1203 let Some(Row::Step(spine)) = view.rows.first() else {
1204 panic!("row 0 is the spine");
1205 };
1206 assert!(
1207 spine.step.field_diffs.is_empty(),
1208 "an identical sync pair carries no field diff: {:?}",
1209 spine.step.field_diffs
1210 );
1211
1212 let Some(Row::Step(down)) = view.rows.get(2) else {
1213 panic!("row 2 is the divergent downstream sync");
1214 };
1215 assert_eq!(down.role, RowRole::Downstream);
1216 assert_eq!(
1217 down.step.field_diffs,
1218 [FieldDiffView {
1219 path: "outputs".to_string(),
1220 removed: Some(SlotText::new("\"reference tail\"")),
1221 added: Some(SlotText::new("\"observed tail\"")),
1222 }],
1223 "the divergent downstream pair surfaces its own field diff"
1224 );
1225 }
1226
1227 #[test]
1228 fn zero_confidence_is_an_explicit_marginal_call() {
1229 let (a, b, res) = forked(0.0);
1230 let view = ViewModel::compute(&res, &a, &b);
1231
1232 let Some(Row::Fork(fork)) = view.rows.get(2) else {
1233 panic!("row 2 is the fork");
1234 };
1235 assert_eq!(fork.confidence, "marginal call");
1236 assert_eq!(view.attribution.unwrap().confidence, "marginal call");
1237 assert_eq!(confidence_text(0.47), "conf 0.47");
1238 }
1239
1240 #[test]
1241 fn model_move_fronts_the_reference_side() {
1242 let a = run("good", Outcome::Pass, vec![step(0, "plan", "x")]);
1245 let b = run("bad", Outcome::Fail, vec![]);
1246 let res = result(&a, &b, vec![Move::model(0, 0.6, 0.0)], None);
1247 let view = ViewModel::compute(&res, &a, &b);
1248
1249 let Some(Row::Step(row)) = view.rows.first() else {
1250 panic!("one row");
1251 };
1252 assert_eq!(row.kind, MoveKind::Model);
1253 assert_eq!(row.step.front().unwrap().name, "plan");
1254 assert_eq!(row.step.display_idx(), Some(0));
1255 assert!(row.step.b.is_none(), "no observed side on a model move");
1256 }
1257
1258 #[test]
1259 fn converged_identical_is_earned_by_all_zero_cost_syncs() {
1260 let steps = || vec![step(0, "plan", "x"), step(1, "answer", "y")];
1261 let a = run("good", Outcome::Pass, steps());
1262 let b = run("good_again", Outcome::Pass, steps());
1263 let alignment = vec![Move::sync(0, 0, 0.0, 1.0), Move::sync(1, 1, 0.0, 1.0)];
1264 let view = ViewModel::compute(&result(&a, &b, alignment, None), &a, &b);
1265
1266 assert_eq!(view.verdict, Verdict::Identical { steps: 2 });
1267 assert_eq!(
1268 view.verdict.converged_text().unwrap(),
1269 "converged — identical through 2 steps"
1270 );
1271 assert!(view.attribution.is_none());
1272 }
1273
1274 #[test]
1275 fn headline_forked_names_the_step_and_confidence() {
1276 let (a, b, res) = forked(0.86);
1277 let view = ViewModel::compute(&res, &a, &b);
1278 assert_eq!(view.headline(), "⑂ forked at step 02 · conf 0.86");
1279 }
1280
1281 #[test]
1282 fn headline_composes_the_designed_weak_call_wording() {
1283 let (a, b, res) = forked(0.0);
1284 let view = ViewModel::compute(&res, &a, &b);
1285 assert_eq!(view.headline(), "⑂ forked at step 02 · marginal call");
1286 }
1287
1288 #[test]
1289 fn headline_converged_is_exactly_the_converged_text() {
1290 let steps = || vec![step(0, "plan", "x"), step(1, "answer", "y")];
1291 let a = run("good", Outcome::Pass, steps());
1292 let b = run("good_again", Outcome::Pass, steps());
1293 let alignment = vec![Move::sync(0, 0, 0.0, 1.0), Move::sync(1, 1, 0.0, 1.0)];
1294 let view = ViewModel::compute(&result(&a, &b, alignment, None), &a, &b);
1295 assert_eq!(view.headline(), "converged — identical through 2 steps");
1296 assert_eq!(Some(view.headline()), view.verdict.converged_text());
1297 }
1298
1299 #[test]
1300 fn absorbed_divergence_never_claims_identical() {
1301 let a = run(
1302 "good",
1303 Outcome::Pass,
1304 vec![step(0, "plan", "x"), step(1, "answer", "y")],
1305 );
1306 let b = run("good_retry", Outcome::Pass, vec![step(0, "plan", "x")]);
1307 let alignment = vec![Move::sync(0, 0, 0.0, 1.0), Move::model(1, 0.6, 0.0)];
1308 let view = ViewModel::compute(&result(&a, &b, alignment, None), &a, &b);
1309
1310 assert_eq!(
1311 view.verdict,
1312 Verdict::Absorbed {
1313 absorbed: 1,
1314 a_steps: 2,
1315 b_steps: 1,
1316 }
1317 );
1318 let text = view.verdict.converged_text().unwrap();
1319 assert_eq!(
1320 text,
1321 "converged — no fork (1 absorbed divergence across 2⇄1 steps)"
1322 );
1323 assert!(!text.contains("identical"));
1324 }
1325
1326 #[test]
1327 fn missing_fork_side_resolves_to_the_designed_absence() {
1328 let a = run("good", Outcome::Pass, vec![step(0, "plan", "x")]);
1329 let b = run("bad", Outcome::Fail, vec![step(0, "other", "y")]);
1330 let fork = Fork {
1331 index: 0,
1332 a_step: None,
1333 b_step: Some(0),
1334 confidence: 0.9,
1335 };
1336 let res = result(&a, &b, vec![Move::sync(0, 0, 0.9, 0.1)], Some(fork));
1337 let view = ViewModel::compute(&res, &a, &b);
1338
1339 let Some(Row::Fork(fork)) = view.rows.first() else {
1340 panic!("one fork row");
1341 };
1342 assert_eq!(fork.side_a, "(no aligned step)");
1343 assert_eq!(fork.side_b, "y");
1344 }
1345
1346 #[test]
1347 fn contiguous_propagation_collapses_to_a_padded_range() {
1348 let (a, b, mut res) = forked(0.47);
1349 res.attribution.as_mut().unwrap().propagation = vec![3, 4, 5];
1350 let view = ViewModel::compute(&res, &a, &b);
1351 assert_eq!(view.attribution.unwrap().propagation, "steps 03–05");
1352 }
1353
1354 #[test]
1355 fn document_roundtrips_through_json() {
1356 let (a, b, res) = forked(0.47);
1357 let doc = Document::new(ViewModel::compute(&res, &a, &b));
1358 assert_eq!(doc.schema_version, DOCUMENT_VERSION);
1359
1360 let json = serde_json::to_string(&doc).unwrap();
1361 let back: Document = serde_json::from_str(&json).unwrap();
1362 assert_eq!(back, doc);
1363 }
1364
1365 #[test]
1366 fn roundtripped_roles_match_the_fork() {
1367 let (a, b, res) = forked(0.47);
1368 let doc = Document::new(ViewModel::compute(&res, &a, &b));
1369 let back: Document = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
1370
1371 let fork_index = res.fork.unwrap().index;
1372 for (i, row) in back.view.rows.iter().enumerate() {
1373 match row {
1374 Row::Fork(_) => assert_eq!(i, fork_index),
1375 Row::Step(s) if i < fork_index => assert_eq!(s.role, RowRole::Spine),
1376 Row::Step(s) => assert_eq!(s.role, RowRole::Downstream),
1377 }
1378 }
1379 }
1380
1381 #[test]
1382 fn converged_verdicts_survive_the_wire() {
1383 let steps = || vec![step(0, "plan", "x"), step(1, "answer", "y")];
1384 let a = run("good", Outcome::Pass, steps());
1385 let b = run("good_again", Outcome::Pass, steps());
1386 let alignment = vec![Move::sync(0, 0, 0.0, 1.0), Move::sync(1, 1, 0.0, 1.0)];
1387 let doc = Document::new(ViewModel::compute(&result(&a, &b, alignment, None), &a, &b));
1388 let back: Document = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
1389 assert_eq!(back.view.verdict, Verdict::Identical { steps: 2 });
1390 assert_eq!(
1391 back.view.verdict.converged_text().unwrap(),
1392 "converged — identical through 2 steps"
1393 );
1394
1395 let b = run("good_retry", Outcome::Pass, vec![step(0, "plan", "x")]);
1398 let alignment = vec![Move::sync(0, 0, 0.0, 1.0), Move::model(1, 0.6, 0.0)];
1399 let doc = Document::new(ViewModel::compute(&result(&a, &b, alignment, None), &a, &b));
1400 let back: Document = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
1401 assert_eq!(
1402 back.view.verdict,
1403 Verdict::Absorbed {
1404 absorbed: 1,
1405 a_steps: 2,
1406 b_steps: 1,
1407 }
1408 );
1409 assert!(
1410 !back
1411 .view
1412 .verdict
1413 .converged_text()
1414 .unwrap()
1415 .contains("identical")
1416 );
1417 }
1418
1419 #[test]
1420 fn envelope_truncates_and_marks_over_limit_slots() {
1421 let huge = "x".repeat(SLOT_TEXT_LIMIT + 100);
1422 let a = run("good", Outcome::Pass, vec![step(0, "fetch", &huge)]);
1423 let b = run("bad", Outcome::Fail, vec![step(0, "fetch", "small")]);
1424 let fork = Fork {
1425 index: 0,
1426 a_step: Some(0),
1427 b_step: Some(0),
1428 confidence: 0.9,
1429 };
1430 let mut res = result(&a, &b, vec![Move::sync(0, 0, 0.9, 0.1)], Some(fork));
1431 res.field_diffs = vec![FieldDiff {
1432 step: 0,
1433 path: "outputs".to_string(),
1434 before: Some(json!(huge.clone())),
1435 after: Some(json!("small")),
1436 kind: FieldDiffKind::Changed,
1437 }];
1438 let doc = Document::new(ViewModel::compute(&res, &a, &b));
1439
1440 let Some(Row::Fork(fork)) = doc.view.rows.first() else {
1441 panic!("one fork row");
1442 };
1443 assert!(fork.side_a.truncated);
1446 assert_eq!(fork.side_a.text.len(), SLOT_TEXT_LIMIT);
1447 assert!(huge.starts_with(&fork.side_a.text));
1448 assert_eq!(
1449 fork.side_a.address,
1450 Some(SlotAddress {
1451 row: 0,
1452 kind: SlotKind::ForkSide { side: Side::A },
1453 })
1454 );
1455 let a_summary = &fork.step.a.as_ref().unwrap().summary;
1456 assert!(a_summary.truncated);
1457 assert_eq!(a_summary.text.len(), SLOT_TEXT_LIMIT);
1458 assert!(huge.starts_with(&a_summary.text));
1459 assert_eq!(
1460 a_summary.address,
1461 Some(SlotAddress {
1462 row: 0,
1463 kind: SlotKind::StepSummary { side: Side::A },
1464 })
1465 );
1466
1467 assert!(!fork.side_b.truncated);
1468 assert_eq!(fork.side_b.address, None);
1469 let removed = fork.step.field_diffs[0].removed.as_ref().unwrap();
1470 assert!(removed.truncated);
1471 assert!(removed.text.len() <= SLOT_TEXT_LIMIT);
1472 assert_eq!(
1473 removed.address,
1474 Some(SlotAddress {
1475 row: 0,
1476 kind: SlotKind::FieldRemoved {
1477 path: "outputs".to_string()
1478 },
1479 })
1480 );
1481 let added = fork.step.field_diffs[0].added.as_ref().unwrap();
1482 assert!(!added.truncated);
1483 assert_eq!(added.address, None);
1484
1485 let back: Document = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap();
1487 assert_eq!(back, doc);
1488 }
1489
1490 #[test]
1491 fn full_text_resolves_every_truncated_slot_kind_back_to_the_untruncated_view() {
1492 let huge = "x".repeat(SLOT_TEXT_LIMIT + 100);
1496 let a = run("good", Outcome::Pass, vec![step(0, "fetch", &huge)]);
1497 let b = run("bad", Outcome::Fail, vec![step(0, "fetch", &huge)]);
1498 let fork = Fork {
1499 index: 0,
1500 a_step: Some(0),
1501 b_step: Some(0),
1502 confidence: 0.9,
1503 };
1504 let mut res = result(&a, &b, vec![Move::sync(0, 0, 0.9, 0.1)], Some(fork));
1505 res.field_diffs = vec![FieldDiff {
1506 step: 0,
1507 path: "outputs".to_string(),
1508 before: Some(json!(huge.clone())),
1509 after: Some(json!(huge.clone())),
1510 kind: FieldDiffKind::Changed,
1511 }];
1512 let full_view = ViewModel::compute(&res, &a, &b);
1514 let doc = Document::new(full_view.clone());
1515
1516 let Some(Row::Fork(fork)) = doc.view.rows.first() else {
1517 panic!("one fork row");
1518 };
1519 let raw_text_addresses = [
1521 fork.side_a.address.clone().unwrap(),
1522 fork.side_b.address.clone().unwrap(),
1523 fork.step
1524 .a
1525 .as_ref()
1526 .unwrap()
1527 .summary
1528 .address
1529 .clone()
1530 .unwrap(),
1531 fork.step
1532 .b
1533 .as_ref()
1534 .unwrap()
1535 .summary
1536 .address
1537 .clone()
1538 .unwrap(),
1539 ];
1540 for address in &raw_text_addresses {
1541 assert_eq!(
1542 full_view.full_text(address),
1543 Some(huge.as_str()),
1544 "address {address:?} must resolve to the full, untruncated payload"
1545 );
1546 }
1547 let quoted = format!("\"{huge}\"");
1550 for address in [
1551 fork.step.field_diffs[0]
1552 .removed
1553 .as_ref()
1554 .unwrap()
1555 .address
1556 .clone()
1557 .unwrap(),
1558 fork.step.field_diffs[0]
1559 .added
1560 .as_ref()
1561 .unwrap()
1562 .address
1563 .clone()
1564 .unwrap(),
1565 ] {
1566 assert_eq!(
1567 full_view.full_text(&address),
1568 Some(quoted.as_str()),
1569 "address {address:?} must resolve to the full, untruncated field value"
1570 );
1571 }
1572 }
1573
1574 #[test]
1575 fn full_text_is_none_for_an_address_that_does_not_resolve() {
1576 let (a, b, res) = forked(0.47);
1577 let view = ViewModel::compute(&res, &a, &b);
1578
1579 assert_eq!(
1581 view.full_text(&SlotAddress {
1582 row: 99,
1583 kind: SlotKind::ForkSide { side: Side::A },
1584 }),
1585 None
1586 );
1587 assert_eq!(
1589 view.full_text(&SlotAddress {
1590 row: 0,
1591 kind: SlotKind::ForkSide { side: Side::A },
1592 }),
1593 None
1594 );
1595 assert_eq!(
1597 view.full_text(&SlotAddress {
1598 row: 2,
1599 kind: SlotKind::FieldRemoved {
1600 path: "no-such-field".to_string(),
1601 },
1602 }),
1603 None
1604 );
1605 }
1606
1607 #[test]
1608 fn envelope_backs_off_to_a_char_boundary() {
1609 let text = format!("ab{}", "🌲".repeat(SLOT_TEXT_LIMIT / 4));
1612 let a = run("good", Outcome::Pass, vec![step(0, "fetch", &text)]);
1613 let b = run("bad", Outcome::Fail, vec![step(0, "fetch", "small")]);
1614 let fork = Fork {
1615 index: 0,
1616 a_step: Some(0),
1617 b_step: Some(0),
1618 confidence: 0.9,
1619 };
1620 let res = result(&a, &b, vec![Move::sync(0, 0, 0.9, 0.1)], Some(fork));
1621 let doc = Document::new(ViewModel::compute(&res, &a, &b));
1622
1623 let Some(Row::Fork(fork)) = doc.view.rows.first() else {
1624 panic!("one fork row");
1625 };
1626 assert!(fork.side_a.truncated);
1627 assert_eq!(fork.side_a.text.len(), SLOT_TEXT_LIMIT - 2);
1628 assert!(text.starts_with(&fork.side_a.text));
1629 }
1630
1631 #[test]
1632 fn under_limit_slots_pass_through_unmarked() {
1633 let (a, b, res) = forked(0.47);
1634 let doc = Document::new(ViewModel::compute(&res, &a, &b));
1635
1636 let json = serde_json::to_string(&doc).unwrap();
1639 assert!(!json.contains("truncated"));
1640 let Some(Row::Fork(fork)) = doc.view.rows.get(2) else {
1641 panic!("row 2 is the fork");
1642 };
1643 assert_eq!(fork.side_a, "census.gov page: population 8,443,000");
1644 }
1645
1646 #[test]
1647 fn headers_idx_width_and_warnings_come_through() {
1648 let (a, b, mut res) = forked(0.47);
1649 res.warnings = vec![Warning {
1650 code: WarningCode::ContentAbsent,
1651 msg: "run b: step 3 carried no content".to_string(),
1652 }];
1653 let view = ViewModel::compute(&res, &a, &b);
1654
1655 assert_eq!(view.run_a.role.label(), "reference");
1656 assert_eq!(view.run_b.role.label(), "observed");
1657 assert_eq!((view.run_a.n_steps, view.run_b.n_steps), (3, 4));
1658 assert_eq!(view.run_a.outcome, Some(Outcome::Pass));
1659 assert_eq!(view.idx_width, 2, "indices are at least two digits");
1660 assert_eq!(view.warnings.len(), 1);
1661 }
1662}