Skip to main content

xfa_layout_engine/trace/
mod.rs

1//! Decision-trace channel for XFA observability.
2//!
3//! This module is part of M1 (Observability Foundation). It exists so that
4//! XFA fidelity debugging can record *why* the engine made a particular
5//! layout decision, separate from *what* the resulting layout looks like
6//! (the latter is captured by [`crate::ir`]).
7//!
8//! ## Design constraints
9//!
10//! - **Off by default.** No global state. The trace channel is enabled
11//!   only inside a [`with_sink`] scope. When no sink is installed,
12//!   [`emit`] is a single thread-local read returning a `None` and
13//!   compiles to a small handful of instructions.
14//! - **No new dependencies.** Plain Rust, `std` only. No `tracing` or
15//!   `serde` involvement at this layer.
16//! - **Frozen vocabulary.** [`Phase`] and [`Reason`] are enums with
17//!   stable string tags; renames are breaking and require a taxonomy
18//!   bump (see migration policy in source comments).
19//! - **Determinism-friendly.** Events are accumulated in insertion order
20//!   inside a [`RecordingSink`], with no `HashMap`/`HashSet` involvement.
21//!
22//! ## Out of scope (M1 v1)
23//!
24//! - Wiring trace emit calls from the engine's hot phases (bind, occur,
25//!   presence, paginate, suppress) into production layout code paths.
26//!   v1 ships the taxonomy, the sink trait, and **demonstration emit
27//!   sites** under tests. Engine wiring lands in a follow-up wave that
28//!   is allowed to perturb existing call sites.
29//! - Streaming output to disk, file rotation, or telemetry. The diff CLI
30//!   in `xfa-test-runner` reads the in-memory `RecordingSink` events
31//!   serialised once at the end of a run.
32//! - JSON/Serde derives. Output is via the small `to_canonical_json`
33//!   helpers below.
34
35pub mod sites;
36
37use std::cell::RefCell;
38use std::fmt::Write;
39use std::rc::Rc;
40use std::sync::{Arc, Mutex, OnceLock};
41
42/// The phase in which a trace event was emitted.
43///
44/// ## Stability policy
45///
46/// Variants are added at the end of the enum to preserve existing tag
47/// strings. Renames or removals require a coordinated update across
48/// every consumer in the same change.
49#[non_exhaustive]
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
51pub enum Phase {
52    /// Data binding — matching template subforms to dataset records.
53    Bind,
54    /// Instance materialisation under `occur`.
55    Occur,
56    /// Presence resolution (`presence="visible|hidden|inactive|invisible"`).
57    Presence,
58    /// Measurement of intrinsic content (text width/height).
59    Measure,
60    /// Pagination decisions (page break, defer, suppress).
61    Paginate,
62    /// Page suppression (drop empty data-bound pages, etc.).
63    Suppress,
64    /// SOM resolution.
65    Resolve,
66    /// Final emit/serialize step.
67    Emit,
68    /// Pipeline-level fallback: the XFA flatten path could not produce
69    /// output and the caller has been served a non-XFA passthrough
70    /// instead. Emitted from `pdf-xfa::flatten` when the inner XFA
71    /// attempt errors or times out.
72    Fallback,
73}
74
75impl Phase {
76    /// Stable string tag; never rename without a taxonomy bump.
77    pub fn tag(self) -> &'static str {
78        match self {
79            Phase::Bind => "bind",
80            Phase::Occur => "occur",
81            Phase::Presence => "presence",
82            Phase::Measure => "measure",
83            Phase::Paginate => "paginate",
84            Phase::Suppress => "suppress",
85            Phase::Resolve => "resolve",
86            Phase::Emit => "emit",
87            Phase::Fallback => "fallback",
88        }
89    }
90}
91
92/// The reason behind a trace event — a closed vocabulary of decision codes.
93///
94/// ## Stability policy
95///
96/// Each variant has a stable lower-snake-case tag. Variants may be added
97/// to the end of the enum without ceremony. **Tags must never be renamed**
98/// because committed snapshots reference them; a rename is a taxonomy
99/// bump and forces a snapshot regeneration.
100#[non_exhaustive]
101#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
102pub enum Reason {
103    // --- Bind ---
104    /// Number of dataset records matched the template's `occur.initial`.
105    DataCountMatchesInitial,
106    /// Dataset record count was clamped to `occur.max`.
107    DataCountClampedByOccurMax,
108    /// Dataset record count was lifted to `occur.min`.
109    DataCountLiftedByOccurMin,
110
111    // --- Occur ---
112    /// Subform was materialised because data was present.
113    SubformMaterialisedFromData,
114    /// Subform was materialised because `occur.initial > 0`.
115    SubformMaterialisedFromInitial,
116    /// Subform instance was suppressed because no data was bound.
117    SubformSuppressedNoData,
118
119    // --- Presence ---
120    /// Node hidden by `presence="hidden"`.
121    PresenceHidden,
122    /// Node hidden by `presence="invisible"`.
123    PresenceInvisible,
124    /// Node hidden by `presence="inactive"`.
125    PresenceInactive,
126    /// Node visible by default or explicit `presence="visible"`.
127    PresenceVisible,
128
129    // --- Paginate ---
130    /// Container fits on the current page.
131    PaginateFitsCurrentPage,
132    /// Container deferred to next page because of `minH`/overflow.
133    PaginateDeferToNextPageMinH,
134    /// Container deferred because `keep` chain forbade splitting.
135    PaginateDeferToNextPageKeep,
136    /// Container split across pages.
137    PaginateSplit,
138
139    // --- Suppress ---
140    /// Empty data-bound page was dropped because real data binding was present elsewhere.
141    SuppressEmptyDataPageDropped,
142    /// Page suppression was capped by the form-DOM's page area count.
143    SuppressCappedByFormDom,
144    /// Static `bind=none` page was preserved.
145    SuppressStaticBindNonePreserved,
146    /// `suppress_empty_pages_only_when_real_data_bound` ran and decided
147    /// the data-bound signal is present, so the suppression heuristic
148    /// is allowed to run.
149    SuppressGatedByDataBoundSignal,
150    /// `suppress_empty_pages_only_when_real_data_bound` ran and decided
151    /// no real data binding occurred, so the suppression heuristic stays
152    /// off (e.g. blank-form output preserves all template pages).
153    SuppressSkippedNoDataBoundSignal,
154    /// `exclude_bind_none_fields_from_page_data_suppression`: at least
155    /// one field with `<bind match="none">` was excluded from the page
156    /// data-field count. Emitted at most once per flatten with the
157    /// exclusion count attached to the decision string.
158    BindNoneFieldExcludedFromDataCheck,
159    /// `static_xfaf_excess_page_trim_with_form_dom_guard`: static XFAF
160    /// surplus host pages may be trimmed under the form-DOM guard.
161    StaticXfafTrimAllowed,
162    /// `static_xfaf_excess_page_trim_with_form_dom_guard`: the form DOM
163    /// (or the dynamic-template hint) prevented the static-trim path
164    /// from firing.
165    StaticXfafTrimBlocked,
166    /// `ignore_invisible_server_metadata_bindings_for_data_bound_signal`:
167    /// at least one binding on an invisible / hidden / inactive field
168    /// was observed and ignored for purposes of the global data-bound
169    /// signal. Emitted at most once per merge with the total ignored
170    /// count attached to the decision string.
171    InvisibleFieldBindingIgnored,
172    /// `exclude_non_data_widgets_from_page_suppression`: at least one
173    /// signature / button / barcode widget was excluded from the
174    /// per-page data-field count. Emitted at most once per flatten
175    /// with the total exclusion count attached.
176    NonDataWidgetExcludedFromDataCheck,
177    /// `bind_none_subform_does_not_auto_expand`: a subform with
178    /// `<bind match="none">` was preserved as a single template
179    /// instance instead of being auto-expanded from datasets, even
180    /// though its `<occur>` would otherwise allow repetition
181    /// (XFA §4.4.3). Emitted per repeating named subform when the
182    /// rule actively blocks expansion.
183    BindNoneSubformExpansionSkipped,
184
185    // --- Resolve / Measure / Emit (placeholders for follow-up waves) ---
186    /// Generic SOM lookup miss; node not found.
187    ResolveLookupMiss,
188    /// Text measurement produced a single line.
189    MeasureSingleLine,
190    /// Text measurement produced a wrapped multi-line block.
191    MeasureWrapped,
192    /// Final emit completed for a node without remarks.
193    EmitOk,
194
195    // --- Fallback ---
196    /// `pdf-xfa::flatten` caught an inner-pipeline error (or
197    /// timeout) and served a non-XFA passthrough to the caller via
198    /// `static_fallback`. Carries a short decision summary (the
199    /// underlying error or "timeout"). Critical signal for fidelity
200    /// gates: a flatten that returns `Ok` *with* this event is not
201    /// real XFA output.
202    StaticFallbackTaken,
203
204    // --- Generic ---
205    /// Unspecified reason; for callers without a more precise tag.
206    Unspecified,
207}
208
209impl Reason {
210    /// Stable string tag; never rename without a taxonomy bump.
211    pub fn tag(self) -> &'static str {
212        match self {
213            Reason::DataCountMatchesInitial => "data_count_matches_initial",
214            Reason::DataCountClampedByOccurMax => "data_count_clamped_by_occur_max",
215            Reason::DataCountLiftedByOccurMin => "data_count_lifted_by_occur_min",
216            Reason::SubformMaterialisedFromData => "subform_materialised_from_data",
217            Reason::SubformMaterialisedFromInitial => "subform_materialised_from_initial",
218            Reason::SubformSuppressedNoData => "subform_suppressed_no_data",
219            Reason::PresenceHidden => "presence_hidden",
220            Reason::PresenceInvisible => "presence_invisible",
221            Reason::PresenceInactive => "presence_inactive",
222            Reason::PresenceVisible => "presence_visible",
223            Reason::PaginateFitsCurrentPage => "paginate_fits_current_page",
224            Reason::PaginateDeferToNextPageMinH => "paginate_defer_to_next_page_min_h",
225            Reason::PaginateDeferToNextPageKeep => "paginate_defer_to_next_page_keep",
226            Reason::PaginateSplit => "paginate_split",
227            Reason::SuppressEmptyDataPageDropped => "suppress_empty_data_page_dropped",
228            Reason::SuppressCappedByFormDom => "suppress_capped_by_form_dom",
229            Reason::SuppressStaticBindNonePreserved => "suppress_static_bind_none_preserved",
230            Reason::SuppressGatedByDataBoundSignal => "suppress_gated_by_data_bound_signal",
231            Reason::SuppressSkippedNoDataBoundSignal => "suppress_skipped_no_data_bound_signal",
232            Reason::BindNoneFieldExcludedFromDataCheck => {
233                "bind_none_field_excluded_from_data_check"
234            }
235            Reason::StaticXfafTrimAllowed => "static_xfaf_trim_allowed",
236            Reason::StaticXfafTrimBlocked => "static_xfaf_trim_blocked",
237            Reason::InvisibleFieldBindingIgnored => "invisible_field_binding_ignored",
238            Reason::NonDataWidgetExcludedFromDataCheck => {
239                "non_data_widget_excluded_from_data_check"
240            }
241            Reason::BindNoneSubformExpansionSkipped => "bind_none_subform_expansion_skipped",
242            Reason::ResolveLookupMiss => "resolve_lookup_miss",
243            Reason::MeasureSingleLine => "measure_single_line",
244            Reason::MeasureWrapped => "measure_wrapped",
245            Reason::EmitOk => "emit_ok",
246            Reason::StaticFallbackTaken => "static_fallback_taken",
247            Reason::Unspecified => "unspecified",
248        }
249    }
250}
251
252/// One trace event.
253///
254/// Optional fields use `None` rather than empty strings so the canonical
255/// JSON output emits `null` cleanly. Numeric `input` and `output` summaries
256/// are constrained to `i64` to stay deterministic across platforms.
257#[derive(Debug, Clone, PartialEq)]
258pub struct TraceEvent {
259    /// Phase in which the event fired.
260    pub phase: Phase,
261    /// Reason describing the decision.
262    pub reason: Reason,
263    /// Optional SOM path of the node the decision applies to.
264    pub som: Option<String>,
265    /// Optional human-readable input summary (e.g. `"available_h=267.3"`).
266    pub input: Option<String>,
267    /// Optional human-readable decision summary (e.g. `"defer_to_next_page"`).
268    pub decision: Option<String>,
269    /// Optional source-module hint (e.g. `"layout::paginate"`); cheap to
270    /// produce and useful when grepping traces.
271    pub source: Option<String>,
272}
273
274impl TraceEvent {
275    /// Construct a minimal event with just phase + reason.
276    pub fn new(phase: Phase, reason: Reason) -> Self {
277        Self {
278            phase,
279            reason,
280            som: None,
281            input: None,
282            decision: None,
283            source: None,
284        }
285    }
286
287    /// Builder: set the SOM path.
288    pub fn with_som(mut self, som: impl Into<String>) -> Self {
289        self.som = Some(som.into());
290        self
291    }
292
293    /// Builder: set the input summary.
294    pub fn with_input(mut self, input: impl Into<String>) -> Self {
295        self.input = Some(input.into());
296        self
297    }
298
299    /// Builder: set the decision summary.
300    pub fn with_decision(mut self, decision: impl Into<String>) -> Self {
301        self.decision = Some(decision.into());
302        self
303    }
304
305    /// Builder: set the source-module hint.
306    pub fn with_source(mut self, source: impl Into<String>) -> Self {
307        self.source = Some(source.into());
308        self
309    }
310}
311
312/// Implemented by anything that wants to receive trace events.
313///
314/// Implementations must be cheap when called; emit sites in the engine
315/// are designed for `~one indirect call` overhead when a sink is
316/// installed.
317pub trait Sink {
318    /// Receive a single event. Implementations should not perform I/O
319    /// or take locks that risk contention with engine work.
320    fn record(&self, event: TraceEvent);
321}
322
323/// A no-op sink. Default when no sink is installed.
324#[derive(Debug, Clone, Copy, Default)]
325pub struct NoopSink;
326
327impl Sink for NoopSink {
328    fn record(&self, _event: TraceEvent) {}
329}
330
331/// A simple recording sink that accumulates events in insertion order.
332///
333/// Backed by a `Mutex<Vec<TraceEvent>>` so the sink is `Send + Sync` and
334/// can be shared across threads via `Arc<RecordingSink>` and installed
335/// through [`with_global_sink`] / [`set_global_sink`]. The single-thread
336/// `Rc<RecordingSink>` path through [`with_sink`] continues to work and
337/// pays only the Mutex acquisition cost when an event is recorded
338/// (still zero-cost when no sink is installed).
339#[derive(Debug, Default)]
340pub struct RecordingSink {
341    events: Mutex<Vec<TraceEvent>>,
342}
343
344impl RecordingSink {
345    /// Create a fresh recording sink.
346    pub fn new() -> Self {
347        Self::default()
348    }
349
350    /// Snapshot the recorded events.
351    pub fn events(&self) -> Vec<TraceEvent> {
352        self.events
353            .lock()
354            .expect("trace sink mutex poisoned")
355            .clone()
356    }
357
358    /// Number of events recorded so far.
359    pub fn len(&self) -> usize {
360        self.events.lock().expect("trace sink mutex poisoned").len()
361    }
362
363    /// Whether no events have been recorded.
364    pub fn is_empty(&self) -> bool {
365        self.events
366            .lock()
367            .expect("trace sink mutex poisoned")
368            .is_empty()
369    }
370
371    /// Render all events to canonical JSON.
372    pub fn to_canonical_json(&self) -> String {
373        events_to_canonical_json(&self.events.lock().expect("trace sink mutex poisoned"))
374    }
375}
376
377impl Sink for RecordingSink {
378    fn record(&self, event: TraceEvent) {
379        self.events
380            .lock()
381            .expect("trace sink mutex poisoned")
382            .push(event);
383    }
384}
385
386// --- Thread-local sink slot --------------------------------------------------
387//
388// The current sink is stored as an `Rc<dyn Sink>` per thread. Installing a
389// sink is a clone of the `Rc`; emission is one thread-local read plus a
390// single virtual call. When no sink is installed, emission costs one
391// thread-local read and an `Option::is_some` branch.
392
393thread_local! {
394    static CURRENT_SINK: RefCell<Option<Rc<dyn Sink>>> = const { RefCell::new(None) };
395}
396
397/// Install `sink` for the duration of `f`, then restore the previous sink.
398///
399/// Pass `sink` by value (typically `Rc::new(RecordingSink::new())` or a
400/// clone of an existing `Rc`); the caller can keep their own `Rc` to read
401/// recorded events after the scope ends.
402///
403/// Panics inside `f` propagate; the previous sink is restored via `Drop`
404/// of an internal guard. Recursive emission inside `Sink::record` is
405/// undefined behaviour: do not call [`emit`] from inside a sink.
406pub fn with_sink<R>(sink: Rc<dyn Sink>, f: impl FnOnce() -> R) -> R {
407    let prev = CURRENT_SINK.with(|cell| cell.borrow_mut().replace(sink));
408
409    struct Guard {
410        prev: Option<Rc<dyn Sink>>,
411    }
412    impl Drop for Guard {
413        fn drop(&mut self) {
414            CURRENT_SINK.with(|cell| {
415                *cell.borrow_mut() = self.prev.take();
416            });
417        }
418    }
419    let _guard = Guard { prev };
420    f()
421}
422
423// --- Global (cross-thread) sink slot ----------------------------------------
424//
425// The thread-local slot above covers in-thread tracing. Some engine code
426// paths (notably `pdf_xfa::flatten_xfa_to_pdf`) run inside a
427// `std::thread::spawn`ed worker, so a thread-local sink installed by the
428// caller is invisible to that worker. The global slot below is an
429// `Arc<dyn Sink + Send + Sync>` that any thread can reach. The trade-off
430// is one extra atomic-loadable lock on every emit when a global sink is
431// installed.
432
433type GlobalSink = Arc<dyn Sink + Send + Sync>;
434
435fn global_slot() -> &'static Mutex<Option<GlobalSink>> {
436    static SLOT: OnceLock<Mutex<Option<GlobalSink>>> = OnceLock::new();
437    SLOT.get_or_init(|| Mutex::new(None))
438}
439
440/// Install a `Send + Sync` sink in the cross-thread global slot.
441///
442/// Replaces any previously installed global sink. Pass an `Arc` cloned
443/// from the caller-owned instance so the caller can still read recorded
444/// events.
445pub fn set_global_sink(sink: GlobalSink) {
446    *global_slot().lock().expect("trace global sink poisoned") = Some(sink);
447}
448
449/// Clear the global sink slot.
450pub fn clear_global_sink() {
451    *global_slot().lock().expect("trace global sink poisoned") = None;
452}
453
454/// Install `sink` in the global slot for the duration of `f`, then restore
455/// the previous global sink. Emits from any thread (including worker
456/// threads `f` spawns) reach this sink. Single-thread callers should prefer
457/// the cheaper [`with_sink`] API.
458pub fn with_global_sink<R>(sink: GlobalSink, f: impl FnOnce() -> R) -> R {
459    let prev = {
460        let mut guard = global_slot().lock().expect("trace global sink poisoned");
461        guard.replace(sink)
462    };
463
464    struct Guard {
465        prev: Option<GlobalSink>,
466    }
467    impl Drop for Guard {
468        fn drop(&mut self) {
469            *global_slot().lock().expect("trace global sink poisoned") = self.prev.take();
470        }
471    }
472    let _guard = Guard { prev };
473    f()
474}
475
476/// Emit one event to the current thread-local sink, if any, and then to
477/// the global sink, if any. Cheap when neither is installed: one
478/// thread-local read, one atomic-protected lock read, and two
479/// `Option::is_some` checks.
480pub fn emit(event: TraceEvent) {
481    // Thread-local has priority for cheap single-threaded callers.
482    let mut consumed = false;
483    CURRENT_SINK.with(|cell| {
484        if let Some(sink) = cell.borrow().as_ref() {
485            sink.record(event.clone());
486            consumed = true;
487        }
488    });
489    // Global slot fires on every emit when set. This also catches emits
490    // from worker threads where the thread-local slot is None.
491    if let Ok(guard) = global_slot().lock() {
492        if let Some(sink) = guard.as_ref() {
493            // If both thread-local and global are set on the same thread,
494            // we still emit to the global sink — tests installing a global
495            // for cross-thread coverage want to see every event.
496            let _ = consumed;
497            sink.record(event);
498        }
499    }
500}
501
502/// Convenience: emit a `Phase + Reason` event with no extra fields.
503pub fn emit_simple(phase: Phase, reason: Reason) {
504    emit(TraceEvent::new(phase, reason));
505}
506
507/// Render a slice of events to canonical JSON (alphabetical keys, fixed
508/// ordering, two-space indent).
509pub fn events_to_canonical_json(events: &[TraceEvent]) -> String {
510    let mut out = String::new();
511    out.push_str("[\n");
512    let last = events.len().saturating_sub(1);
513    for (i, e) in events.iter().enumerate() {
514        out.push_str("  {\n");
515        write_kv_optional(&mut out, "decision", e.decision.as_deref());
516        write_kv_optional(&mut out, "input", e.input.as_deref());
517        write_kv(&mut out, "phase", e.phase.tag());
518        write_kv(&mut out, "reason", e.reason.tag());
519        write_kv_optional(&mut out, "som", e.som.as_deref());
520        write_kv_optional_last(&mut out, "source", e.source.as_deref());
521        out.push_str("  }");
522        if i != last {
523            out.push(',');
524        }
525        out.push('\n');
526    }
527    out.push_str("]\n");
528    out
529}
530
531fn write_json_string(out: &mut String, s: &str) {
532    out.push('"');
533    for c in s.chars() {
534        match c {
535            '"' => out.push_str("\\\""),
536            '\\' => out.push_str("\\\\"),
537            '\n' => out.push_str("\\n"),
538            '\r' => out.push_str("\\r"),
539            '\t' => out.push_str("\\t"),
540            '\u{08}' => out.push_str("\\b"),
541            '\u{0C}' => out.push_str("\\f"),
542            c if (c as u32) < 0x20 => {
543                let _ = write!(out, "\\u{:04x}", c as u32);
544            }
545            c => out.push(c),
546        }
547    }
548    out.push('"');
549}
550
551fn write_kv(out: &mut String, key: &str, value: &str) {
552    out.push_str("    \"");
553    out.push_str(key);
554    out.push_str("\": ");
555    write_json_string(out, value);
556    out.push_str(",\n");
557}
558
559fn write_kv_optional(out: &mut String, key: &str, value: Option<&str>) {
560    out.push_str("    \"");
561    out.push_str(key);
562    out.push_str("\": ");
563    match value {
564        Some(s) => write_json_string(out, s),
565        None => out.push_str("null"),
566    }
567    out.push_str(",\n");
568}
569
570fn write_kv_optional_last(out: &mut String, key: &str, value: Option<&str>) {
571    out.push_str("    \"");
572    out.push_str(key);
573    out.push_str("\": ");
574    match value {
575        Some(s) => write_json_string(out, s),
576        None => out.push_str("null"),
577    }
578    out.push('\n');
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn no_sink_means_no_record() {
587        // emit() outside any with_sink scope must be a no-op (and not panic).
588        emit_simple(Phase::Bind, Reason::Unspecified);
589    }
590
591    #[test]
592    fn recording_sink_collects_events() {
593        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
594        with_sink(sink.clone(), || {
595            emit_simple(Phase::Bind, Reason::DataCountMatchesInitial);
596            emit(TraceEvent::new(Phase::Paginate, Reason::PaginateSplit).with_som("form1.subform"));
597        });
598        assert_eq!(sink.len(), 2);
599        let evs = sink.events();
600        assert_eq!(evs[0].phase, Phase::Bind);
601        assert_eq!(evs[1].som.as_deref(), Some("form1.subform"));
602    }
603
604    #[test]
605    fn nested_with_sink_restores() {
606        let outer: Rc<RecordingSink> = Rc::new(RecordingSink::new());
607        let inner: Rc<RecordingSink> = Rc::new(RecordingSink::new());
608        with_sink(outer.clone(), || {
609            emit_simple(Phase::Bind, Reason::Unspecified);
610            with_sink(inner.clone(), || {
611                emit_simple(Phase::Paginate, Reason::PaginateSplit);
612            });
613            emit_simple(Phase::Suppress, Reason::SuppressEmptyDataPageDropped);
614        });
615        assert_eq!(outer.len(), 2);
616        assert_eq!(inner.len(), 1);
617        assert_eq!(inner.events()[0].phase, Phase::Paginate);
618    }
619
620    #[test]
621    fn canonical_json_is_byte_stable() {
622        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
623        with_sink(sink.clone(), || {
624            emit(
625                TraceEvent::new(Phase::Bind, Reason::DataCountMatchesInitial)
626                    .with_som("a")
627                    .with_input("n=3")
628                    .with_decision("ok"),
629            );
630            emit_simple(Phase::Paginate, Reason::PaginateSplit);
631        });
632        let a = sink.to_canonical_json();
633        let b = sink.to_canonical_json();
634        assert_eq!(a, b);
635    }
636
637    #[test]
638    fn phase_tags_are_stable() {
639        // This test exists to lock the public string vocabulary. Editing
640        // any of these RHS strings is a taxonomy bump and requires a
641        // coordinated update across snapshots.
642        assert_eq!(Phase::Bind.tag(), "bind");
643        assert_eq!(Phase::Occur.tag(), "occur");
644        assert_eq!(Phase::Presence.tag(), "presence");
645        assert_eq!(Phase::Measure.tag(), "measure");
646        assert_eq!(Phase::Paginate.tag(), "paginate");
647        assert_eq!(Phase::Suppress.tag(), "suppress");
648        assert_eq!(Phase::Resolve.tag(), "resolve");
649        assert_eq!(Phase::Emit.tag(), "emit");
650    }
651
652    #[test]
653    fn reason_tags_are_stable_subset() {
654        // Lock the five M1.6 hot-phase reasons explicitly.
655        assert_eq!(
656            Reason::DataCountMatchesInitial.tag(),
657            "data_count_matches_initial"
658        );
659        assert_eq!(
660            Reason::SubformMaterialisedFromData.tag(),
661            "subform_materialised_from_data"
662        );
663        assert_eq!(Reason::PresenceVisible.tag(), "presence_visible");
664        assert_eq!(Reason::PaginateSplit.tag(), "paginate_split");
665        assert_eq!(
666            Reason::SuppressEmptyDataPageDropped.tag(),
667            "suppress_empty_data_page_dropped"
668        );
669    }
670}