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}