Skip to main content

amberfork_layout/
lib.rs

1//! The semantic view-model over a [`DiffResult`] — the one seam every painter reads
2//! (issue #21, design doc Amendment 2026-07-11).
3//!
4//! ```text
5//!            DiffResult  (+ the two Runs)
6//!                     │
7//!                     ▼
8//!             amberfork-layout
9//!                     │
10//!                     ▼
11//!                 ViewModel
12//!        semantic rows (spine / fork / downstream), step summaries,
13//!        designed wording (confidence, verdict, absence), attribution
14//!        reading order, field-diff evidence
15//!                     │
16//!         ┌───────────┴───────────┐
17//!         ▼                       ▼
18//!   CLI painter (render.rs)   web painter (ui/, Leptos)
19//!   columns · glyphs · ANSI   SVG/DOM geometry
20//! ```
21//!
22//! What lives here is exactly what every surface must agree on: which alignment move plays
23//! which role, the one-line gist of a step, the wording rules the design locked — confidence
24//! as `conf 0.NN` with the explicit `marginal call` at zero (notebook 005), the converged
25//! verdict that only claims "identical" when the alignment earned it (issue #19), the
26//! `(no aligned step)` absence — plus the attribution reading order (DR5) and the
27//! field-level evidence at the fork.
28//!
29//! What deliberately does NOT live here is any painter's own arithmetic: column widths,
30//! truncation, wrapping, gutter glyphs, and ANSI are the CLI painter's business; pixel
31//! geometry is the web painter's. This crate has zero terminal dependencies, and styling
32//! decisions never feed back into it.
33//!
34//! For the web painter the view crosses a wire: [`Document`] is the serializable form the
35//! server ships (issue #24) — the same [`ViewModel`] plus a [`DOCUMENT_VERSION`] stamp and
36//! the payload envelope: building the document cuts every payload-derived slot at
37//! [`SLOT_TEXT_LIMIT`] bytes with an explicit [`SlotText::truncated`] mark the UI must
38//! render visibly.
39
40use 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
48// The model types embedded in the view-model's public fields (`StepView::kind`, `StepRow::kind`,
49// `RunHeader::outcome`, `ViewModel::warnings`) and named by the `*_label` helpers travel with it:
50// a consumer that reads a `ViewModel` must be able to name what it is made of. This completes the
51// view-model's own type surface — it is the contract, not a barrel re-export of the engine.
52pub use amberfork_model::{MoveKind, Outcome, StepKind, Warning};
53
54/// The design system's stylesheet — the single source both painters dress from.
55///
56/// It lives beside the view-model rather than in `ui/` for a packaging reason: `ui/` is a
57/// separate workspace, so `crates/amberfork/src/html_export.rs` could only reach it with an
58/// `include_str!` that escapes its own crate root, which builds in the repo and then fails
59/// `cargo package` (the tarball carries no path outside the crate). Owning it here makes it a
60/// normal dependency for the static export, while `ui/index.html` inlines the same bytes at
61/// build time via trunk — one stylesheet, no hand-copied second copy to drift.
62pub const UI_CSS: &str = include_str!("../ui.css");
63
64/// The document version this build emits. A bare wire-hygiene marker (issue #24): the web UI
65/// and the server ship in one binary, lockstep by construction, so there is no read-gate —
66/// bump it when the document's shape changes so a stale payload is at least identifiable.
67/// `0.2`: field-level evidence moved from `ForkRow` onto every [`AlignedStep`] (issue #27) so
68/// the content-diff pane can show any selected pair's diff, not only the fork's.
69pub const DOCUMENT_VERSION: &str = "0.2";
70
71/// The wire form of the seam: what `amberfork serve` ships to the web painter. The body is
72/// the SAME [`ViewModel`] the terminal paints — the document only adds wire hygiene on top.
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct Document {
75    /// Always [`DOCUMENT_VERSION`] when built by this crate.
76    pub schema_version: String,
77    pub view: ViewModel,
78}
79
80impl Document {
81    /// Wrap a view for the wire: stamp the current document version and enforce the payload
82    /// envelope. There is deliberately no other way to build a [`Document`], so an
83    /// over-limit payload can never reach the browser unmarked (issue #24).
84    #[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
94/// The payload envelope's per-slot byte limit. The threat is multi-MB field-diff payloads
95/// (eng review D12+D17): a slot is one display line, so 4 KiB loses nothing any pane shows
96/// unexpanded (expand-on-demand is issue #30's job) while keeping a worst-case document of
97/// hundreds of rows around a megabyte. Bytes, not chars, because the guard protects wire
98/// size; the cut backs off to a UTF-8 char boundary.
99pub const SLOT_TEXT_LIMIT: usize = 4096;
100
101/// Cut every payload-derived slot in the view down to [`SLOT_TEXT_LIMIT`], marking the cut and
102/// stamping a [`SlotAddress`] the served view can be asked to resolve back to full text with
103/// (issue #30). The fork adds its own resolved sides on top; the field-level evidence rides on
104/// the aligned step now, so [`envelope_step`] bounds it uniformly for every row.
105fn 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/// One payload-derived text slot. [`ViewModel::compute`] always produces the full text;
152/// only the payload envelope in [`Document::new`] sets `truncated` — so the CLI painter,
153/// which reads the view directly, never sees a cut slot. Generated wording (verdicts,
154/// confidence, attribution, warnings) is bounded by construction and stays plain `String`.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct SlotText {
157    pub text: String,
158    /// Whether the envelope cut this slot at [`SLOT_TEXT_LIMIT`]. A UI must render the
159    /// mark visibly — a silently shortened payload reads as the payload.
160    #[serde(default, skip_serializing_if = "is_false")]
161    pub truncated: bool,
162    /// Where to find this slot's full text again (issue #30: expand-on-demand). `Some` iff
163    /// `truncated` — an unmarked slot already carries its full text, so it needs no address.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub address: Option<SlotAddress>,
166}
167
168impl SlotText {
169    /// A full, unmarked slot — the only form [`ViewModel::compute`] emits.
170    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    /// Cut to the largest char boundary within `limit` bytes and mark the cut, stamping
184    /// where the full text can be found again. `address` is a closure rather than a value so
185    /// building one (a field-diff address clones a path string) costs nothing on the
186    /// overwhelmingly common under-limit path.
187    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/// Which of a row's two runs a slot belongs to — the `DiffResult` side convention (`a` is the
202/// reference, `b` the observed run), reused here rather than a second vocabulary.
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "lowercase")]
205pub enum Side {
206    A,
207    B,
208}
209
210/// Which slot within a row's [`AlignedStep`] (or [`ForkRow`]) a [`SlotAddress`] names.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(rename_all = "snake_case")]
213pub enum SlotKind {
214    /// [`StepView::summary`] on the named side.
215    StepSummary { side: Side },
216    /// [`ForkRow::side_a`] / `side_b`.
217    ForkSide { side: Side },
218    /// [`FieldDiffView::removed`] at the named field path.
219    FieldRemoved { path: String },
220    /// [`FieldDiffView::added`] at the named field path.
221    FieldAdded { path: String },
222}
223
224/// A stable pointer to one payload-derived slot, good for the life of one `Document` (rows
225/// are positional, so this does not survive a re-diff). Round-trips over the wire so the web
226/// painter can hand one straight back to the server to ask for the full text (issue #30).
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct SlotAddress {
229    /// Index into [`ViewModel::rows`].
230    pub row: usize,
231    pub kind: SlotKind,
232}
233
234/// The wire form of one resolved [`SlotAddress`] (issue #30) — deliberately just the text, no
235/// echo of the address or the `truncated` marker: the caller already has both from the
236/// document it read the address off, so restating them here would only invite the two copies
237/// to drift. Shared by `amberfork-server`'s handler and the web painter's fetch, the same way
238/// [`Document`] is — one wire contract, not one redeclared per consumer.
239#[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
250/// Content comparison — the mark is provenance, not content.
251impl 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/// The full semantic view of one diff: everything a painter needs, nothing it must compute.
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263pub struct ViewModel {
264    pub run_a: RunHeader,
265    pub run_b: RunHeader,
266    /// Digits for zero-padded step indices (`step 06`), derived from the runs' largest step
267    /// index — shared number-formatting voice, not a column width (those are data-derived,
268    /// these are medium-derived; only the former belong in the seam).
269    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    /// Latency / token deltas between the two runs (issue #40), already formatted in the
275    /// designed wording. `None` when [`DiffResult::deltas`] is `None` (nothing to show), not
276    /// when a delta happens to be zero — a real zero still renders `+0ms`.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub deltas: Option<DeltasView>,
279    /// Pass-through of the result's warnings so a painter never reaches back into
280    /// [`DiffResult`]: the CLI keeps warnings on stderr, the web UI surfaces them inline.
281    pub warnings: Vec<Warning>,
282}
283
284/// A run's identity as every surface introduces it (terminal header lines, web header bar).
285#[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/// Which seat a run occupies in the diff — the `DiffResult` contract's side convention:
295/// `a` is always the reference, `b` the observed/failing run.
296#[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    /// The designed label every surface prints.
305    pub fn label(self) -> &'static str {
306        match self {
307            Self::Reference => "reference",
308            Self::Observed => "observed",
309        }
310    }
311}
312
313/// One alignment move as a display row.
314#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
315#[serde(rename_all = "lowercase")]
316pub enum Row {
317    /// A regular move: spine before the fork, divergent path after it.
318    Step(StepRow),
319    /// The fork itself, with both sides' content and the field-level evidence.
320    Fork(ForkRow),
321}
322
323impl Row {
324    /// The aligned pair behind the row, whichever variant it is.
325    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/// Where a non-fork row stands relative to the fork — the role that drives every painter's
334/// styling (sameness recedes, divergence glows).
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(rename_all = "lowercase")]
337pub enum RowRole {
338    /// Before the fork, or every row of a converged diff. The eye skates over it.
339    Spine,
340    /// After the fork: the divergent path stays marked.
341    Downstream,
342}
343
344#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
345pub struct StepRow {
346    pub role: RowRole,
347    /// The move's alignment kind; painters frame it as the row tag (the CLI brackets it).
348    pub kind: MoveKind,
349    pub step: AlignedStep,
350}
351
352/// The fork row: the first move of the block the alignment never recovers from.
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
354pub struct ForkRow {
355    pub step: AlignedStep,
356    /// Reference-side content at the fork, resolved to the designed absence wording
357    /// (`(no aligned step)`) when the fork has no step on that side.
358    pub side_a: SlotText,
359    /// Observed-side counterpart of `side_a`.
360    pub side_b: SlotText,
361    /// Designed confidence wording: `conf 0.NN`, or `marginal call` at zero (notebook 005).
362    pub confidence: String,
363}
364
365/// The two sides of one alignment move, resolved for display. Indices are kept separate
366/// from the resolved views: a gap move has an index on one side only, and a painter still
367/// shows the index even where (malformed hand-built input) it resolves to no step.
368#[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    /// Field-level `-`/`+` evidence for this synchronous pair — the only red/green in any
379    /// surface. Empty when the payloads matched (a recede-worthy sync) or the move has no
380    /// counterpart to compare (a log/model gap move); the engine emits it for every sync,
381    /// so it rides the pair, not the fork alone (issue #27). The content-diff pane shows
382    /// the selected row's; the terminal renders only the fork's.
383    #[serde(default, skip_serializing_if = "Vec::is_empty")]
384    pub field_diffs: Vec<FieldDiffView>,
385}
386
387impl AlignedStep {
388    /// The step fronting a single-line rendering: the observed side where it exists — that
389    /// is the run being debugged — the reference side for model-only moves.
390    pub fn front(&self) -> Option<&StepView> {
391        self.b.as_ref().or(self.a.as_ref())
392    }
393
394    /// The index shown in the gutter, matching [`Self::front`]'s side priority.
395    pub fn display_idx(&self) -> Option<usize> {
396        self.b_idx.or(self.a_idx)
397    }
398}
399
400/// One side's step, resolved to what surfaces display.
401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
402pub struct StepView {
403    pub kind: StepKind,
404    pub name: String,
405    /// One-line gist: first line of the step's output (else input) text, compact JSON for
406    /// structured payloads, or the designed `(no content captured)`.
407    pub summary: SlotText,
408}
409
410/// One field-level difference at the fork, values already in display form (compact JSON).
411/// `removed`/`added` are what a painter shows on the `-`/`+` side; an added field has no
412/// `removed`, a removed field no `added`.
413#[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/// The one-line answer every diff ends with.
423#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
424#[serde(rename_all = "lowercase")]
425pub enum Verdict {
426    /// Every move a sync at cost 0 — a claim the alignment must earn (issue #19).
427    Identical {
428        steps: usize,
429    },
430    /// No fork, but the alignment absorbed divergence (gap moves, costly syncs) on the way.
431    Absorbed {
432        absorbed: usize,
433        a_steps: usize,
434        b_steps: usize,
435    },
436    Forked,
437}
438
439impl Verdict {
440    /// The designed converged statement; `None` when forked — the fork row and attribution
441    /// carry the answer instead.
442    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/// The attribution answer in DR5's reading order — mode, origin, propagation, confidence, then
461/// the counterfactual verdict when there is one — each part already in its designed wording. The
462/// terminal flattens the parts to one line; the web attribution pane renders them as separate
463/// elements.
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
465pub struct AttributionView {
466    /// `static` or `counterfactual` — owned, not `&'static str`, because the view must
467    /// deserialize on the web side of the wire.
468    pub mode: String,
469    /// `origin step 02` (gutter-padded) or `origin unlocalized`.
470    pub origin: String,
471    /// `step 03`, a contiguous `steps 03–07`, a comma list, or `none`.
472    pub propagation: String,
473    pub confidence: String,
474    /// The counterfactual recovery verdict — `recovered · 3 runs`, `not recovered · 3 runs`, or
475    /// `unverified · 3 runs` — present only when re-execution produced evidence. `None` for static
476    /// attribution, whose wire form and terminal line are therefore unchanged.
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub verdict: Option<String>,
479}
480
481/// Latency / token deltas, already in the designed wording (`+5.20s`, `-300 tok`) so no
482/// painter reinvents the sign/units convention. Mirrors [`AttributionView`]'s split: the
483/// terminal flattens the parts to one line, the web pane can lay them out as separate elements.
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
485pub struct DeltasView {
486    /// Whole-run totals, when at least one of latency/tokens has data on both sides.
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub total: Option<String>,
489    /// The delta at just the diverging step, when the fork lands on a synchronous pair.
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub at_fork: Option<String>,
492}
493
494impl ViewModel {
495    /// Build the semantic view. Pure and total: any `DiffResult` over its two runs yields a
496    /// view, and nothing here inspects the output medium.
497    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                    // The field-level evidence rides on the pair, so every sync row carries its
518                    // own — the content-diff pane reads the selected row's, not only the fork's.
519                    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                // "identical" is a claim the alignment must earn: every move a sync at cost
546                // 0. Anything the resync rule merely absorbed (gap moves, costly syncs)
547                // converged without being identical (issue #19).
548                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    /// Resolve a [`SlotAddress`] back to full text (issue #30: expand-on-demand). Only
593    /// meaningful called on a pre-envelope view — [`ViewModel::compute`]'s own output, never a
594    /// [`Document`]'s already-truncated one — so the caller must hold onto that view itself;
595    /// nothing here re-derives it. `None` means the address does not resolve against this view
596    /// (a stale address from a different diff, a malformed request), not that the slot is empty.
597    #[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    /// The one-line verdict headline every surface opens with: `serve`'s terminal handoff
637    /// and the web header print the SAME string (issue #25 amendment), so it lives here
638    /// with the rest of the designed wording, not in a painter.
639    ///
640    /// Forked: `⑂ forked at step 02 · conf 0.86` — index padded like the gutter, confidence
641    /// in the fork row's designed wording (so `marginal call` composes for free).
642    /// Converged: exactly [`Verdict::converged_text`].
643    pub fn headline(&self) -> String {
644        if let Some(text) = self.verdict.converged_text() {
645            return text;
646        }
647        // Forked: compute() emits exactly one fork row alongside a Forked verdict; the
648        // bare-claim fallback keeps this total for hand-built views (never panics).
649        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            // The gutter's absence convention (a fork row with no index on either side).
659            None => "·".repeat(self.idx_width),
660        };
661        format!("⑂ forked at step {idx} · {}", fork.confidence)
662    }
663}
664
665/// The canonical step-kind vocabulary every surface prints.
666pub 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
675/// The canonical move-kind vocabulary; painters add their own framing (the CLI brackets it
676/// into `[sync]`, the web renders a chip).
677pub 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
685/// The canonical outcome vocabulary.
686pub 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
694/// Confidence per notebook 005: zero — the designed weak-call state (evidence ≤ τ) — is
695/// stated in words, never rendered as a small number.
696pub 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
704/// One side of an alignment move, where its index resolves to a real step.
705fn 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
713/// Fork-side content: the step's summary, empty where an index resolves to no step, the
714/// designed absence wording where the fork has no step on that side at all.
715fn 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
722/// The field-level evidence attached to the fork's alignment index, values in display form.
723fn 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        // Keyed on the evidence, not the mode: a verdict appears iff re-execution produced one, so
758        // static attribution stays verdict-free and its rendering is untouched.
759        verdict: attribution.counterfactual.as_ref().map(verdict_text),
760    }
761}
762
763/// The counterfactual verdict wording: the recovery outcome and the run count that backs it —
764/// `recovered · 3 runs`, `not recovered · 1 run`, `unverified · 3 runs`. The tri-state maps
765/// one-to-one to [`Recovery`], so `unverified` reads as the honest "we re-ran but the runs did not
766/// agree", never as a silent absence.
767fn 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
778/// Resolve a [`ResourceDeltas`] to its designed wording, or `None` when neither granularity
779/// has anything to say (both fields empty on both `total` and `at_fork`).
780fn 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
786/// One measurement's designed wording: `+5.20s`, `-300 tok`, or both joined — `None` when
787/// neither latency nor tokens is present on this delta.
788fn 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
799/// A millisecond delta, signed: sub-second deltas stay in `ms` (`+120ms`) so a fast local step
800/// doesn't round to `+0.00s`; anything at or past a second switches to `s` (`+5.20s`).
801fn 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
811/// A token-count delta, signed: `+300 tok`, `-40 tok`.
812fn token_text(tokens: i64) -> String {
813    let sign = if tokens < 0 { '-' } else { '+' };
814    format!("{sign}{} tok", tokens.unsigned_abs())
815}
816
817/// A step list in the gutter's zero-padded style: `none`, `step 03`, a contiguous
818/// `steps 03–07`, or a comma list when a future mode emits gaps.
819fn 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
833/// One-line gist of a step: its output (else input) text, first line only.
834fn 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    // Field lists live in amberfork-model's test-support builders (issue #22); these one-line
862    // adapters keep call sites in the shape the assertions read.
863
864    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    /// Forked pair: two clean syncs, a high-cost sync (the fork), one log move downstream,
899    /// with field diffs and a static attribution attached at the fork.
900    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        // The observed run fronts the row; both sides stay available for the web columns.
997        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                // Static attribution carries no counterfactual evidence, so no verdict — the
1015                // reading order and wire form are unchanged from before re-execution existed.
1016                verdict: None,
1017            })
1018        );
1019    }
1020
1021    /// A counterfactual attribution is the same reading order plus the recovery verdict as a
1022    /// distinct part — the terminal appends it, the web pane will render it separately.
1023    #[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    /// Every `Recovery` state has its own honest wording, and the run count is pluralized — so a
1042    /// single-run verify never reads `1 runs` and `unverified` never reads as a silent absence.
1043    #[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        // The content-diff pane shows the SELECTED row's field diff, so the evidence must ride
1148        // every sync pair, not only the fork (issue #27). A spine sync whose payloads match
1149        // stays empty (a recede-worthy pair); a divergent sync downstream of the fork surfaces
1150        // its own diff — proof the layout no longer discards what the engine already computes.
1151        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        // Two costly syncs after a clean one: index 1 is the fork, index 2 the divergent
1170        // downstream pair.
1171        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        // The engine keys field diffs by alignment index; here we inject the two divergent
1183        // pairs it would emit (the identical pair 0 emits nothing, by construction).
1184        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        // A model move exists only on the reference (a) side; the front and the gutter
1243        // index must follow it there.
1244        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        // The absorbed distinction (issue #19) must survive the wire too — the web surface
1396        // may never flatten "converged with absorbed divergence" into "identical".
1397        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        // The same over-limit payload feeds side_a and the a-side summary; both arrive cut,
1444        // each with its OWN address (issue #30) — they are two distinct slots, not aliases.
1445        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        // The marker itself survives the wire.
1486        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        // Issue #30: the server keeps ViewModel::compute's own (pre-envelope, full-fidelity)
1493        // output around so it can answer an expand-on-demand request. Every address stamped
1494        // by envelope() on the served Document must resolve against that full view.
1495        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        // The full view is exactly what a server would hold onto before calling Document::new.
1513        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        // side_a/side_b and both step summaries carry the raw step text verbatim.
1520        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        // Field-diff slots hold the value in its display form (compact JSON) — a JSON string
1548        // literal, quotes included — not the bare payload text.
1549        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        // Row out of range.
1580        assert_eq!(
1581            view.full_text(&SlotAddress {
1582                row: 99,
1583                kind: SlotKind::ForkSide { side: Side::A },
1584            }),
1585            None
1586        );
1587        // A step-row address asking for a fork-only slot.
1588        assert_eq!(
1589            view.full_text(&SlotAddress {
1590                row: 0,
1591                kind: SlotKind::ForkSide { side: Side::A },
1592            }),
1593            None
1594        );
1595        // A field path that was never in this row's diffs.
1596        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        // Two ASCII bytes then 4-byte chars: byte SLOT_TEXT_LIMIT lands mid-char, so the
1610        // cut must back off to the last boundary below it instead of panicking.
1611        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        // Nothing in the standard fixture is near the limit: no slot is marked, and the
1637        // lean wire never mentions the marker at all.
1638        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}