Skip to main content

dsfb_debug/
lib.rs

1//! # dsfb-debug — Structural Semiotics Engine for Software Debugging
2//!
3//! A deterministic, read-only, observer-only augmentation layer that
4//! turns the residuals every observability stack already discards into
5//! typed, human-readable debugging episodes with full evidence trails.
6//!
7//! ## Augmentation, not replacement
8//!
9//! DSFB-Debug does NOT compete with existing observability tools
10//! (Datadog, OpenTelemetry, Jaeger, Sentry, Prometheus, ELK). It sits
11//! on top of them as a passive observer that ingests their residuals
12//! and produces typed structural interpretation. The intended deployment
13//! is: existing tools detect anomalies; DSFB-Debug structures them
14//! into typed episodes; operators receive the union as actionable
15//! insight rather than alert noise.
16//!
17//! ## Non-Intrusion Contract (type-enforced)
18//!
19//! Every public function accepts only shared immutable references
20//! (`&[T]`). There is NO mutable write path into any upstream data
21//! structure. The Rust type system enforces this at compile time.
22//! See `docs/non_intrusion_contract.md` for the formal contract.
23//!
24//! ## Crate Properties (compile-time enforced)
25//!
26//! - `#![no_std]` — no standard library dependency in the core
27//! - `#![forbid(unsafe_code)]` — zero unsafe blocks anywhere
28//! - `#![deny(clippy::unwrap_used)]` — no panic paths
29//! - Zero runtime Cargo dependencies — SHA-256, DFT, matrix algebra
30//!   are hand-rolled in `src/adapters/` and `src/incumbent_baselines.rs`
31//! - Deterministic — identical inputs always produce byte-identical
32//!   outputs (Theorem 9, formally proven in paper §6.4)
33//!
34//! ## Feature Gates
35//!
36//! - `std` — enables `Vec`-based variable-size buffers and the
37//!   adapter layer. The no_std core is unchanged.
38//! - `paper-lock` — enables `evaluate_real_dataset`, the
39//!   `RealDatasetManifest` struct, and SHA-256 integrity gating.
40//!   Implies `std`. Without `paper-lock`, the real-data entry point
41//!   is physically absent from the compiled artefact.
42//!
43//! ## Pipeline Architecture
44//!
45//! ```text
46//! Residual → SignTuple → Grammar → Hysteresis → ReasonCode → Bank lookup → Episode
47//! ```
48//!
49//! The engine is a pipeline of deterministic stages. Each stage has
50//! its own module:
51//!
52//! | Stage | Module | Output type |
53//! |-------|--------|-------------|
54//! | Residual extraction | `adapters/residual_projection.rs` | `OwnedResidualMatrix` |
55//! | Sign tuple | `sign.rs` | `SignTuple` |
56//! | Grammar | `grammar.rs` | `GrammarState` |
57//! | Hysteresis | `policy.rs` | confirmed `GrammarState` |
58//! | Reason code | `policy.rs` | `ReasonCode` |
59//! | Heuristics bank lookup | `heuristics_bank.rs` | `SemanticDisposition` |
60//! | Episode aggregation | `episode.rs` | `DebugEpisode` |
61//! | Multi-detector fusion | `fusion.rs` | `FusionMetrics` (std-only) |
62//! | Causality attribution | `causality.rs` | `root_cause_signal_index` |
63//! | Operator rendering | `render.rs` | `String` (std-only) |
64//!
65//! ## Standards Alignment
66//!
67//! - **NIST SP 800-53 Rev. 5**: AU-2 (auditable events: named
68//!   primary witness detectors), AU-3 (record content: per-motif
69//!   provenance + DOI + taxonomy), AU-6 (review/analysis: episode
70//!   catalog), AU-12 (audit generation: deterministic replay)
71//! - **NIST SP 800-92**: §4.2 (log analysis), §5 (log management)
72//! - **NIST SP 800-171 Rev. 2**: §3.3 (Audit & Accountability)
73//! - **DO-178C** §6.3: certification-pathway-eligible architectural
74//!   foresight (NOT a certification claim)
75//! - **IEEE 1012-2016** §7: V&V verification tool classification
76//! - **ISO/IEC 25010:2023**: Analysability, Testability
77//! - **OpenTelemetry Semantic Conventions**: OTLP-compatible residual ingestion
78//! - **W3C Trace Context Level 1**: §3 (traceparent), §4 (tracestate)
79//! - **SOC 2 Type II**: CC7.2, CC7.3 (Monitoring Activities)
80//!
81//! ## Theorem 9 (Deterministic Replay)
82//!
83//! For any byte-stable residual matrix input, two consecutive
84//! `engine.run_evaluation(...)` calls produce byte-identical episode
85//! output. Mechanically proven by composition of deterministic stages
86//! (paper §6.4); empirically verified on every real-bytes vendored
87//! fixture by `verify_deterministic_replay`. Failure of this assertion
88//! on real bytes surfaces as a hard test failure, not a silent
89//! metric drift.
90
91#![no_std]
92#![forbid(unsafe_code)]
93#![deny(clippy::unwrap_used)]
94
95// `std` feature opts the std-only adapter and real-dataset entry point in.
96// The no_std core (residual / sign / grammar / policy / episode pipeline)
97// is byte-stable regardless of which features are enabled.
98#[cfg(feature = "std")]
99extern crate std;
100
101pub mod types;
102pub mod error;
103pub mod config;
104pub mod residual;
105pub mod sign;
106pub mod envelope;
107pub mod grammar;
108pub mod heuristics_bank;
109pub mod dsa;
110pub mod policy;
111pub mod episode;
112pub mod baseline;
113pub mod causality;
114pub mod graph_inference;
115pub mod episode_catalog;
116
117// std-only adapters and real-dataset entry point. Absent from the no_std
118// default build.
119#[cfg(feature = "std")]
120pub mod adapters;
121#[cfg(feature = "paper-lock")]
122pub mod real_data;
123#[cfg(feature = "std")]
124pub mod calibration;
125#[cfg(feature = "std")]
126pub mod incumbent_baselines;
127#[cfg(feature = "std")]
128pub mod render;
129#[cfg(feature = "std")]
130pub mod fusion;
131
132#[cfg(feature = "std")]
133pub mod audit;
134
135#[cfg(feature = "demo")]
136pub mod demo;
137
138use types::*;
139use error::{DsfbError, Result};
140use config::EngineConfig;
141use heuristics_bank::HeuristicsBank;
142
143/// Main DSFB debugging engine — stateless, deterministic, read-only.
144///
145/// The engine evaluates telemetry residuals through the DSFB pipeline
146/// and produces typed, auditable debugging episodes.
147///
148/// # Const Generic Parameters
149/// - `MAX_SIGNALS`: maximum number of monitored signals (span durations, error rates, etc.)
150/// - `MAX_MOTIFS`: maximum heuristics bank entries
151///
152/// # Non-Intrusion Contract
153///
154/// All public methods accept only `&self` and `&[T]` (shared immutable references).
155/// There is NO mutable write path into any upstream data structure.
156/// The Rust type system enforces this at compile time.
157pub struct DsfbDebugEngine<
158    const MAX_SIGNALS: usize,
159    const MAX_MOTIFS: usize,
160> {
161    config: EngineConfig,
162    heuristics_bank: HeuristicsBank<MAX_MOTIFS>,
163}
164
165impl<const S: usize, const M: usize> DsfbDebugEngine<S, M> {
166    /// Create a new engine with the given configuration.
167    pub fn new(config: EngineConfig) -> Result<Self> {
168        config.validate()?;
169        Ok(Self {
170            config,
171            heuristics_bank: HeuristicsBank::with_canonical_motifs(),
172        })
173    }
174
175    /// Create a new engine with paper-lock configuration.
176    pub fn paper_lock() -> Result<Self> {
177        Self::new(config::PAPER_LOCK_CONFIG)
178    }
179
180    /// Get the engine configuration (read-only)
181    pub fn config(&self) -> &EngineConfig {
182        &self.config
183    }
184
185    /// Get the engine's heuristics bank (read-only). Exposed for the
186    /// fusion harness's consensus-aware scoring pass; operators
187    /// reading this can call `bank.match_episode_with_consensus(...)`
188    /// against any closed `DebugEpisode` directly.
189    pub fn heuristics_bank(&self) -> &HeuristicsBank<M> {
190        &self.heuristics_bank
191    }
192
193    /// Evaluate a single window of telemetry for a single signal.
194    ///
195    /// # Non-Intrusion Contract
196    /// All inputs are shared immutable references. Cannot modify upstream data.
197    ///
198    /// # Arguments
199    /// * `residual_norms` - historical residual norms for this signal (immutable)
200    /// * `k` - current window index into the norms array
201    /// * `rho` - envelope radius for this signal
202    /// * `signal_index` - index of this signal
203    /// * `window_index` - global window index
204    /// * `was_imputed` - whether this observation was imputed (missing data)
205    /// * `recent_raw_states` - last n_confirm raw grammar states
206    /// * `persistence_count` - consecutive Boundary windows
207    #[allow(clippy::too_many_arguments)]
208    pub fn evaluate_signal(
209        &self,
210        residual_norms: &[f64],     // immutable
211        k: usize,
212        rho: f64,
213        signal_index: u16,
214        window_index: u64,
215        was_imputed: bool,
216        recent_raw_states: &[GrammarState], // immutable
217        persistence_count: usize,
218    ) -> SignalEvaluation {
219        // Missingness-aware: imputed signals → zero everything
220        if was_imputed {
221            return SignalEvaluation {
222                window_index,
223                signal_index,
224                residual_value: 0.0,
225                sign_tuple: SignTuple::ZERO,
226                raw_grammar_state: GrammarState::Admissible,
227                confirmed_grammar_state: GrammarState::Admissible,
228                reason_code: ReasonCode::Admissible,
229                motif: None,
230                semantic_disposition: SemanticDisposition::Unknown,
231                dsa_score: 0.0,
232                policy_state: PolicyState::Silent,
233                was_imputed: true,
234                drift_persistence: 0.0,
235            };
236        }
237
238        // Step 1: Compute sign tuple
239        let sign_tuple = sign::compute_sign_tuple(residual_norms, k);
240
241        // Step 2: Compute drift persistence
242        let drift_pers = sign::drift_persistence(
243            residual_norms, k, self.config.drift_window,
244        );
245
246        // Step 3: Evaluate raw grammar state
247        let (raw_grammar, reason_code) = grammar::evaluate_raw_grammar(
248            &sign_tuple, rho, &self.config, drift_pers,
249        );
250
251        // Step 4: Apply hysteresis confirmation
252        let confirmed = grammar::hysteresis_confirm(
253            recent_raw_states, self.config.hysteresis_confirm,
254        );
255        // Use the higher of hysteresis result and current raw (Violation bypasses)
256        let confirmed_grammar = if raw_grammar == GrammarState::Violation {
257            GrammarState::Violation
258        } else {
259            confirmed
260        };
261
262        // Step 5: Compute DSA features
263        // Simplified: use drift persistence as primary DSA input
264        let slew_mag = if sign_tuple.slew > 0.0 { sign_tuple.slew } else { -sign_tuple.slew };
265        let dsa_score = dsa::compute_dsa_score(
266            0.0, // boundary density would need state history — simplified
267            drift_pers,
268            if slew_mag > self.config.slew_delta { 1.0 } else { 0.0 },
269        );
270        let gate_passed = dsa::consistency_gate(dsa_score, self.config.consistency_gate);
271
272        // Step 6: Heuristics bank lookup (semantics)
273        let semantic = self.heuristics_bank.lookup(reason_code, drift_pers, slew_mag);
274
275        // Step 7: Extract motif class
276        let motif = match semantic {
277            SemanticDisposition::Named(m) => Some(m),
278            SemanticDisposition::Unknown => None,
279        };
280
281        // Step 8: Apply policy
282        let policy_state = policy::apply_policy(
283            confirmed_grammar,
284            dsa_score,
285            gate_passed,
286            semantic,
287            persistence_count,
288            self.config.persistence_threshold,
289        );
290
291        SignalEvaluation {
292            window_index,
293            signal_index,
294            residual_value: if k < residual_norms.len() { residual_norms[k] } else { 0.0 },
295            sign_tuple,
296            raw_grammar_state: raw_grammar,
297            confirmed_grammar_state: confirmed_grammar,
298            reason_code,
299            motif,
300            semantic_disposition: semantic,
301            dsa_score,
302            policy_state,
303            was_imputed: false,
304            drift_persistence: drift_pers,
305        }
306    }
307
308    /// Run the full DSFB evaluation pipeline over a dataset.
309    ///
310    /// This is the main entry point for benchmark evaluation.
311    ///
312    /// # Non-Intrusion Contract
313    /// All input slices are shared immutable references.
314    /// Outputs are written into caller-owned mutable buffers.
315    ///
316    /// # Arguments
317    /// * `data` - row-major observation data [window][signal] (immutable)
318    /// * `num_signals` - signals per window
319    /// * `num_windows` - total windows
320    /// * `fault_labels` - per-window fault labels (immutable)
321    /// * `healthy_window_end` - index of last healthy window for baseline
322    /// * `eval_out` - output buffer for per-signal evaluations (row-major)
323    /// * `episodes_out` - output buffer for episodes
324    /// * `dataset_name` - name for metrics reporting
325    ///
326    /// # Returns
327    /// (episode_count, BenchmarkMetrics)
328    #[allow(clippy::too_many_arguments)]
329    pub fn run_evaluation(
330        &self,
331        data: &[f64],                    // immutable
332        num_signals: usize,
333        num_windows: usize,
334        fault_labels: &[bool],           // immutable
335        healthy_window_end: usize,
336        eval_out: &mut [SignalEvaluation],
337        episodes_out: &mut [DebugEpisode],
338        dataset_name: &'static str,
339    ) -> Result<(usize, BenchmarkMetrics)> {
340        if num_signals > S {
341            return Err(DsfbError::SignalBufferFull);
342        }
343        if data.len() < num_windows * num_signals {
344            return Err(DsfbError::DimensionMismatch {
345                expected: num_windows * num_signals,
346                got: data.len(),
347            });
348        }
349        // Flat-aggregation buffers below are sized FLAT_CAP. Refuse rather
350        // than silently truncate the policy/reason/drift/slew streams.
351        const FLAT_CAP: usize = 8192;
352        let needed = match num_signals.checked_mul(num_windows) {
353            Some(n) => n,
354            None => return Err(DsfbError::BufferTooSmall { needed: usize::MAX, available: FLAT_CAP }),
355        };
356        if needed > FLAT_CAP {
357            return Err(DsfbError::BufferTooSmall { needed, available: FLAT_CAP });
358        }
359
360        // Phase 1: Compute baseline from healthy window
361        let mut baseline_mean = [0.0_f64; S];
362        let mut rho = [0.0_f64; S];
363        let healthy_data_end = healthy_window_end * num_signals;
364        let healthy_slice = if healthy_data_end <= data.len() {
365            &data[..healthy_data_end]
366        } else {
367            data
368        };
369        baseline::compute_baseline_mean(
370            healthy_slice, num_signals, healthy_window_end, &mut baseline_mean[..num_signals],
371        );
372        baseline::compute_baseline_envelope(
373            healthy_slice, &baseline_mean[..num_signals],
374            num_signals, healthy_window_end, &mut rho[..num_signals],
375        );
376
377        // Phase 2: Compute residuals and evaluate each signal at each window
378        // We need per-signal norm histories for sign tuple computation
379        // Use a rolling buffer approach — keep norms inline
380
381        // Flatten evaluation: track per-signal state
382        let mut persistence_counts = [0_usize; S];
383        let mut recent_raw = [[GrammarState::Admissible; 4]; S]; // last 4 raw states per signal
384        let mut raw_head = [0_usize; S]; // circular index
385
386        // Per-signal norm histories for drift computation
387        // We'll compute norms incrementally and store in eval_out for traceability
388        let mut policy_states_flat: [PolicyState; 8192] = [PolicyState::Silent; 8192];
389        let mut reason_codes_flat: [ReasonCode; 8192] = [ReasonCode::Admissible; 8192];
390        let mut drift_dirs_flat: [DriftDirection; 8192] = [DriftDirection::None; 8192];
391        let mut slew_mags_flat: [f64; 8192] = [0.0; 8192];
392        let mut raw_anomaly_count: u64 = 0;
393
394        // We need per-signal norm arrays. Since no_alloc, use a fixed window.
395        // Keep last (drift_window + 2) norms per signal for sign computation.
396        const NORM_HIST: usize = 32; // enough for any reasonable drift_window
397        let mut norm_histories = [[0.0_f64; NORM_HIST]; S];
398        let mut norm_heads = [0_usize; S];
399
400        let mut w = 0_usize;
401        while w < num_windows {
402            let mut s = 0_usize;
403            while s < num_signals {
404                let data_idx = w * num_signals + s;
405                let obs = if data_idx < data.len() { data[data_idx] } else { 0.0 };
406                let is_nan = obs.is_nan(); // NaN check
407                let residual = if is_nan { 0.0 } else { obs - baseline_mean[s] };
408                let norm = residual::residual_norm(residual);
409
410                // Push norm into per-signal history
411                let h = norm_heads[s];
412                if h < NORM_HIST {
413                    norm_histories[s][h] = norm;
414                    norm_heads[s] = h + 1;
415                } else {
416                    // Shift left (simple, O(NORM_HIST) but NORM_HIST is small)
417                    let mut i = 0;
418                    while i < NORM_HIST - 1 {
419                        norm_histories[s][i] = norm_histories[s][i + 1];
420                        i += 1;
421                    }
422                    norm_histories[s][NORM_HIST - 1] = norm;
423                }
424
425                let nh = norm_heads[s];
426                let k = if nh > 0 { nh - 1 } else { 0 };
427
428                // Build recent_raw_states slice for hysteresis
429                let rh = raw_head[s];
430                let recent_slice_len = if rh < 4 { rh } else { 4 };
431                let _recent_start = rh.saturating_sub(4);
432                // We need a contiguous slice — use the array directly
433                let recent = &recent_raw[s][..recent_slice_len];
434
435                let eval = self.evaluate_signal(
436                    &norm_histories[s][..nh],
437                    k,
438                    rho[s],
439                    s as u16,
440                    w as u64,
441                    is_nan,
442                    recent,
443                    persistence_counts[s],
444                );
445
446                // Update persistence count
447                if eval.confirmed_grammar_state >= GrammarState::Boundary {
448                    persistence_counts[s] += 1;
449                } else {
450                    persistence_counts[s] = 0;
451                }
452
453                // Update raw state history (circular)
454                let rh_idx = raw_head[s] % 4;
455                recent_raw[s][rh_idx] = eval.raw_grammar_state;
456                raw_head[s] += 1;
457
458                // Store evaluation
459                let eval_idx = w * num_signals + s;
460                if eval_idx < eval_out.len() {
461                    eval_out[eval_idx] = eval;
462                }
463
464                // Store flattened arrays for episode aggregation
465                let flat_idx = w * num_signals + s;
466                if flat_idx < policy_states_flat.len() {
467                    policy_states_flat[flat_idx] = eval.policy_state;
468                    reason_codes_flat[flat_idx] = eval.reason_code;
469                    slew_mags_flat[flat_idx] = if eval.sign_tuple.slew > 0.0 {
470                        eval.sign_tuple.slew
471                    } else {
472                        -eval.sign_tuple.slew
473                    };
474                    drift_dirs_flat[flat_idx] = if eval.sign_tuple.drift > 0.1 {
475                        DriftDirection::Positive
476                    } else if eval.sign_tuple.drift < -0.1 {
477                        DriftDirection::Negative
478                    } else {
479                        DriftDirection::None
480                    };
481                }
482
483                // Count raw anomalies (any signal in Boundary or Violation)
484                if eval.confirmed_grammar_state >= GrammarState::Boundary {
485                    raw_anomaly_count += 1;
486                }
487
488                s += 1;
489            }
490            w += 1;
491        }
492
493        // Phase 3: Episode aggregation (Trace Event Collapse)
494        let total_flat = num_windows * num_signals;
495        let flat_len = if total_flat < policy_states_flat.len() {
496            total_flat
497        } else {
498            policy_states_flat.len()
499        };
500
501        let episode_count = episode::aggregate_episodes(
502            &policy_states_flat[..flat_len],
503            num_signals,
504            num_windows,
505            &reason_codes_flat[..flat_len],
506            &drift_dirs_flat[..flat_len],
507            &slew_mags_flat[..flat_len],
508            self.config.episode_correlation_window,
509            episodes_out,
510        );
511
512        // Phase 3b: Episode-level heuristics-bank match (Session 3).
513        // For every closed episode, compute average drift persistence
514        // and average boundary density over the episode's window range
515        // from `eval_out`, then call `match_episode` to populate the
516        // previously-stub `matched_motif` field with a real disposition.
517        let mut ep_idx: usize = 0;
518        while ep_idx < episode_count {
519            let ep = episodes_out[ep_idx];
520            let start_w = ep.start_window as usize;
521            let end_w = ep.end_window as usize;
522
523            let mut sum_drift: f64 = 0.0;
524            let mut boundary_count: usize = 0;
525            let mut total: usize = 0;
526            let mut w = start_w;
527            while w <= end_w && w < num_windows {
528                let mut s = 0;
529                while s < num_signals {
530                    let idx = w * num_signals + s;
531                    if idx < eval_out.len() {
532                        let e = eval_out[idx];
533                        sum_drift += e.drift_persistence;
534                        if e.confirmed_grammar_state == GrammarState::Boundary {
535                            boundary_count += 1;
536                        }
537                        total += 1;
538                    }
539                    s += 1;
540                }
541                w += 1;
542            }
543            let avg_drift = if total > 0 { sum_drift / total as f64 } else { 0.0 };
544            let avg_boundary = if total > 0 { boundary_count as f64 / total as f64 } else { 0.0 };
545
546            let disposition = self.heuristics_bank.match_episode(&ep, avg_drift, avg_boundary);
547            // Write the disposition back into the episode output buffer.
548            episodes_out[ep_idx].matched_motif = disposition;
549            // If the disposition resolved to a Named motif, also reflect it
550            // through the `policy_state` selection: violations stay
551            // Escalate; boundary episodes inherit the bank's recommended
552            // action where it is more conservative (no downgrade below
553            // Review for boundary episodes).
554            if let SemanticDisposition::Named(motif) = disposition {
555                let recommended = self.heuristics_bank.recommended_action(motif);
556                if episodes_out[ep_idx].policy_state == PolicyState::Review
557                    && recommended == PolicyState::Escalate
558                {
559                    episodes_out[ep_idx].policy_state = PolicyState::Escalate;
560                }
561            }
562
563            ep_idx += 1;
564        }
565
566        // Phase 4: Compute metrics
567        let metrics = episode::compute_metrics(
568            episodes_out,
569            episode_count,
570            fault_labels,
571            raw_anomaly_count,
572            self.config.episode_precision_window,
573            dataset_name,
574            num_signals as u16,
575        );
576
577        Ok((episode_count, metrics))
578    }
579
580    /// `run_evaluation` plus graph-attribution: same return value, but
581    /// each closed episode's `root_cause_signal_index` is populated by
582    /// walking the supplied service-call graph (see `crate::causality`).
583    ///
584    /// Backward-compatible with v0.1: callers without a graph use
585    /// `run_evaluation` and get `root_cause_signal_index = None` on
586    /// every episode.
587    #[allow(clippy::too_many_arguments)]
588    pub fn run_evaluation_with_graph(
589        &self,
590        data: &[f64],
591        num_signals: usize,
592        num_windows: usize,
593        fault_labels: &[bool],
594        healthy_window_end: usize,
595        eval_out: &mut [SignalEvaluation],
596        episodes_out: &mut [DebugEpisode],
597        dataset_name: &'static str,
598        service_graph: &[(u16, u16)],
599    ) -> Result<(usize, BenchmarkMetrics)> {
600        let (episode_count, metrics) = self.run_evaluation(
601            data, num_signals, num_windows, fault_labels,
602            healthy_window_end, eval_out, episodes_out, dataset_name,
603        )?;
604        causality::attribute_root_causes(
605            episodes_out,
606            episode_count,
607            eval_out,
608            num_signals,
609            num_windows,
610            service_graph,
611            self.config.slew_delta,
612        );
613        Ok((episode_count, metrics))
614    }
615
616    /// Deterministic replay verification (Theorem 9 proof-by-construction).
617    ///
618    /// Runs the evaluation twice on identical inputs and verifies identical outputs.
619    pub fn verify_deterministic_replay(
620        &self,
621        data: &[f64],
622        num_signals: usize,
623        num_windows: usize,
624        fault_labels: &[bool],
625        healthy_window_end: usize,
626    ) -> Result<bool> {
627        // First run
628        let mut eval1 = [SignalEvaluation {
629            window_index: 0, signal_index: 0, residual_value: 0.0,
630            sign_tuple: SignTuple::ZERO,
631            raw_grammar_state: GrammarState::Admissible,
632            confirmed_grammar_state: GrammarState::Admissible,
633            reason_code: ReasonCode::Admissible,
634            motif: None, semantic_disposition: SemanticDisposition::Unknown,
635            dsa_score: 0.0, policy_state: PolicyState::Silent, was_imputed: false,
636            drift_persistence: 0.0,
637        }; 4096];
638        let blank_ep = DebugEpisode {
639            episode_id: 0, start_window: 0, end_window: 0,
640            peak_grammar_state: GrammarState::Admissible,
641            primary_reason_code: ReasonCode::Admissible,
642            matched_motif: SemanticDisposition::Unknown,
643            policy_state: PolicyState::Silent,
644            contributing_signal_count: 0,
645            structural_signature: StructuralSignature {
646                dominant_drift_direction: DriftDirection::None,
647                peak_slew_magnitude: 0.0, duration_windows: 0, signal_correlation: 0.0,
648            },
649            root_cause_signal_index: None,
650        };
651        let mut ep1 = [blank_ep; 256];
652        let (c1, m1) = self.run_evaluation(
653            data, num_signals, num_windows, fault_labels,
654            healthy_window_end, &mut eval1, &mut ep1, "replay_test",
655        )?;
656
657        // Second run — identical inputs
658        let mut eval2 = eval1;
659        // Reset eval2
660        let mut i = 0;
661        while i < eval2.len() {
662            eval2[i] = SignalEvaluation {
663                window_index: 0, signal_index: 0, residual_value: 0.0,
664                sign_tuple: SignTuple::ZERO,
665                raw_grammar_state: GrammarState::Admissible,
666                confirmed_grammar_state: GrammarState::Admissible,
667                reason_code: ReasonCode::Admissible,
668                motif: None, semantic_disposition: SemanticDisposition::Unknown,
669                dsa_score: 0.0, policy_state: PolicyState::Silent, was_imputed: false,
670                drift_persistence: 0.0,
671            };
672            i += 1;
673        }
674        let mut ep2 = [blank_ep; 256];
675        let (c2, m2) = self.run_evaluation(
676            data, num_signals, num_windows, fault_labels,
677            healthy_window_end, &mut eval2, &mut ep2, "replay_test",
678        )?;
679
680        // Verify identical outputs
681        if c1 != c2 { return Ok(false); }
682        if m1.dsfb_episode_count != m2.dsfb_episode_count { return Ok(false); }
683        if m1.raw_anomaly_count != m2.raw_anomaly_count { return Ok(false); }
684
685        // Check episode-level equality
686        let mut j = 0;
687        while j < c1 {
688            if ep1[j] != ep2[j] { return Ok(false); }
689            j += 1;
690        }
691
692        Ok(true)
693    }
694}
695
696// Default implementation for common use case (Session 3: MAX_MOTIFS bumped 32 → 64).
697impl DsfbDebugEngine<256, 64> {
698    /// Create with default const generics (256 signals, 64 motifs).
699    ///
700    /// The 64-slot heuristics bank holds 29 canonical motifs as of v0.2
701    /// (Session 3 expansion); the remaining 35 slots provide v0.3 / v0.4
702    /// headroom for additional site-specific findings.
703    pub fn default_size() -> Result<Self> {
704        Self::paper_lock()
705    }
706}