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}