Skip to main content

dsfb_debug/
heuristics_bank.rs

1//! DSFB-Debug: Heuristics Bank — 32 hand-curated motifs with full
2//! Phase 0–8 fusion-axis decision parameters.
3//!
4//! # What this module is
5//!
6//! The heuristics bank is the IP claim of DSFB-Debug. It is a
7//! fixed-size compile-time-curated table of `HeuristicEntry` records
8//! that maps a structural residual signature to a named `MotifClass`
9//! interpretation. Each entry carries:
10//!
11//! 1. **Reason-code anchor** — which `ReasonCode` (drift / slew /
12//!    boundary / oscillation) the motif matches.
13//! 2. **Provenance** — `FrameworkDesign` (hand-coded) /
14//!    `DatasetObserved` (observed in a vendored upstream slice) /
15//!    `FieldValidated` (confirmed in production deployment, reserved
16//!    for Phase II partner engagement).
17//! 3. **Evidence base** — upstream `evidence_dataset` + DOI of the
18//!    archive in which the signature was first observed (e.g.
19//!    `"tadbench_F04"` + `"10.5281/zenodo.6979726"`).
20//! 4. **Taxonomy anchor** — IEEE 24765 + Avizienis-Laprie-Randell
21//!    decomposition (e.g. `"IEEE 24765: 'fault propagation'; A-L-R:
22//!    error → service-failure"`). Every motif name is established
23//!    vocabulary, never invented.
24//! 5. **Drift / slew / boundary thresholds** — gating predicates the
25//!    closed episode must satisfy.
26//! 6. **Correlation / duration ranges** — multi-service motifs require
27//!    `min_correlation_count >= 3`; transient motifs cap
28//!    `max_duration_windows`.
29//! 7. **Per-feature scoring weights** — five weights (drift, slew,
30//!    boundary, correlation, duration) let one motif emphasise drift
31//!    while another emphasises slew.
32//! 8. **Affinity-tier bitmask** — which detector tiers route their
33//!    evidence into this motif's score (Routed Evidence Principle,
34//!    paper §6.5).
35//! 9. **Phase-5.6+ confuser-pair declaration** — explicit motif that
36//!    competes for the same residual signature; episodes failing the
37//!    `margin_vs_confuser` gate surface as `ConfuserAmbiguous` rather
38//!    than committing to a single typing.
39//! 10. **Phase-7 primary witness tiers** — strict subset of affinity
40//!     tiers that MUST fire for typed confirmation.
41//! 11. **Phase-8 named witness detectors** — strict per-detector
42//!     anti-hallucination gate; ≥1 of the captured named witnesses
43//!     must fire.
44//! 12. **Dashboard hint** — operator-side template with
45//!     `${ROOT_CAUSE_SERVICE}`, `${PEAK_SLEW}`, etc. placeholders that
46//!     `render::render_episode_summary` substitutes at presentation
47//!     time.
48//!
49//! # Two lookup paths
50//!
51//! - **`lookup(reason_code, drift_persistence, slew_magnitude)`** —
52//!   per-signal, called from `evaluate_signal`. Preserves the v0.1
53//!   wire shape (unit-weighted scoring across reason-code match +
54//!   drift + slew). Populates `SignalEvaluation.semantic_disposition`.
55//! - **`match_episode_with_*` family** — per-episode, called from
56//!   `run_evaluation` after `aggregate_episodes` closes each episode.
57//!   Uses ALL features available at episode close. The post-Phase-8
58//!   entry point `match_episode_with_consensus` consults the 9-axis
59//!   bank-aware fusion configuration (\S\ref{sec:nine_axis_fusion}
60//!   in the paper); legacy `match_episode_with_confidence` and
61//!   `match_episode_with_tier_affinity` are preserved for backward
62//!   compatibility.
63//!
64//! Both paths are deterministic: tie-breakers (higher provenance rank
65//! wins; lower index wins) preserve Theorem 9 across all configs.
66//!
67//! # Endoductive discipline (panel-locked)
68//!
69//! The bank intentionally does NOT carry motifs for LO2-style
70//! API-semantic anomalies (OAuth2 flow / architectural-degradation
71//! patterns). Those are validated against the
72//! `SemanticDisposition::Unknown` branch by `tests/eval_lo2.rs`.
73//! Adding LO2-specific motifs would destroy the validator. The
74//! absence is by design — see paper §5.6 and Session-3 panel directive.
75//!
76//! # Standards alignment
77//!
78//! - **NIST SP 800-53 AU-3** ("audit record content"): each entry's
79//!   `(provenance, evidence_dataset, evidence_dataset_doi,
80//!   taxonomy_ref, dashboard_hint)` collectively satisfies the
81//!   "what / when / where / source / outcome" content requirement at
82//!   per-motif granularity.
83//! - **NIST SP 800-53 AU-2** ("auditable events"): the
84//!   `primary_witness_detectors` list defines the named auditable
85//!   events that must fire for typed confirmation.
86//! - **IEEE 24765 + Avizienis-Laprie-Randell**: every motif name
87//!   decomposes into established software-engineering vocabulary.
88//!   No ad-hoc naming.
89//! - **IEEE 1012-2016** ("verification and validation"): the
90//!   confuser-boundary mechanism (`margin_vs_confuser`) provides
91//!   independent confirmation of typed disposition before declaring
92//!   `Named(motif)` rather than `ConfuserAmbiguous`.
93//!
94//! # Theorem 9 contract
95//!
96//! Every public method preserves deterministic replay. The bank
97//! itself is `Copy + Clone + Debug + PartialEq` (every field is
98//! Copy-able), so two `HeuristicsBank` instances with the same
99//! `entries` array produce byte-identical `match_*` results on the
100//! same inputs.
101
102use crate::types::*;
103
104// =====================================================================
105// Phase 2 — Tier-affinity bits.
106//
107// Each detector belongs to exactly one tier; each motif's affinity mask
108// declares which tiers are predictive for that motif. Per-cell tier-fired
109// bitmasks let the fusion arithmetic compute motif-conditional consensus
110// (Direction #2 from the panel) by AND-ing the cell's tier mask against
111// the candidate motif's affinity mask, then popcount-ing.
112//
113// Bits 0..22 cover the 23 tier groupings (A–F + Extras + G–U). Bits 22+
114// are reserved for future tiers.
115// =====================================================================
116pub const TIER_BIT_A: u32         = 1 << 0;   // parametric trio (scalar/CUSUM/EWMA)
117pub const TIER_BIT_B: u32         = 1 << 1;   // robust statistics
118pub const TIER_BIT_C: u32         = 1 << 2;   // model / non-parametric
119pub const TIER_BIT_D: u32         = 1 << 3;   // additional non-dep
120pub const TIER_BIT_E: u32         = 1 << 4;   // debugging-specific stats
121pub const TIER_BIT_F: u32         = 1 << 5;   // burst (neuroscience-derived)
122pub const TIER_BIT_EXTRA: u32     = 1 << 6;   // GLR/ADWIN/MEWMA/retry-storm/correlation-break
123pub const TIER_BIT_G: u32         = 1 << 7;   // concept-drift streaming
124pub const TIER_BIT_H: u32         = 1 << 8;   // distribution shift
125pub const TIER_BIT_I: u32         = 1 << 9;   // robust nonparametric
126pub const TIER_BIT_J: u32         = 1 << 10;  // forecast residual
127pub const TIER_BIT_K: u32         = 1 << 11;  // frequency / oscillation
128pub const TIER_BIT_L: u32         = 1 << 12;  // multivariate relationship
129pub const TIER_BIT_M: u32         = 1 << 13;  // debugging-native
130pub const TIER_BIT_N: u32         = 1 << 14;  // offline CPD
131pub const TIER_BIT_O: u32         = 1 << 15;  // rare changepoint
132pub const TIER_BIT_P: u32         = 1 << 16;  // streaming sequential
133pub const TIER_BIT_Q: u32         = 1 << 17;  // concept drift rarer
134pub const TIER_BIT_R: u32         = 1 << 18;  // robust depth
135pub const TIER_BIT_S: u32         = 1 << 19;  // count event-process
136pub const TIER_BIT_T: u32         = 1 << 20;  // info-theoretic
137pub const TIER_BIT_U: u32         = 1 << 21;  // dynamical systems
138
139// Phase 5 wave (Sessions 9+) — new families. Each adds one tier bit.
140pub const TIER_BIT_V: u32         = 1 << 22;  // industrial fault-diagnosis (FDD)
141pub const TIER_BIT_W: u32         = 1 << 23;  // formal runtime monitoring
142pub const TIER_BIT_X: u32         = 1 << 24;  // climate homogeneity (D-extended)
143pub const TIER_BIT_Y: u32         = 1 << 25;  // robust dispersion / rank (E-extended)
144pub const TIER_BIT_Z: u32         = 1 << 26;  // circular / directional
145pub const TIER_BIT_AA: u32        = 1 << 27;  // higher-order nonlinear time-series
146pub const TIER_BIT_BB: u32        = 1 << 28;  // econometric parameter-instability
147pub const TIER_BIT_CC: u32        = 1 << 29;  // metrology / clock-stability
148pub const TIER_BIT_DD: u32        = 1 << 30;  // numerical-computing pathology
149pub const TIER_BIT_EE: u32        = 1 << 31;  // process-monitoring contribution / isolation
150
151/// Tier-affinity mask for a motif: which detector tiers are predictive
152/// for the motif's reason-code structural signature. Multi-service motifs
153/// (`min_correlation_count >= 3`) get multivariate tiers added regardless.
154///
155/// `u32::MAX` (= "all tiers") for unmapped reason codes preserves the
156/// pre-Phase-2 uniform-voting semantics for that motif.
157pub fn affinity_tiers_for(reason_code: ReasonCode, min_correlation_count: u16) -> u32 {
158    let base = match reason_code {
159        // Slow drift: trend / forecast-residual / monotone-leak / variance-time / regularity
160        ReasonCode::SustainedOutwardDrift => {
161            TIER_BIT_I | TIER_BIT_J | TIER_BIT_M | TIER_BIT_S | TIER_BIT_U | TIER_BIT_T
162        }
163        // Step / abrupt change: scalar-3σ, Page-Hinkley, Theil-Sen step, offline + rare CPD
164        ReasonCode::AbruptSlewViolation => {
165            TIER_BIT_A | TIER_BIT_B | TIER_BIT_I | TIER_BIT_N | TIER_BIT_O | TIER_BIT_EXTRA
166        }
167        // Oscillation / boundary grazing: frequency, debugging-native limit-cycle/flap, RQA
168        ReasonCode::RecurrentBoundaryGrazing => {
169            TIER_BIT_K | TIER_BIT_M | TIER_BIT_U
170        }
171        // Drift with recovery: trend + forecast + sawtooth-ramp / hysteresis
172        ReasonCode::DriftWithRecovery => {
173            TIER_BIT_I | TIER_BIT_J | TIER_BIT_M | TIER_BIT_T
174        }
175        // Boundary approach: just-below-envelope; slowly-rising trend + spectral-entropy
176        ReasonCode::BoundaryApproach => {
177            TIER_BIT_I | TIER_BIT_J | TIER_BIT_K | TIER_BIT_M
178        }
179        // Envelope violation: hard breach; scalar/CUSUM/Page-Hinkley + extreme + saturation
180        ReasonCode::EnvelopeViolation => {
181            TIER_BIT_A | TIER_BIT_B | TIER_BIT_E | TIER_BIT_R
182        }
183        // Single crossing: dismissed by persistence; scalar + burst-like
184        ReasonCode::SingleCrossing => {
185            TIER_BIT_A | TIER_BIT_F
186        }
187        // Default: all tiers active (preserve pre-Phase-2 semantics).
188        ReasonCode::Admissible => u32::MAX,
189    };
190    // Multi-service motifs always benefit from multivariate + correlation tiers.
191    let multivariate = if min_correlation_count >= 3 {
192        TIER_BIT_C | TIER_BIT_L | TIER_BIT_EXTRA
193    } else { 0 };
194    base | multivariate
195}
196
197/// The heuristics bank: a fixed-size array of known motif patterns.
198/// Provenance-aware: each entry records where it came from.
199pub struct HeuristicsBank<const MAX: usize> {
200    entries: [Option<HeuristicEntry>; MAX],
201    count: usize,
202}
203
204impl<const MAX: usize> HeuristicsBank<MAX> {
205    /// Create a new bank pre-loaded with the canonical debugging motifs
206    /// (32 entries, anchored to IEEE 24765 + Avizienis-Laprie-Randell).
207    pub fn with_canonical_motifs() -> Self {
208        let mut bank = Self {
209            entries: [None; MAX],
210            count: 0,
211        };
212
213        let canonical: &[HeuristicEntry] = &[
214            // ===== Tier-1: original 10 motifs (FrameworkDesign) ==========
215
216            HeuristicEntry {
217                motif_class: MotifClass::MemoryLeakDrift,
218                reason_code: ReasonCode::SustainedOutwardDrift,
219                candidate_interpretation: "sustained monotonic memory-consumption drift; may correspond to object-retention bugs",
220                provenance: Provenance::FrameworkDesign,
221                recommended_action: PolicyState::Review,
222                drift_threshold: 0.6,
223                slew_threshold: 0.0,
224                boundary_density_threshold: 0.4,
225                min_correlation_count: 1,
226                max_correlation_count: u16::MAX,
227                min_duration_windows: 5,
228                max_duration_windows: u16::MAX,
229                weight_drift: 1.5,
230                weight_slew: 0.3,
231                weight_boundary: 1.0,
232                weight_correlation: 0.5,
233                weight_duration: 1.2,
234                evidence_dataset: "FrameworkDesign",
235                evidence_dataset_doi: "",
236                dashboard_hint: "Inspect process RSS / heap-used and gc.duration over the past hour",
237                taxonomy_ref: "IEEE 24765: 'memory leak'; A-L-R: latent fault → error",
238                affinity_tiers: TIER_BIT_I | TIER_BIT_J | TIER_BIT_M | TIER_BIT_S | TIER_BIT_U | TIER_BIT_T | TIER_BIT_X | TIER_BIT_Y,
239                confuser_motif: Some(MotifClass::ConnectionPoolExhaustionDrift),
240                margin_vs_confuser_threshold: 0.10,
241                primary_witness_tiers: TIER_BIT_I | TIER_BIT_M | TIER_BIT_S,
242                primary_witness_detectors: &["mann_kendall", "monotone_leak", "theil_sen_residual"],
243            },
244            HeuristicEntry {
245                motif_class: MotifClass::CascadingTimeoutSlew,
246                reason_code: ReasonCode::AbruptSlewViolation,
247                candidate_interpretation: "step-change latency propagating across dependency chain; may correspond to upstream failure",
248                provenance: Provenance::DatasetObserved,
249                recommended_action: PolicyState::Escalate,
250                drift_threshold: 0.0,
251                slew_threshold: 0.5,
252                boundary_density_threshold: 0.0,
253                min_correlation_count: 3,
254                max_correlation_count: u16::MAX,
255                min_duration_windows: 2,
256                max_duration_windows: 30,
257                weight_drift: 0.2,
258                weight_slew: 1.5,
259                weight_boundary: 0.5,
260                weight_correlation: 1.5,
261                weight_duration: 0.8,
262                evidence_dataset: "tadbench_trainticket_F04",
263                evidence_dataset_doi: "10.5281/zenodo.6979726",
264                dashboard_hint: "Inspect ${ROOT_CAUSE_SERVICE} (signal ${ROOT_CAUSE_INDEX}); ${CONTRIBUTING_COUNT} services contribute over ${DURATION_WINDOWS} windows; peak slew ${PEAK_SLEW}",
265                taxonomy_ref: "IEEE 24765: 'fault propagation'; A-L-R: error → service-failure",
266                affinity_tiers: TIER_BIT_C | TIER_BIT_L | TIER_BIT_EXTRA | TIER_BIT_B | TIER_BIT_M | TIER_BIT_V | TIER_BIT_X,
267                confuser_motif: Some(MotifClass::DependencySlowdown),
268                margin_vs_confuser_threshold: 0.10,
269                primary_witness_tiers: TIER_BIT_C | TIER_BIT_L | TIER_BIT_EXTRA,
270                primary_witness_detectors: &["correlation_break", "lof", "causal_lag"],
271            },
272            HeuristicEntry {
273                motif_class: MotifClass::DeploymentRegressionSlew,
274                reason_code: ReasonCode::AbruptSlewViolation,
275                candidate_interpretation: "abrupt baseline shift coinciding with deployment; structural step function",
276                provenance: Provenance::DatasetObserved,
277                recommended_action: PolicyState::Escalate,
278                drift_threshold: 0.0,
279                slew_threshold: 0.8,
280                boundary_density_threshold: 0.0,
281                min_correlation_count: 1,
282                max_correlation_count: 2,
283                min_duration_windows: 1,
284                max_duration_windows: u16::MAX,
285                weight_drift: 0.0,
286                weight_slew: 2.0,
287                weight_boundary: 0.5,
288                weight_correlation: 0.3,
289                weight_duration: 0.2,
290                evidence_dataset: "tadbench_trainticket_F11",
291                evidence_dataset_doi: "10.5281/zenodo.6979726",
292                dashboard_hint: "Single-service step shift on ${ROOT_CAUSE_SERVICE} (signal ${ROOT_CAUSE_INDEX}); peak slew ${PEAK_SLEW}; correlate with deployment log near window ${DURATION_WINDOWS}; consider rollback",
293                taxonomy_ref: "IEEE 24765: 'regression'; A-L-R: design fault → error",
294                affinity_tiers: TIER_BIT_A | TIER_BIT_B | TIER_BIT_I | TIER_BIT_N | TIER_BIT_O | TIER_BIT_X | TIER_BIT_Y | TIER_BIT_V,
295                confuser_motif: Some(MotifClass::CircuitBreakerOpenShift),
296                margin_vs_confuser_threshold: 0.10,
297                primary_witness_tiers: TIER_BIT_A | TIER_BIT_B | TIER_BIT_N | TIER_BIT_X,
298                primary_witness_detectors: &["page_hinkley", "pelt", "pettitt_test"],
299            },
300            HeuristicEntry {
301                motif_class: MotifClass::CacheDegradationGrazing,
302                reason_code: ReasonCode::RecurrentBoundaryGrazing,
303                candidate_interpretation: "oscillatory approach to SLO boundary; may correspond to cache eviction patterns",
304                provenance: Provenance::FrameworkDesign,
305                recommended_action: PolicyState::Watch,
306                drift_threshold: 0.3,
307                slew_threshold: 0.0,
308                boundary_density_threshold: 0.5,
309                min_correlation_count: 1,
310                max_correlation_count: u16::MAX,
311                min_duration_windows: 4,
312                max_duration_windows: u16::MAX,
313                weight_drift: 0.6,
314                weight_slew: 0.4,
315                weight_boundary: 1.8,
316                weight_correlation: 0.4,
317                weight_duration: 1.0,
318                evidence_dataset: "FrameworkDesign",
319                evidence_dataset_doi: "",
320                dashboard_hint: "Inspect cache hit-rate and eviction rate of the affected service",
321                taxonomy_ref: "IEEE 24765: 'performance degradation'; A-L-R: marginal-state error",
322                affinity_tiers: TIER_BIT_K | TIER_BIT_M | TIER_BIT_U | TIER_BIT_F | TIER_BIT_Z | TIER_BIT_AA,
323                confuser_motif: Some(MotifClass::GcPressureOscillation),
324                margin_vs_confuser_threshold: 0.10,
325                primary_witness_tiers: TIER_BIT_K | TIER_BIT_M | TIER_BIT_U,
326                primary_witness_detectors: &["autocorrelation_peak", "limit_cycle", "flap"],
327            },
328            HeuristicEntry {
329                motif_class: MotifClass::ConnectionPoolExhaustionDrift,
330                reason_code: ReasonCode::SustainedOutwardDrift,
331                candidate_interpretation: "slow positive drift in queue depth + latency with increasing variance",
332                provenance: Provenance::DatasetObserved,
333                recommended_action: PolicyState::Review,
334                drift_threshold: 0.5,
335                slew_threshold: 0.0,
336                boundary_density_threshold: 0.4,
337                min_correlation_count: 1,
338                max_correlation_count: u16::MAX,
339                min_duration_windows: 5,
340                max_duration_windows: u16::MAX,
341                weight_drift: 1.4,
342                weight_slew: 0.2,
343                weight_boundary: 1.0,
344                weight_correlation: 0.6,
345                weight_duration: 1.1,
346                evidence_dataset: "tadbench_trainticket_F19",
347                evidence_dataset_doi: "10.5281/zenodo.6979726",
348                dashboard_hint: "Inspect connection pool waiting queue + active connections + idle timeout",
349                taxonomy_ref: "IEEE 24765: 'resource exhaustion'; A-L-R: error build-up",
350                affinity_tiers: TIER_BIT_I | TIER_BIT_J | TIER_BIT_M | TIER_BIT_E | TIER_BIT_X | TIER_BIT_Y,
351                confuser_motif: Some(MotifClass::MemoryLeakDrift),
352                margin_vs_confuser_threshold: 0.10,
353                primary_witness_tiers: TIER_BIT_I | TIER_BIT_E | TIER_BIT_M,
354                primary_witness_detectors: &["monotone_leak", "saturation_chain", "theil_sen_residual"],
355            },
356            HeuristicEntry {
357                motif_class: MotifClass::GcPressureOscillation,
358                reason_code: ReasonCode::RecurrentBoundaryGrazing,
359                candidate_interpretation: "periodic slew events coinciding with GC pauses; bounded oscillation",
360                provenance: Provenance::FrameworkDesign,
361                recommended_action: PolicyState::Watch,
362                drift_threshold: 0.0,
363                slew_threshold: 0.2,
364                boundary_density_threshold: 0.4,
365                min_correlation_count: 1,
366                max_correlation_count: 3,
367                min_duration_windows: 3,
368                max_duration_windows: u16::MAX,
369                weight_drift: 0.2,
370                weight_slew: 1.4,
371                weight_boundary: 1.4,
372                weight_correlation: 0.4,
373                weight_duration: 0.6,
374                evidence_dataset: "FrameworkDesign",
375                evidence_dataset_doi: "",
376                dashboard_hint: "Inspect gc.collection.count + gc.duration histograms",
377                taxonomy_ref: "IEEE 24765: 'stop-the-world pause'; A-L-R: transient error",
378                affinity_tiers: TIER_BIT_K | TIER_BIT_F | TIER_BIT_M | TIER_BIT_S | TIER_BIT_Z | TIER_BIT_AA,
379                confuser_motif: Some(MotifClass::CacheDegradationGrazing),
380                margin_vs_confuser_threshold: 0.10,
381                primary_witness_tiers: TIER_BIT_K | TIER_BIT_F | TIER_BIT_Z,
382                primary_witness_detectors: &["autocorrelation_peak", "sawtooth_ramp", "welch_psd"],
383            },
384            HeuristicEntry {
385                motif_class: MotifClass::ErrorRateEscalation,
386                reason_code: ReasonCode::SustainedOutwardDrift,
387                candidate_interpretation: "sustained positive drift in error rate",
388                provenance: Provenance::FrameworkDesign,
389                recommended_action: PolicyState::Escalate,
390                drift_threshold: 0.7,
391                slew_threshold: 0.0,
392                boundary_density_threshold: 0.3,
393                min_correlation_count: 1,
394                max_correlation_count: u16::MAX,
395                min_duration_windows: 3,
396                max_duration_windows: u16::MAX,
397                weight_drift: 1.8,
398                weight_slew: 0.3,
399                weight_boundary: 0.8,
400                weight_correlation: 0.7,
401                weight_duration: 1.0,
402                evidence_dataset: "FrameworkDesign",
403                evidence_dataset_doi: "",
404                dashboard_hint: "Inspect HTTP 5xx rate by endpoint and recent deploys / config changes",
405                taxonomy_ref: "IEEE 24765: 'error escalation'; A-L-R: error → multi-failure regime",
406                affinity_tiers: TIER_BIT_A | TIER_BIT_G | TIER_BIT_Q | TIER_BIT_E | TIER_BIT_X | TIER_BIT_Y,
407                confuser_motif: Some(MotifClass::PacketLossErrorEscalation),
408                margin_vs_confuser_threshold: 0.10,
409                primary_witness_tiers: TIER_BIT_A | TIER_BIT_G | TIER_BIT_E,
410                primary_witness_detectors: &["chi_squared_proportion", "ddm", "ecdd"],
411            },
412            HeuristicEntry {
413                motif_class: MotifClass::DependencySlowdown,
414                reason_code: ReasonCode::SustainedOutwardDrift,
415                candidate_interpretation: "gradual latency increase in upstream dependency",
416                provenance: Provenance::FrameworkDesign,
417                recommended_action: PolicyState::Review,
418                drift_threshold: 0.4,
419                slew_threshold: 0.0,
420                boundary_density_threshold: 0.3,
421                min_correlation_count: 1,
422                max_correlation_count: u16::MAX,
423                min_duration_windows: 5,
424                max_duration_windows: u16::MAX,
425                weight_drift: 1.3,
426                weight_slew: 0.3,
427                weight_boundary: 0.7,
428                weight_correlation: 0.7,
429                weight_duration: 0.9,
430                evidence_dataset: "FrameworkDesign",
431                evidence_dataset_doi: "",
432                dashboard_hint: "Inspect the upstream service's latency distribution; check its dependencies",
433                taxonomy_ref: "IEEE 24765: 'performance degradation upstream'; A-L-R: external fault",
434                affinity_tiers: TIER_BIT_C | TIER_BIT_L | TIER_BIT_M | TIER_BIT_EXTRA | TIER_BIT_V | TIER_BIT_X,
435                confuser_motif: Some(MotifClass::CascadingTimeoutSlew),
436                margin_vs_confuser_threshold: 0.10,
437                primary_witness_tiers: TIER_BIT_C | TIER_BIT_L | TIER_BIT_M,
438                primary_witness_detectors: &["causal_lag", "correlation_break", "lof"],
439            },
440            HeuristicEntry {
441                motif_class: MotifClass::ResourceSaturation,
442                reason_code: ReasonCode::SustainedOutwardDrift,
443                candidate_interpretation: "CPU/memory/disk approaching ceiling; concave-up drift",
444                provenance: Provenance::FrameworkDesign,
445                recommended_action: PolicyState::Review,
446                drift_threshold: 0.5,
447                slew_threshold: 0.0,
448                boundary_density_threshold: 0.5,
449                min_correlation_count: 1,
450                max_correlation_count: u16::MAX,
451                min_duration_windows: 5,
452                max_duration_windows: u16::MAX,
453                weight_drift: 1.4,
454                weight_slew: 0.3,
455                weight_boundary: 1.2,
456                weight_correlation: 0.5,
457                weight_duration: 1.0,
458                evidence_dataset: "FrameworkDesign",
459                evidence_dataset_doi: "",
460                dashboard_hint: "Inspect system resource gauges (CPU%, memory%, disk%) over the past hour",
461                taxonomy_ref: "IEEE 24765: 'resource saturation'; A-L-R: latent → manifest fault",
462                affinity_tiers: TIER_BIT_A | TIER_BIT_E | TIER_BIT_M | TIER_BIT_R | TIER_BIT_X | TIER_BIT_Y,
463                confuser_motif: Some(MotifClass::ConnectionPoolExhaustionDrift),
464                margin_vs_confuser_threshold: 0.10,
465                primary_witness_tiers: TIER_BIT_A | TIER_BIT_E | TIER_BIT_R,
466                primary_witness_detectors: &["saturation_chain", "monotone_leak", "mann_kendall"],
467            },
468            HeuristicEntry {
469                motif_class: MotifClass::QueueBackpressure,
470                reason_code: ReasonCode::SustainedOutwardDrift,
471                candidate_interpretation: "message queue depth growing monotonically",
472                provenance: Provenance::FrameworkDesign,
473                recommended_action: PolicyState::Review,
474                drift_threshold: 0.6,
475                slew_threshold: 0.0,
476                boundary_density_threshold: 0.3,
477                min_correlation_count: 1,
478                max_correlation_count: u16::MAX,
479                min_duration_windows: 4,
480                max_duration_windows: u16::MAX,
481                weight_drift: 1.5,
482                weight_slew: 0.2,
483                weight_boundary: 0.9,
484                weight_correlation: 0.5,
485                weight_duration: 1.1,
486                evidence_dataset: "FrameworkDesign",
487                evidence_dataset_doi: "",
488                dashboard_hint: "Inspect message-queue depth and consumer lag metrics",
489                taxonomy_ref: "IEEE 24765: 'back-pressure accumulation'; A-L-R: error build-up",
490                affinity_tiers: TIER_BIT_M | TIER_BIT_L | TIER_BIT_J | TIER_BIT_S | TIER_BIT_V | TIER_BIT_X | TIER_BIT_AA,
491                confuser_motif: Some(MotifClass::ConnectionPoolExhaustionDrift),
492                margin_vs_confuser_threshold: 0.10,
493                primary_witness_tiers: TIER_BIT_M | TIER_BIT_L,
494                primary_witness_detectors: &["backpressure", "mann_kendall", "mahalanobis"],
495            },
496
497            // ===== Tier-2: TADBench fault cases ==========================
498
499            HeuristicEntry {
500                motif_class: MotifClass::RetryStormCascade,
501                reason_code: ReasonCode::SustainedOutwardDrift,
502                candidate_interpretation: "client retries amplify upstream load; both error rate and request rate rise together",
503                provenance: Provenance::DatasetObserved,
504                recommended_action: PolicyState::Escalate,
505                drift_threshold: 0.6,
506                slew_threshold: 0.3,
507                boundary_density_threshold: 0.4,
508                min_correlation_count: 2,
509                max_correlation_count: u16::MAX,
510                min_duration_windows: 3,
511                max_duration_windows: 60,
512                weight_drift: 1.3,
513                weight_slew: 1.0,
514                weight_boundary: 0.8,
515                weight_correlation: 1.4,
516                weight_duration: 0.7,
517                evidence_dataset: "tadbench_retry_storm",
518                evidence_dataset_doi: "10.5281/zenodo.6979726",
519                dashboard_hint: "Inspect retry-policy parameters and client-side request rate vs upstream success rate",
520                taxonomy_ref: "IEEE 24765: 'retry-induced amplification'; A-L-R: cascading error",
521                affinity_tiers: TIER_BIT_F | TIER_BIT_M | TIER_BIT_EXTRA | TIER_BIT_S | TIER_BIT_AA | TIER_BIT_Z,
522                confuser_motif: Some(MotifClass::CascadingTimeoutSlew),
523                margin_vs_confuser_threshold: 0.10,
524                primary_witness_tiers: TIER_BIT_F | TIER_BIT_EXTRA | TIER_BIT_M,
525                primary_witness_detectors: &["retry_storm", "causal_lag", "poisson_burst"],
526            },
527            HeuristicEntry {
528                motif_class: MotifClass::CircuitBreakerOpenShift,
529                reason_code: ReasonCode::AbruptSlewViolation,
530                candidate_interpretation: "circuit breaker transitions from CLOSED to OPEN; downstream calls fail fast",
531                provenance: Provenance::DatasetObserved,
532                recommended_action: PolicyState::Escalate,
533                drift_threshold: 0.0,
534                slew_threshold: 0.6,
535                boundary_density_threshold: 0.0,
536                min_correlation_count: 1,
537                max_correlation_count: 4,
538                min_duration_windows: 2,
539                max_duration_windows: u16::MAX,
540                weight_drift: 0.2,
541                weight_slew: 1.6,
542                weight_boundary: 0.4,
543                weight_correlation: 0.8,
544                weight_duration: 0.6,
545                evidence_dataset: "tadbench_circuit_breaker",
546                evidence_dataset_doi: "10.5281/zenodo.6979726",
547                dashboard_hint: "Inspect circuit-breaker state metrics and the underlying service's health",
548                taxonomy_ref: "IEEE 24765: 'fault tolerance mechanism state change'",
549                affinity_tiers: TIER_BIT_A | TIER_BIT_B | TIER_BIT_N | TIER_BIT_O | TIER_BIT_X | TIER_BIT_Y,
550                confuser_motif: Some(MotifClass::DeploymentRegressionSlew),
551                margin_vs_confuser_threshold: 0.10,
552                primary_witness_tiers: TIER_BIT_A | TIER_BIT_B | TIER_BIT_N,
553                primary_witness_detectors: &["cusum", "pelt", "binary_segmentation"],
554            },
555            HeuristicEntry {
556                motif_class: MotifClass::DatabaseLockContention,
557                reason_code: ReasonCode::SustainedOutwardDrift,
558                candidate_interpretation: "database lock contention; rising query latency and queue depth",
559                provenance: Provenance::DatasetObserved,
560                recommended_action: PolicyState::Review,
561                drift_threshold: 0.5,
562                slew_threshold: 0.2,
563                boundary_density_threshold: 0.4,
564                min_correlation_count: 2,
565                max_correlation_count: u16::MAX,
566                min_duration_windows: 5,
567                max_duration_windows: u16::MAX,
568                weight_drift: 1.2,
569                weight_slew: 0.6,
570                weight_boundary: 1.0,
571                weight_correlation: 1.0,
572                weight_duration: 1.0,
573                evidence_dataset: "tadbench_db_lock",
574                evidence_dataset_doi: "10.5281/zenodo.6979726",
575                dashboard_hint: "Inspect database lock-wait stats and slow-query log for the active transactions",
576                taxonomy_ref: "IEEE 24765: 'concurrency fault'; A-L-R: synchronisation error",
577                affinity_tiers: TIER_BIT_L | TIER_BIT_M | TIER_BIT_C | TIER_BIT_EXTRA | TIER_BIT_V | TIER_BIT_AA,
578                confuser_motif: Some(MotifClass::DependencySlowdown),
579                margin_vs_confuser_threshold: 0.10,
580                primary_witness_tiers: TIER_BIT_L | TIER_BIT_M | TIER_BIT_EXTRA,
581                primary_witness_detectors: &["causal_lag", "correlation_break", "mahalanobis"],
582            },
583            HeuristicEntry {
584                motif_class: MotifClass::AuthenticationFailureSpike,
585                reason_code: ReasonCode::AbruptSlewViolation,
586                candidate_interpretation: "authentication subsystem partial outage; spike in 401/403 + downstream re-auth retries",
587                provenance: Provenance::DatasetObserved,
588                recommended_action: PolicyState::Escalate,
589                drift_threshold: 0.0,
590                slew_threshold: 0.5,
591                boundary_density_threshold: 0.2,
592                min_correlation_count: 1,
593                max_correlation_count: u16::MAX,
594                min_duration_windows: 1,
595                max_duration_windows: 30,
596                weight_drift: 0.3,
597                weight_slew: 1.5,
598                weight_boundary: 0.6,
599                weight_correlation: 0.9,
600                weight_duration: 0.5,
601                evidence_dataset: "tadbench_auth_fail",
602                evidence_dataset_doi: "10.5281/zenodo.6979726",
603                dashboard_hint: "Inspect auth-service health, token-issuance rate, and 401/403 distribution",
604                taxonomy_ref: "IEEE 24765: 'authentication subsystem failure'",
605                affinity_tiers: TIER_BIT_F | TIER_BIT_M | TIER_BIT_A | TIER_BIT_AA | TIER_BIT_Z | TIER_BIT_Y,
606                confuser_motif: Some(MotifClass::EpisodicTransientSpike),
607                margin_vs_confuser_threshold: 0.10,
608                primary_witness_tiers: TIER_BIT_F | TIER_BIT_M,
609                primary_witness_detectors: &["poisson_burst", "burst_after_silence", "flap"],
610            },
611            HeuristicEntry {
612                motif_class: MotifClass::ConfigDriftRegression,
613                reason_code: ReasonCode::AbruptSlewViolation,
614                candidate_interpretation: "step shift coinciding with version-config change",
615                provenance: Provenance::DatasetObserved,
616                recommended_action: PolicyState::Escalate,
617                drift_threshold: 0.0,
618                slew_threshold: 0.6,
619                boundary_density_threshold: 0.0,
620                min_correlation_count: 1,
621                max_correlation_count: 3,
622                min_duration_windows: 1,
623                max_duration_windows: u16::MAX,
624                weight_drift: 0.2,
625                weight_slew: 1.7,
626                weight_boundary: 0.5,
627                weight_correlation: 0.5,
628                weight_duration: 0.4,
629                evidence_dataset: "trainticket_anomaly_version_config",
630                evidence_dataset_doi: "10.5281/zenodo.6979726",
631                dashboard_hint: "Diff config artefacts between the last good window and the current; consider rollback",
632                taxonomy_ref: "IEEE 24765: 'configuration regression'; A-L-R: design-time fault",
633                affinity_tiers: TIER_BIT_H | TIER_BIT_G | TIER_BIT_Q | TIER_BIT_X | TIER_BIT_Y,
634                confuser_motif: Some(MotifClass::DeploymentRegressionSlew),
635                margin_vs_confuser_threshold: 0.10,
636                primary_witness_tiers: TIER_BIT_H | TIER_BIT_G | TIER_BIT_Q,
637                primary_witness_detectors: &["wasserstein_1d", "ddm", "kl_divergence"],
638            },
639
640            // ===== Tier-3: AIOps Challenge categories ====================
641
642            HeuristicEntry {
643                motif_class: MotifClass::PacketLossErrorEscalation,
644                reason_code: ReasonCode::SustainedOutwardDrift,
645                candidate_interpretation: "network-layer packet loss elevates error rate; sustained positive drift",
646                provenance: Provenance::DatasetObserved,
647                recommended_action: PolicyState::Escalate,
648                drift_threshold: 0.6,
649                slew_threshold: 0.0,
650                boundary_density_threshold: 0.3,
651                min_correlation_count: 2,
652                max_correlation_count: u16::MAX,
653                min_duration_windows: 3,
654                max_duration_windows: u16::MAX,
655                weight_drift: 1.6,
656                weight_slew: 0.4,
657                weight_boundary: 0.8,
658                weight_correlation: 1.2,
659                weight_duration: 0.8,
660                evidence_dataset: "aiops_challenge_packet_loss",
661                evidence_dataset_doi: "AIOps-Challenge-2020-2021",
662                dashboard_hint: "Inspect TCP retransmits / packet-loss counters at the network layer",
663                taxonomy_ref: "IEEE 24765: 'communication failure (lower layer)'",
664                affinity_tiers: TIER_BIT_A | TIER_BIT_G | TIER_BIT_F | TIER_BIT_E | TIER_BIT_AA | TIER_BIT_Z,
665                confuser_motif: Some(MotifClass::ErrorRateEscalation),
666                margin_vs_confuser_threshold: 0.10,
667                primary_witness_tiers: TIER_BIT_A | TIER_BIT_G | TIER_BIT_F,
668                primary_witness_detectors: &["chi_squared_proportion", "poisson_burst", "ddm"],
669            },
670            HeuristicEntry {
671                motif_class: MotifClass::NetworkDelayDependencyInflation,
672                reason_code: ReasonCode::SustainedOutwardDrift,
673                candidate_interpretation: "injected network delay on upstream link; gradual latency-increase pattern across consumers",
674                provenance: Provenance::DatasetObserved,
675                recommended_action: PolicyState::Review,
676                drift_threshold: 0.4,
677                slew_threshold: 0.0,
678                boundary_density_threshold: 0.3,
679                min_correlation_count: 2,
680                max_correlation_count: u16::MAX,
681                min_duration_windows: 5,
682                max_duration_windows: u16::MAX,
683                weight_drift: 1.3,
684                weight_slew: 0.2,
685                weight_boundary: 0.7,
686                weight_correlation: 1.3,
687                weight_duration: 1.1,
688                evidence_dataset: "aiops_challenge_network_delay",
689                evidence_dataset_doi: "AIOps-Challenge-2020-2021",
690                dashboard_hint: "Inspect inter-service RTT histograms and link-level latency gauges",
691                taxonomy_ref: "IEEE 24765: 'communication-path performance fault'",
692                affinity_tiers: TIER_BIT_L | TIER_BIT_C | TIER_BIT_M | TIER_BIT_V | TIER_BIT_X,
693                confuser_motif: Some(MotifClass::DependencySlowdown),
694                margin_vs_confuser_threshold: 0.10,
695                primary_witness_tiers: TIER_BIT_L | TIER_BIT_C | TIER_BIT_M,
696                primary_witness_detectors: &["causal_lag", "lof", "correlation_break"],
697            },
698            HeuristicEntry {
699                motif_class: MotifClass::DiskIoSaturation,
700                reason_code: ReasonCode::SustainedOutwardDrift,
701                candidate_interpretation: "disk I/O saturation; concave-up latency drift on storage-bound services",
702                provenance: Provenance::DatasetObserved,
703                recommended_action: PolicyState::Review,
704                drift_threshold: 0.5,
705                slew_threshold: 0.0,
706                boundary_density_threshold: 0.5,
707                min_correlation_count: 1,
708                max_correlation_count: u16::MAX,
709                min_duration_windows: 4,
710                max_duration_windows: u16::MAX,
711                weight_drift: 1.4,
712                weight_slew: 0.3,
713                weight_boundary: 1.2,
714                weight_correlation: 0.6,
715                weight_duration: 1.0,
716                evidence_dataset: "aiops_challenge_disk_exhaustion",
717                evidence_dataset_doi: "AIOps-Challenge-2020-2021",
718                dashboard_hint: "Inspect disk IOPS / await / queue depth; check for runaway log writes",
719                taxonomy_ref: "IEEE 24765: 'storage subsystem saturation'",
720                affinity_tiers: TIER_BIT_E | TIER_BIT_A | TIER_BIT_I | TIER_BIT_R | TIER_BIT_X | TIER_BIT_Y,
721                confuser_motif: Some(MotifClass::CpuSaturation),
722                margin_vs_confuser_threshold: 0.10,
723                primary_witness_tiers: TIER_BIT_E | TIER_BIT_I | TIER_BIT_R,
724                primary_witness_detectors: &["saturation_chain", "monotone_leak", "theil_sen_residual"],
725            },
726            HeuristicEntry {
727                motif_class: MotifClass::CpuSaturation,
728                reason_code: ReasonCode::SustainedOutwardDrift,
729                candidate_interpretation: "CPU saturation; latency drift with rising envelope occupancy",
730                provenance: Provenance::DatasetObserved,
731                recommended_action: PolicyState::Review,
732                drift_threshold: 0.5,
733                slew_threshold: 0.0,
734                boundary_density_threshold: 0.5,
735                min_correlation_count: 1,
736                max_correlation_count: u16::MAX,
737                min_duration_windows: 4,
738                max_duration_windows: u16::MAX,
739                weight_drift: 1.4,
740                weight_slew: 0.3,
741                weight_boundary: 1.2,
742                weight_correlation: 0.6,
743                weight_duration: 1.0,
744                evidence_dataset: "aiops_challenge_cpu_exhaustion",
745                evidence_dataset_doi: "AIOps-Challenge-2020-2021",
746                dashboard_hint: "Inspect CPU utilisation, run-queue length, and thread-level scheduling latency",
747                taxonomy_ref: "IEEE 24765: 'compute resource saturation'",
748                affinity_tiers: TIER_BIT_E | TIER_BIT_A | TIER_BIT_I | TIER_BIT_R | TIER_BIT_X | TIER_BIT_Y,
749                confuser_motif: Some(MotifClass::DiskIoSaturation),
750                margin_vs_confuser_threshold: 0.10,
751                primary_witness_tiers: TIER_BIT_E | TIER_BIT_I | TIER_BIT_R,
752                primary_witness_detectors: &["saturation_chain", "monotone_leak", "theil_sen_residual"],
753            },
754            HeuristicEntry {
755                motif_class: MotifClass::JvmHeapPressure,
756                reason_code: ReasonCode::SustainedOutwardDrift,
757                candidate_interpretation: "JVM heap pressure; sustained latency drift with rising variance and elevated GC frequency",
758                provenance: Provenance::DatasetObserved,
759                recommended_action: PolicyState::Review,
760                drift_threshold: 0.6,
761                slew_threshold: 0.0,
762                boundary_density_threshold: 0.4,
763                min_correlation_count: 1,
764                max_correlation_count: u16::MAX,
765                min_duration_windows: 5,
766                max_duration_windows: u16::MAX,
767                weight_drift: 1.6,
768                weight_slew: 0.4,
769                weight_boundary: 1.0,
770                weight_correlation: 0.5,
771                weight_duration: 1.2,
772                evidence_dataset: "aiops_challenge_memory_exhaustion",
773                evidence_dataset_doi: "AIOps-Challenge-2020-2021",
774                dashboard_hint: "Inspect jvm.memory.heap.used + gc.collection.count + minor/major GC ratio",
775                taxonomy_ref: "IEEE 24765: 'memory leak (JVM-specific)'; refines MemoryLeakDrift",
776                affinity_tiers: TIER_BIT_I | TIER_BIT_J | TIER_BIT_M | TIER_BIT_E | TIER_BIT_X | TIER_BIT_Y,
777                confuser_motif: Some(MotifClass::MemoryLeakDrift),
778                margin_vs_confuser_threshold: 0.10,
779                primary_witness_tiers: TIER_BIT_I | TIER_BIT_M | TIER_BIT_E,
780                primary_witness_detectors: &["monotone_leak", "saturation_chain", "mann_kendall"],
781            },
782            HeuristicEntry {
783                motif_class: MotifClass::JvmGcPause,
784                reason_code: ReasonCode::AbruptSlewViolation,
785                candidate_interpretation: "JVM stop-the-world GC pause: distinct latency spikes with regular cadence",
786                provenance: Provenance::DatasetObserved,
787                recommended_action: PolicyState::Watch,
788                drift_threshold: 0.0,
789                slew_threshold: 0.4,
790                boundary_density_threshold: 0.3,
791                min_correlation_count: 1,
792                max_correlation_count: 3,
793                min_duration_windows: 1,
794                max_duration_windows: 10,
795                weight_drift: 0.2,
796                weight_slew: 1.6,
797                weight_boundary: 1.0,
798                weight_correlation: 0.4,
799                weight_duration: 0.4,
800                evidence_dataset: "aiops_challenge_jvm_resource_exhaustion",
801                evidence_dataset_doi: "AIOps-Challenge-2020-2021",
802                dashboard_hint: "Inspect gc.duration percentiles + STW pause histograms; consider GC tuning",
803                taxonomy_ref: "IEEE 24765: 'stop-the-world pause (JVM-specific)'; refines GcPressureOscillation",
804                affinity_tiers: TIER_BIT_K | TIER_BIT_F | TIER_BIT_M | TIER_BIT_Z | TIER_BIT_AA,
805                confuser_motif: Some(MotifClass::AuthenticationFailureSpike),
806                margin_vs_confuser_threshold: 0.10,
807                primary_witness_tiers: TIER_BIT_K | TIER_BIT_F,
808                primary_witness_detectors: &["welch_psd", "autocorrelation_peak", "poisson_burst"],
809            },
810
811            // ===== Tier-4: MultiDim-Localization patterns ================
812
813            HeuristicEntry {
814                motif_class: MotifClass::ServiceGraphDriftPropagation,
815                reason_code: ReasonCode::SustainedOutwardDrift,
816                candidate_interpretation: "drift propagating along the service-call graph (multi-hop); affects sequentially-related services",
817                provenance: Provenance::DatasetObserved,
818                recommended_action: PolicyState::Escalate,
819                drift_threshold: 0.5,
820                slew_threshold: 0.2,
821                boundary_density_threshold: 0.4,
822                min_correlation_count: 4,
823                max_correlation_count: u16::MAX,
824                min_duration_windows: 5,
825                max_duration_windows: u16::MAX,
826                weight_drift: 1.4,
827                weight_slew: 0.7,
828                weight_boundary: 0.9,
829                weight_correlation: 1.7,
830                weight_duration: 1.0,
831                evidence_dataset: "multidim_localization_graph_propagation",
832                evidence_dataset_doi: "MultiDimension-Localization-NetManAIOps",
833                dashboard_hint: "Multi-hop drift propagation across ${CONTRIBUTING_COUNT} services; originator: ${ROOT_CAUSE_SERVICE} (signal ${ROOT_CAUSE_INDEX}); peak slew ${PEAK_SLEW}",
834                taxonomy_ref: "IEEE 24765: 'graph-structured fault propagation'",
835                affinity_tiers: TIER_BIT_L | TIER_BIT_C | TIER_BIT_G | TIER_BIT_EXTRA | TIER_BIT_V | TIER_BIT_X,
836                confuser_motif: Some(MotifClass::DependencySlowdown),
837                margin_vs_confuser_threshold: 0.10,
838                primary_witness_tiers: TIER_BIT_L | TIER_BIT_C | TIER_BIT_EXTRA,
839                primary_witness_detectors: &["correlation_matrix_distance", "correlation_break", "causal_lag"],
840            },
841            HeuristicEntry {
842                motif_class: MotifClass::HighDimAnomalyCluster,
843                reason_code: ReasonCode::SustainedOutwardDrift,
844                candidate_interpretation: "multi-metric correlated anomaly without single dominant signal; compound fault",
845                provenance: Provenance::DatasetObserved,
846                recommended_action: PolicyState::Review,
847                drift_threshold: 0.4,
848                slew_threshold: 0.2,
849                boundary_density_threshold: 0.3,
850                min_correlation_count: 6,
851                max_correlation_count: u16::MAX,
852                min_duration_windows: 4,
853                max_duration_windows: u16::MAX,
854                weight_drift: 1.0,
855                weight_slew: 0.6,
856                weight_boundary: 0.7,
857                weight_correlation: 2.0,
858                weight_duration: 0.9,
859                evidence_dataset: "multidim_localization_cluster",
860                evidence_dataset_doi: "MultiDimension-Localization-NetManAIOps",
861                dashboard_hint: "Inspect the multi-metric anomaly cluster as a unit; no single metric is dominant",
862                taxonomy_ref: "IEEE 24765: 'compound fault signature'",
863                affinity_tiers: TIER_BIT_L | TIER_BIT_C | TIER_BIT_R | TIER_BIT_V | TIER_BIT_Y,
864                confuser_motif: Some(MotifClass::MetricCorrelationCollapse),
865                margin_vs_confuser_threshold: 0.10,
866                primary_witness_tiers: TIER_BIT_L | TIER_BIT_C | TIER_BIT_R,
867                primary_witness_detectors: &["mahalanobis", "pca_reconstruction", "lof"],
868            },
869            HeuristicEntry {
870                motif_class: MotifClass::MetricCorrelationCollapse,
871                reason_code: ReasonCode::AbruptSlewViolation,
872                candidate_interpretation: "historically-correlated metrics decorrelate; structural regime shift",
873                provenance: Provenance::DatasetObserved,
874                recommended_action: PolicyState::Review,
875                drift_threshold: 0.0,
876                slew_threshold: 0.4,
877                boundary_density_threshold: 0.0,
878                min_correlation_count: 2,
879                max_correlation_count: u16::MAX,
880                min_duration_windows: 3,
881                max_duration_windows: u16::MAX,
882                weight_drift: 0.4,
883                weight_slew: 1.3,
884                weight_boundary: 0.5,
885                weight_correlation: 1.5,
886                weight_duration: 0.8,
887                evidence_dataset: "multidim_localization_correlation_collapse",
888                evidence_dataset_doi: "MultiDimension-Localization-NetManAIOps",
889                dashboard_hint: "Compare current pairwise metric correlations against the baseline correlation matrix",
890                taxonomy_ref: "IEEE 24765: 'structural model invalidation'",
891                affinity_tiers: TIER_BIT_L | TIER_BIT_C | TIER_BIT_EXTRA | TIER_BIT_V | TIER_BIT_AA,
892                confuser_motif: Some(MotifClass::HighDimAnomalyCluster),
893                margin_vs_confuser_threshold: 0.10,
894                primary_witness_tiers: TIER_BIT_L | TIER_BIT_C | TIER_BIT_EXTRA,
895                primary_witness_detectors: &["correlation_break", "correlation_matrix_distance", "mahalanobis"],
896            },
897
898            // ===== Tier-5: DeepTraLog log + trace fusion =================
899
900            HeuristicEntry {
901                motif_class: MotifClass::LogVolumeAnomaly,
902                reason_code: ReasonCode::SustainedOutwardDrift,
903                candidate_interpretation: "log-frequency outward drift on a service; structural log-rate anomaly",
904                provenance: Provenance::DatasetObserved,
905                recommended_action: PolicyState::Review,
906                drift_threshold: 0.5,
907                slew_threshold: 0.0,
908                boundary_density_threshold: 0.3,
909                min_correlation_count: 1,
910                max_correlation_count: u16::MAX,
911                min_duration_windows: 3,
912                max_duration_windows: u16::MAX,
913                weight_drift: 1.4,
914                weight_slew: 0.3,
915                weight_boundary: 0.8,
916                weight_correlation: 0.6,
917                weight_duration: 0.9,
918                evidence_dataset: "deeptralog_log_volume",
919                evidence_dataset_doi: "DeepTraLog-ICSE-2022",
920                dashboard_hint: "Inspect log-volume gauges per severity per service over the past hour",
921                taxonomy_ref: "IEEE 24765: 'diagnostic-output anomaly'",
922                affinity_tiers: TIER_BIT_A | TIER_BIT_S | TIER_BIT_I | TIER_BIT_X | TIER_BIT_Y,
923                confuser_motif: Some(MotifClass::LogSeverityEscalation),
924                margin_vs_confuser_threshold: 0.10,
925                primary_witness_tiers: TIER_BIT_A | TIER_BIT_S | TIER_BIT_I,
926                primary_witness_detectors: &["chi_squared_proportion", "poisson_burst", "mann_kendall"],
927            },
928            HeuristicEntry {
929                motif_class: MotifClass::LogTraceTemporalDecorrelation,
930                reason_code: ReasonCode::AbruptSlewViolation,
931                candidate_interpretation: "log timing departs from trace timing pattern; instrumentation/temporal divergence",
932                provenance: Provenance::DatasetObserved,
933                recommended_action: PolicyState::Review,
934                drift_threshold: 0.0,
935                slew_threshold: 0.3,
936                boundary_density_threshold: 0.2,
937                min_correlation_count: 1,
938                max_correlation_count: u16::MAX,
939                min_duration_windows: 3,
940                max_duration_windows: u16::MAX,
941                weight_drift: 0.4,
942                weight_slew: 1.3,
943                weight_boundary: 0.7,
944                weight_correlation: 0.7,
945                weight_duration: 0.7,
946                evidence_dataset: "deeptralog_temporal_mismatch",
947                evidence_dataset_doi: "DeepTraLog-ICSE-2022",
948                dashboard_hint: "Inspect log-event timestamps vs trace-span timestamps for the same request IDs",
949                taxonomy_ref: "IEEE 24765: 'instrumentation-temporal divergence'",
950                affinity_tiers: TIER_BIT_L | TIER_BIT_T | TIER_BIT_EXTRA | TIER_BIT_V | TIER_BIT_AA,
951                confuser_motif: Some(MotifClass::ServiceGraphDriftPropagation),
952                margin_vs_confuser_threshold: 0.10,
953                primary_witness_tiers: TIER_BIT_L | TIER_BIT_T | TIER_BIT_EXTRA,
954                primary_witness_detectors: &["correlation_break", "transfer_entropy", "correlation_matrix_distance"],
955            },
956            HeuristicEntry {
957                motif_class: MotifClass::LogSeverityEscalation,
958                reason_code: ReasonCode::SustainedOutwardDrift,
959                candidate_interpretation: "log severity distribution shift (more WARN/ERROR proportionally)",
960                provenance: Provenance::DatasetObserved,
961                recommended_action: PolicyState::Escalate,
962                drift_threshold: 0.6,
963                slew_threshold: 0.0,
964                boundary_density_threshold: 0.4,
965                min_correlation_count: 1,
966                max_correlation_count: u16::MAX,
967                min_duration_windows: 3,
968                max_duration_windows: u16::MAX,
969                weight_drift: 1.6,
970                weight_slew: 0.3,
971                weight_boundary: 1.0,
972                weight_correlation: 0.7,
973                weight_duration: 0.9,
974                evidence_dataset: "deeptralog_severity_shift",
975                evidence_dataset_doi: "DeepTraLog-ICSE-2022",
976                dashboard_hint: "Inspect log-severity distribution histograms; compare against the healthy-window baseline",
977                taxonomy_ref: "IEEE 24765: 'diagnostic severity escalation'",
978                affinity_tiers: TIER_BIT_H | TIER_BIT_G | TIER_BIT_A | TIER_BIT_X | TIER_BIT_Y,
979                confuser_motif: Some(MotifClass::LogVolumeAnomaly),
980                margin_vs_confuser_threshold: 0.10,
981                primary_witness_tiers: TIER_BIT_H | TIER_BIT_G | TIER_BIT_A,
982                primary_witness_detectors: &["wasserstein_1d", "chi_squared_proportion", "ddm"],
983            },
984
985            // ===== Tier-6: cross-cutting structural motifs ===============
986
987            HeuristicEntry {
988                motif_class: MotifClass::SaturationTrending,
989                reason_code: ReasonCode::SustainedOutwardDrift,
990                candidate_interpretation: "concave-up approach to a ceiling; generalises ResourceSaturation",
991                provenance: Provenance::FrameworkDesign,
992                recommended_action: PolicyState::Watch,
993                drift_threshold: 0.5,
994                slew_threshold: 0.1,
995                boundary_density_threshold: 0.5,
996                min_correlation_count: 1,
997                max_correlation_count: u16::MAX,
998                min_duration_windows: 6,
999                max_duration_windows: u16::MAX,
1000                weight_drift: 1.3,
1001                weight_slew: 0.6,
1002                weight_boundary: 1.4,
1003                weight_correlation: 0.5,
1004                weight_duration: 1.1,
1005                evidence_dataset: "FrameworkDesign",
1006                evidence_dataset_doi: "",
1007                dashboard_hint: "Project the current drift forward to estimate time-to-ceiling; consider scaling",
1008                taxonomy_ref: "IEEE 24765: 'asymptotic resource saturation'",
1009                affinity_tiers: TIER_BIT_E | TIER_BIT_M | TIER_BIT_I | TIER_BIT_J | TIER_BIT_X | TIER_BIT_Y,
1010                confuser_motif: Some(MotifClass::ConnectionPoolExhaustionDrift),
1011                margin_vs_confuser_threshold: 0.10,
1012                primary_witness_tiers: TIER_BIT_E | TIER_BIT_M | TIER_BIT_I,
1013                primary_witness_detectors: &["saturation_chain", "monotone_leak", "mann_kendall"],
1014            },
1015            HeuristicEntry {
1016                motif_class: MotifClass::EpisodicTransientSpike,
1017                reason_code: ReasonCode::AbruptSlewViolation,
1018                candidate_interpretation: "short-duration high-slew event that self-resolves",
1019                provenance: Provenance::FrameworkDesign,
1020                recommended_action: PolicyState::Watch,
1021                drift_threshold: 0.0,
1022                slew_threshold: 0.5,
1023                boundary_density_threshold: 0.0,
1024                min_correlation_count: 1,
1025                max_correlation_count: u16::MAX,
1026                min_duration_windows: 1,
1027                max_duration_windows: 4,
1028                weight_drift: 0.2,
1029                weight_slew: 1.6,
1030                weight_boundary: 0.3,
1031                weight_correlation: 0.4,
1032                weight_duration: 0.3,
1033                evidence_dataset: "FrameworkDesign",
1034                evidence_dataset_doi: "",
1035                dashboard_hint: "Note the timestamp; correlate with cron / scheduled jobs / external triggers",
1036                taxonomy_ref: "IEEE 24765: 'transient-only error'; A-L-R: transient fault",
1037                affinity_tiers: TIER_BIT_A | TIER_BIT_F | TIER_BIT_M | TIER_BIT_AA | TIER_BIT_Z,
1038                confuser_motif: Some(MotifClass::AuthenticationFailureSpike),
1039                margin_vs_confuser_threshold: 0.10,
1040                primary_witness_tiers: TIER_BIT_A | TIER_BIT_F | TIER_BIT_M,
1041                primary_witness_detectors: &["poisson_burst", "burst_after_silence", "scalar_threshold_3sigma"],
1042            },
1043            HeuristicEntry {
1044                motif_class: MotifClass::RegressiveDriftWithRecovery,
1045                reason_code: ReasonCode::DriftWithRecovery,
1046                candidate_interpretation: "outward drift followed by return to baseline; self-healing structural transient",
1047                provenance: Provenance::FrameworkDesign,
1048                recommended_action: PolicyState::Watch,
1049                drift_threshold: 0.4,
1050                slew_threshold: 0.0,
1051                boundary_density_threshold: 0.2,
1052                min_correlation_count: 1,
1053                max_correlation_count: u16::MAX,
1054                min_duration_windows: 4,
1055                max_duration_windows: 30,
1056                weight_drift: 1.2,
1057                weight_slew: 0.3,
1058                weight_boundary: 0.6,
1059                weight_correlation: 0.5,
1060                weight_duration: 0.8,
1061                evidence_dataset: "FrameworkDesign",
1062                evidence_dataset_doi: "",
1063                dashboard_hint: "No action required if recovery confirmed; record as a near-miss for trend analysis",
1064                taxonomy_ref: "IEEE 24765: 'self-healing transient drift'",
1065                affinity_tiers: TIER_BIT_I | TIER_BIT_J | TIER_BIT_M | TIER_BIT_T | TIER_BIT_X | TIER_BIT_Y,
1066                confuser_motif: Some(MotifClass::MemoryLeakDrift),
1067                margin_vs_confuser_threshold: 0.10,
1068                primary_witness_tiers: TIER_BIT_I | TIER_BIT_J | TIER_BIT_M,
1069                primary_witness_detectors: &["theil_sen_residual", "monotone_leak", "mann_kendall"],
1070            },
1071            HeuristicEntry {
1072                motif_class: MotifClass::EnvelopeBoundaryApproach,
1073                reason_code: ReasonCode::BoundaryApproach,
1074                candidate_interpretation: "first-time approach to the SLO envelope without recurrence or persistent drift; marginal-state transient",
1075                provenance: Provenance::FrameworkDesign,
1076                recommended_action: PolicyState::Watch,
1077                drift_threshold: 0.0,
1078                slew_threshold: 0.0,
1079                boundary_density_threshold: 0.0,
1080                min_correlation_count: 1,
1081                max_correlation_count: u16::MAX,
1082                min_duration_windows: 1,
1083                max_duration_windows: u16::MAX,
1084                weight_drift: 0.5,
1085                weight_slew: 0.5,
1086                weight_boundary: 0.8,
1087                weight_correlation: 0.3,
1088                weight_duration: 0.4,
1089                evidence_dataset: "FrameworkDesign",
1090                evidence_dataset_doi: "",
1091                dashboard_hint: "Note the timestamp; if recurrence is observed in subsequent windows escalate to CacheDegradationGrazing",
1092                taxonomy_ref: "IEEE 24765: 'marginal-state transient'; A-L-R: dormant fault",
1093                affinity_tiers: TIER_BIT_A | TIER_BIT_J | TIER_BIT_I | TIER_BIT_X | TIER_BIT_Y,
1094                confuser_motif: Some(MotifClass::EnvelopeBreach),
1095                margin_vs_confuser_threshold: 0.10,
1096                primary_witness_tiers: TIER_BIT_A | TIER_BIT_I | TIER_BIT_J,
1097                primary_witness_detectors: &["scalar_threshold_3sigma", "mann_kendall", "theil_sen_residual"],
1098            },
1099            HeuristicEntry {
1100                motif_class: MotifClass::EnvelopeBreach,
1101                reason_code: ReasonCode::EnvelopeViolation,
1102                candidate_interpretation: "envelope breach without abrupt slew evidence; smooth threshold crossing",
1103                provenance: Provenance::FrameworkDesign,
1104                recommended_action: PolicyState::Escalate,
1105                drift_threshold: 0.0,
1106                slew_threshold: 0.0,
1107                boundary_density_threshold: 0.0,
1108                min_correlation_count: 1,
1109                max_correlation_count: u16::MAX,
1110                min_duration_windows: 1,
1111                max_duration_windows: u16::MAX,
1112                weight_drift: 0.7,
1113                weight_slew: 0.3,
1114                weight_boundary: 0.7,
1115                weight_correlation: 0.6,
1116                weight_duration: 0.6,
1117                evidence_dataset: "FrameworkDesign",
1118                evidence_dataset_doi: "",
1119                dashboard_hint: "Inspect SLO/SLA threshold + the affected service's value distribution; threshold may be too tight",
1120                taxonomy_ref: "IEEE 24765: 'threshold breach (smooth)'; A-L-R: error → manifest",
1121                affinity_tiers: TIER_BIT_A | TIER_BIT_B | TIER_BIT_R | TIER_BIT_E | TIER_BIT_X | TIER_BIT_Y,
1122                confuser_motif: Some(MotifClass::EnvelopeBoundaryApproach),
1123                margin_vs_confuser_threshold: 0.10,
1124                primary_witness_tiers: TIER_BIT_A | TIER_BIT_B | TIER_BIT_R,
1125                primary_witness_detectors: &["scalar_threshold_3sigma", "cusum", "page_hinkley"],
1126            },
1127        ];
1128
1129        let mut i = 0;
1130        while i < canonical.len() && i < MAX {
1131            bank.entries[i] = Some(canonical[i]);
1132            bank.count += 1;
1133            i += 1;
1134        }
1135        bank
1136    }
1137
1138    /// Per-signal lookup (v0.1 wire shape).
1139    ///
1140    /// Used at signal-evaluation time inside `evaluate_signal`. Score
1141    /// composition is unit-weighted; multi-feature weighting happens at
1142    /// the episode level (`match_episode`).
1143    pub fn lookup(
1144        &self,
1145        reason_code: ReasonCode,
1146        drift_persistence: f64,
1147        slew_magnitude: f64,
1148    ) -> SemanticDisposition {
1149        let mut best_match: Option<MotifClass> = None;
1150        let mut best_score: f64 = 0.0;
1151        let mut best_provenance_rank: u8 = 0;
1152        let mut best_index: usize = usize::MAX;
1153
1154        let mut i = 0;
1155        while i < self.count {
1156            if let Some(entry) = &self.entries[i] {
1157                if entry.reason_code == reason_code {
1158                    let mut score: f64 = 1.0; // base score for reason code match
1159                    if drift_persistence >= entry.drift_threshold {
1160                        score += drift_persistence;
1161                    }
1162                    if slew_magnitude >= entry.slew_threshold {
1163                        score += slew_magnitude;
1164                    }
1165                    let prov_rank = provenance_rank(entry.provenance);
1166                    let take = score > best_score
1167                        || (score == best_score && prov_rank > best_provenance_rank)
1168                        || (score == best_score && prov_rank == best_provenance_rank && i < best_index);
1169                    if take {
1170                        best_score = score;
1171                        best_match = Some(entry.motif_class);
1172                        best_provenance_rank = prov_rank;
1173                        best_index = i;
1174                    }
1175                }
1176            }
1177            i += 1;
1178        }
1179
1180        match best_match {
1181            Some(motif) => SemanticDisposition::Named(motif),
1182            None => SemanticDisposition::Unknown, // Endoductive mode
1183        }
1184    }
1185
1186    /// Per-episode lookup (Session 3 addition).
1187    ///
1188    /// Uses ALL features available at episode close: peak slew (from
1189    /// `episode.structural_signature.peak_slew_magnitude`), signal
1190    /// correlation count, duration in windows, the supplied average
1191    /// drift persistence and average boundary density. Score
1192    /// composition: `Σ weight_f × feature_f` for the five features,
1193    /// gated by `reason_code` match plus the min/max range checks on
1194    /// correlation count and duration windows.
1195    ///
1196    /// Tie-breakers (deterministic, no FP randomness):
1197    ///   1. higher provenance rank wins
1198    ///      (`FieldValidated > DatasetObserved > FrameworkDesign`)
1199    ///   2. lower index in the entries array wins (canonical ordering)
1200    pub fn match_episode(
1201        &self,
1202        episode: &DebugEpisode,
1203        avg_drift_persistence: f64,
1204        avg_boundary_density: f64,
1205    ) -> SemanticDisposition {
1206        let mut best_match: Option<MotifClass> = None;
1207        let mut best_score: f64 = 0.0;
1208        let mut best_provenance_rank: u8 = 0;
1209        let mut best_index: usize = usize::MAX;
1210
1211        let correlation_count = episode.contributing_signal_count;
1212        let duration_windows: u16 = if episode.end_window >= episode.start_window {
1213            let d = episode.end_window - episode.start_window + 1;
1214            if d > u16::MAX as u64 { u16::MAX } else { d as u16 }
1215        } else {
1216            0
1217        };
1218        let peak_slew = episode.structural_signature.peak_slew_magnitude;
1219        let slew_mag = if peak_slew >= 0.0 { peak_slew } else { -peak_slew };
1220
1221        let mut i = 0;
1222        while i < self.count {
1223            if let Some(entry) = &self.entries[i] {
1224                if entry.reason_code != episode.primary_reason_code {
1225                    i += 1;
1226                    continue;
1227                }
1228                if correlation_count < entry.min_correlation_count
1229                    || correlation_count > entry.max_correlation_count
1230                {
1231                    i += 1;
1232                    continue;
1233                }
1234                if duration_windows < entry.min_duration_windows
1235                    || duration_windows > entry.max_duration_windows
1236                {
1237                    i += 1;
1238                    continue;
1239                }
1240
1241                let mut score: f64 = 1.0; // base for reason-code + range gates passing
1242
1243                if avg_drift_persistence >= entry.drift_threshold {
1244                    score += entry.weight_drift * avg_drift_persistence;
1245                }
1246                if slew_mag >= entry.slew_threshold {
1247                    score += entry.weight_slew * slew_mag;
1248                }
1249                if avg_boundary_density >= entry.boundary_density_threshold {
1250                    score += entry.weight_boundary * avg_boundary_density;
1251                }
1252                // Correlation contribution (count is unitless; scale by 0.1 to
1253                // keep it comparable to fractional features).
1254                score += entry.weight_correlation * (correlation_count as f64) * 0.1;
1255                // Duration contribution (windows; scale by 0.05 — long episodes
1256                // mildly boost long-duration motifs without overwhelming).
1257                score += entry.weight_duration * (duration_windows as f64) * 0.05;
1258
1259                let prov_rank = provenance_rank(entry.provenance);
1260                let take = score > best_score
1261                    || (score == best_score && prov_rank > best_provenance_rank)
1262                    || (score == best_score && prov_rank == best_provenance_rank && i < best_index);
1263                if take {
1264                    best_score = score;
1265                    best_match = Some(entry.motif_class);
1266                    best_provenance_rank = prov_rank;
1267                    best_index = i;
1268                }
1269            }
1270            i += 1;
1271        }
1272
1273        match best_match {
1274            Some(motif) => SemanticDisposition::Named(motif),
1275            None => SemanticDisposition::Unknown,
1276        }
1277    }
1278
1279    /// Per-episode lookup with confidence margin (Phase 6 addition).
1280    ///
1281    /// Same scoring as `match_episode`, but additionally tracks the
1282    /// runner-up scored motif so operators can read the
1283    /// top-vs-runner-up margin (see `MatchConfidence`). Tie-breakers
1284    /// (provenance rank, lower index) are unchanged.
1285    pub fn match_episode_with_confidence(
1286        &self,
1287        episode: &DebugEpisode,
1288        avg_drift_persistence: f64,
1289        avg_boundary_density: f64,
1290    ) -> MatchConfidence {
1291        let mut best_match: Option<MotifClass> = None;
1292        let mut best_score: f64 = 0.0;
1293        let mut best_provenance_rank: u8 = 0;
1294        let mut best_index: usize = usize::MAX;
1295        let mut runner_up_match: Option<MotifClass> = None;
1296        let mut runner_up_score: f64 = 0.0;
1297
1298        let correlation_count = episode.contributing_signal_count;
1299        let duration_windows: u16 = if episode.end_window >= episode.start_window {
1300            let d = episode.end_window - episode.start_window + 1;
1301            if d > u16::MAX as u64 { u16::MAX } else { d as u16 }
1302        } else {
1303            0
1304        };
1305        let peak_slew = episode.structural_signature.peak_slew_magnitude;
1306        let slew_mag = if peak_slew >= 0.0 { peak_slew } else { -peak_slew };
1307
1308        let mut i = 0;
1309        while i < self.count {
1310            if let Some(entry) = &self.entries[i] {
1311                if entry.reason_code != episode.primary_reason_code {
1312                    i += 1;
1313                    continue;
1314                }
1315                if correlation_count < entry.min_correlation_count
1316                    || correlation_count > entry.max_correlation_count
1317                {
1318                    i += 1;
1319                    continue;
1320                }
1321                if duration_windows < entry.min_duration_windows
1322                    || duration_windows > entry.max_duration_windows
1323                {
1324                    i += 1;
1325                    continue;
1326                }
1327
1328                let mut score: f64 = 1.0;
1329                if avg_drift_persistence >= entry.drift_threshold {
1330                    score += entry.weight_drift * avg_drift_persistence;
1331                }
1332                if slew_mag >= entry.slew_threshold {
1333                    score += entry.weight_slew * slew_mag;
1334                }
1335                if avg_boundary_density >= entry.boundary_density_threshold {
1336                    score += entry.weight_boundary * avg_boundary_density;
1337                }
1338                score += entry.weight_correlation * (correlation_count as f64) * 0.1;
1339                score += entry.weight_duration * (duration_windows as f64) * 0.05;
1340
1341                let prov_rank = provenance_rank(entry.provenance);
1342                let take_top = score > best_score
1343                    || (score == best_score && prov_rank > best_provenance_rank)
1344                    || (score == best_score && prov_rank == best_provenance_rank && i < best_index);
1345                if take_top {
1346                    // Demote previous top to runner-up
1347                    if let Some(prev_top) = best_match {
1348                        if score > runner_up_score
1349                            || (score == runner_up_score && best_score > runner_up_score)
1350                        {
1351                            runner_up_match = Some(prev_top);
1352                            runner_up_score = best_score;
1353                        }
1354                    }
1355                    best_score = score;
1356                    best_match = Some(entry.motif_class);
1357                    best_provenance_rank = prov_rank;
1358                    best_index = i;
1359                } else if score > runner_up_score {
1360                    runner_up_score = score;
1361                    runner_up_match = Some(entry.motif_class);
1362                }
1363            }
1364            i += 1;
1365        }
1366
1367        let disposition = match best_match {
1368            Some(m) => SemanticDisposition::Named(m),
1369            None => SemanticDisposition::Unknown,
1370        };
1371        let margin = if best_score > 0.0 {
1372            ((best_score - runner_up_score) / best_score).clamp(0.0, 1.0)
1373        } else {
1374            0.0
1375        };
1376        MatchConfidence {
1377            disposition,
1378            top_score: best_score,
1379            runner_up_score,
1380            runner_up_motif: runner_up_match,
1381            margin,
1382            tier_consensus_factor: 0.0,
1383            confuser_motif: None,
1384            confuser_score: 0.0,
1385            margin_vs_confuser: 0.0,
1386        }
1387    }
1388
1389    /// Per-episode lookup with multi-detector consensus context
1390    /// (post-Phase-8 fusion-aware lookup).
1391    ///
1392    /// Same scoring as `match_episode_with_confidence`, but additionally
1393    /// adds a consensus boost: `consensus_boost = (episode_max_consensus
1394    /// / max_detectors) * 1.0`, summed into the score for every motif
1395    /// that survives the reason-code + range gates. Result: when multiple
1396    /// detectors agree, the bank is MORE confident in the typed
1397    /// interpretation; when only DSFB-structural fired, the bank still
1398    /// produces the structural motif but with smaller margin (signaling
1399    /// "this is a structurally-detected anomaly that flat detectors
1400    /// missed — consider whether DSFB is over-firing or whether the
1401    /// flat detectors are missing real signal").
1402    ///
1403    /// `episode_max_consensus` is the maximum consensus_count observed
1404    /// in any (window, signal) cell within the episode's window range.
1405    /// `max_detectors` is the total number of enabled detectors
1406    /// (typically 12 with all-default fusion).
1407    pub fn match_episode_with_consensus(
1408        &self,
1409        episode: &DebugEpisode,
1410        avg_drift_persistence: f64,
1411        avg_boundary_density: f64,
1412        episode_max_consensus: u8,
1413        max_detectors: u8,
1414    ) -> MatchConfidence {
1415        let consensus_factor = if max_detectors > 0 {
1416            episode_max_consensus as f64 / max_detectors as f64
1417        } else { 0.0 };
1418
1419        let mut best_match: Option<MotifClass> = None;
1420        let mut best_score: f64 = 0.0;
1421        let mut best_provenance_rank: u8 = 0;
1422        let mut best_index: usize = usize::MAX;
1423        let mut runner_up_match: Option<MotifClass> = None;
1424        let mut runner_up_score: f64 = 0.0;
1425
1426        let correlation_count = episode.contributing_signal_count;
1427        let duration_windows: u16 = if episode.end_window >= episode.start_window {
1428            let d = episode.end_window - episode.start_window + 1;
1429            if d > u16::MAX as u64 { u16::MAX } else { d as u16 }
1430        } else { 0 };
1431        let peak_slew = episode.structural_signature.peak_slew_magnitude;
1432        let slew_mag = if peak_slew >= 0.0 { peak_slew } else { -peak_slew };
1433
1434        let mut i = 0;
1435        while i < self.count {
1436            if let Some(entry) = &self.entries[i] {
1437                if entry.reason_code != episode.primary_reason_code {
1438                    i += 1;
1439                    continue;
1440                }
1441                if correlation_count < entry.min_correlation_count
1442                    || correlation_count > entry.max_correlation_count
1443                {
1444                    i += 1;
1445                    continue;
1446                }
1447                if duration_windows < entry.min_duration_windows
1448                    || duration_windows > entry.max_duration_windows
1449                {
1450                    i += 1;
1451                    continue;
1452                }
1453
1454                let mut score: f64 = 1.0;
1455                if avg_drift_persistence >= entry.drift_threshold {
1456                    score += entry.weight_drift * avg_drift_persistence;
1457                }
1458                if slew_mag >= entry.slew_threshold {
1459                    score += entry.weight_slew * slew_mag;
1460                }
1461                if avg_boundary_density >= entry.boundary_density_threshold {
1462                    score += entry.weight_boundary * avg_boundary_density;
1463                }
1464                score += entry.weight_correlation * (correlation_count as f64) * 0.1;
1465                score += entry.weight_duration * (duration_windows as f64) * 0.05;
1466                // Consensus boost — scales linearly with the fraction
1467                // of detectors that agreed. Doesn't gate the match;
1468                // amplifies the score so that motifs corroborated by
1469                // many independent detectors get higher margin.
1470                score += consensus_factor;
1471
1472                let prov_rank = provenance_rank(entry.provenance);
1473                let take_top = score > best_score
1474                    || (score == best_score && prov_rank > best_provenance_rank)
1475                    || (score == best_score && prov_rank == best_provenance_rank && i < best_index);
1476                if take_top {
1477                    if let Some(prev_top) = best_match {
1478                        if score > runner_up_score
1479                            || (score == runner_up_score && best_score > runner_up_score)
1480                        {
1481                            runner_up_match = Some(prev_top);
1482                            runner_up_score = best_score;
1483                        }
1484                    }
1485                    best_score = score;
1486                    best_match = Some(entry.motif_class);
1487                    best_provenance_rank = prov_rank;
1488                    best_index = i;
1489                } else if score > runner_up_score {
1490                    runner_up_score = score;
1491                    runner_up_match = Some(entry.motif_class);
1492                }
1493            }
1494            i += 1;
1495        }
1496
1497        let disposition = match best_match {
1498            Some(m) => SemanticDisposition::Named(m),
1499            None => SemanticDisposition::Unknown,
1500        };
1501        let margin = if best_score > 0.0 {
1502            ((best_score - runner_up_score) / best_score).clamp(0.0, 1.0)
1503        } else { 0.0 };
1504        MatchConfidence {
1505            disposition,
1506            top_score: best_score,
1507            runner_up_score,
1508            runner_up_motif: runner_up_match,
1509            margin,
1510            tier_consensus_factor: 0.0,
1511            confuser_motif: None,
1512            confuser_score: 0.0,
1513            margin_vs_confuser: 0.0,
1514        }
1515    }
1516
1517    /// Get recommended action for a matched motif
1518    pub fn recommended_action(&self, motif: MotifClass) -> PolicyState {
1519        let mut i = 0;
1520        while i < self.count {
1521            if let Some(entry) = &self.entries[i] {
1522                if entry.motif_class == motif {
1523                    return entry.recommended_action;
1524                }
1525            }
1526            i += 1;
1527        }
1528        PolicyState::Watch // default if motif not found
1529    }
1530
1531    /// Number of entries currently populated.
1532    pub fn count(&self) -> usize {
1533        self.count
1534    }
1535
1536    /// Iterate over the populated entries in canonical-bank order.
1537    ///
1538    /// Returns `&HeuristicEntry` items in the order they were appended
1539    /// to the bank — the same order used by the deterministic
1540    /// tie-breaker rule (lower index wins on score ties at the same
1541    /// provenance rank). Used by the `demo` feature's documentation /
1542    /// figure-rendering layer to emit the 32-motif × 27-tier affinity
1543    /// matrix without requiring access to the private `entries`
1544    /// storage.
1545    pub fn entries_iter(&self) -> impl Iterator<Item = &HeuristicEntry> {
1546        self.entries[..self.count].iter().filter_map(|e| e.as_ref())
1547    }
1548
1549    /// Look up an entry by its `MotifClass` (for documentation /
1550    /// dashboard rendering of the matched motif's metadata).
1551    pub fn entry_for(&self, motif: MotifClass) -> Option<&HeuristicEntry> {
1552        let mut i = 0;
1553        while i < self.count {
1554            if let Some(entry) = &self.entries[i] {
1555                if entry.motif_class == motif {
1556                    return Some(entry);
1557                }
1558            }
1559            i += 1;
1560        }
1561        None
1562    }
1563
1564    /// Bank-aware fusion: per-motif effective minimum consensus threshold,
1565    /// derived from the motif entry's provenance ladder + correlation count.
1566    ///
1567    /// Direction #4 (provenance-tier rules) + Direction #1 (per-motif consensus)
1568    /// from the Phase-1 panel proposal:
1569    ///
1570    /// * `FieldValidated` motifs trust experience — threshold lowered by 1.
1571    /// * `DatasetObserved` motifs use the global threshold unchanged.
1572    /// * `FrameworkDesign` motifs require additional corroboration —
1573    ///   threshold raised by 1 (still hypothesis-stage typing).
1574    /// * Multi-service motifs (`min_correlation_count >= 3`) require
1575    ///   stronger consensus regardless of provenance — extra +1.
1576    ///
1577    /// Floor of 1 enforced (zero-consensus typing is rejected).
1578    pub fn effective_min_consensus(&self, entry: &HeuristicEntry, global_min: u8) -> u8 {
1579        let mut t = global_min as i16;
1580        match entry.provenance {
1581            Provenance::FieldValidated => { t -= 1; }
1582            Provenance::DatasetObserved => { /* unchanged */ }
1583            Provenance::FrameworkDesign => { t += 1; }
1584        }
1585        if entry.min_correlation_count >= 3 { t += 1; }
1586        if t < 1 { t = 1; }
1587        if t > 255 { t = 255; }
1588        t as u8
1589    }
1590
1591    /// Bank-aware fusion convenience: look up the effective per-motif
1592    /// consensus threshold by `MotifClass`. Falls back to `global_min`
1593    /// if the motif is not in the bank.
1594    pub fn effective_min_consensus_for_motif(
1595        &self, motif: MotifClass, global_min: u8,
1596    ) -> u8 {
1597        match self.entry_for(motif) {
1598            Some(entry) => self.effective_min_consensus(entry, global_min),
1599            None => global_min,
1600        }
1601    }
1602
1603    /// Phase 2 — Direction #2 (tier-affinity matrix) + Direction #5
1604    /// (margin feedback). Score each candidate motif using a *tier-
1605    /// restricted* consensus computed from per-cell + per-window
1606    /// tier-fired bitmasks; motifs whose affinity tiers actually fired
1607    /// score higher than motifs whose affinity tiers were silent.
1608    ///
1609    /// Inputs:
1610    /// * `cell_tier_mask[w * num_signals + s]` — bitmask of tiers that
1611    ///   contributed at cell (w, s). One bit per tier.
1612    /// * `window_tier_mask[w]` — bitmask of tiers contributing at the
1613    ///   window level (multivariate / global detectors).
1614    /// * `episode_max_consensus` — fallback consensus value (used to
1615    ///   set the consensus_factor for motifs unmapped by reason-code
1616    ///   affinity, i.e. `Admissible` default).
1617    ///
1618    /// Returns the best-scoring motif, with `margin` reflecting the
1619    /// gap between top and runner-up under tier-affinity scoring.
1620    /// Determinism preserved (deterministic tie-break on provenance
1621    /// rank then index, identical to `match_episode_with_consensus`).
1622    /// Bank-side ablation axes (Phase η.4).
1623    ///
1624    /// Each flag controls one of the three bank-internal fusion axes
1625    /// (axes 4 / 7 / 8 of the 9-axis ladder). Default `true` preserves
1626    /// pre-Phase-η.4 behaviour exactly. Setting any flag to `false`
1627    /// disables that single bank-internal axis for ablation studies.
1628    pub fn match_episode_with_tier_affinity(
1629        &self,
1630        episode: &DebugEpisode,
1631        avg_drift_persistence: f64,
1632        avg_boundary_density: f64,
1633        cell_tier_mask: &[u32],
1634        window_tier_mask: &[u32],
1635        num_signals: usize,
1636        max_active_tiers: u8,
1637        episode_max_consensus: u8,
1638    ) -> MatchConfidence {
1639        // Default delegate: all bank-internal axes enabled.
1640        self.match_episode_with_tier_affinity_axes(
1641            episode, avg_drift_persistence, avg_boundary_density,
1642            cell_tier_mask, window_tier_mask, num_signals,
1643            max_active_tiers, episode_max_consensus,
1644            true, true, true,
1645        )
1646    }
1647
1648    /// Phase η.4 — full per-axis ablation entry point.
1649    ///
1650    /// Identical to `match_episode_with_tier_affinity` but with three
1651    /// extra ablation flags:
1652    ///
1653    /// - `use_zero_tier_filter` (axis 4) — drop motifs whose affinity
1654    ///   tiers are all silent in the episode range.
1655    /// - `use_disambiguator_boost` (axis 7) — multiplicative boost
1656    ///   when distinguishing tiers fire (motif AND NOT confuser).
1657    /// - `use_primary_witness_tier_gate` (axis 8) — drop motifs whose
1658    ///   declared `primary_witness_tiers` are silent.
1659    ///
1660    /// Theorem 9 preservation: each flag toggles a deterministic
1661    /// branch; same `(episode, masks, flags)` triple → same
1662    /// MatchConfidence byte-for-byte.
1663    pub fn match_episode_with_tier_affinity_axes(
1664        &self,
1665        episode: &DebugEpisode,
1666        avg_drift_persistence: f64,
1667        avg_boundary_density: f64,
1668        cell_tier_mask: &[u32],
1669        window_tier_mask: &[u32],
1670        num_signals: usize,
1671        max_active_tiers: u8,
1672        episode_max_consensus: u8,
1673        use_zero_tier_filter: bool,
1674        use_disambiguator_boost: bool,
1675        use_primary_witness_tier_gate: bool,
1676    ) -> MatchConfidence {
1677        let mut best_match: Option<MotifClass> = None;
1678        let mut best_score: f64 = 0.0;
1679        let mut best_provenance_rank: u8 = 0;
1680        let mut best_index: usize = usize::MAX;
1681        let mut best_consensus_factor: f64 = 0.0;
1682        let mut runner_up_match: Option<MotifClass> = None;
1683        let mut runner_up_score: f64 = 0.0;
1684        // Phase 5.6 — store every entry's score so we can look up the
1685        // top motif's declared confuser score without a second pass.
1686        let mut entry_scores: [f64; MAX] = [0.0; MAX];
1687
1688        // Phase 6 — pre-compute each entry's affinity mask + disambiguator
1689        // mask (affinity AND NOT confuser.affinity). Two-pass: first pass
1690        // collects all (entry_index, motif_class, affinity_mask, confuser_motif);
1691        // second pass during scoring uses these to compute the
1692        // disambiguator boost = popcount of disambig tiers that fired in
1693        // the episode range, divided by popcount of disambig mask.
1694        let mut entry_affinity: [u32; MAX] = [0; MAX];
1695        let mut entry_confuser_idx: [usize; MAX] = [usize::MAX; MAX];
1696        let mut p = 0;
1697        while p < self.count {
1698            if let Some(e) = &self.entries[p] {
1699                let aff = if e.affinity_tiers != 0 { e.affinity_tiers }
1700                          else { affinity_tiers_for(e.reason_code, e.min_correlation_count) };
1701                if p < MAX { entry_affinity[p] = aff; }
1702                // Locate confuser's entry index.
1703                if let Some(cm) = e.confuser_motif {
1704                    let mut q = 0;
1705                    while q < self.count {
1706                        if let Some(ce) = &self.entries[q] {
1707                            if ce.motif_class == cm && q < MAX {
1708                                entry_confuser_idx[p] = q;
1709                                break;
1710                            }
1711                        }
1712                        q += 1;
1713                    }
1714                }
1715            }
1716            p += 1;
1717        }
1718
1719        let correlation_count = episode.contributing_signal_count;
1720        let duration_windows: u16 = if episode.end_window >= episode.start_window {
1721            let d = episode.end_window - episode.start_window + 1;
1722            if d > u16::MAX as u64 { u16::MAX } else { d as u16 }
1723        } else { 0 };
1724        let peak_slew = episode.structural_signature.peak_slew_magnitude;
1725        let slew_mag = if peak_slew >= 0.0 { peak_slew } else { -peak_slew };
1726
1727        let start_w = episode.start_window as usize;
1728        let end_w = episode.end_window as usize;
1729        let num_windows = window_tier_mask.len();
1730
1731        let mut i = 0;
1732        while i < self.count {
1733            if let Some(entry) = &self.entries[i] {
1734                if entry.reason_code != episode.primary_reason_code { i += 1; continue; }
1735                if correlation_count < entry.min_correlation_count
1736                    || correlation_count > entry.max_correlation_count
1737                { i += 1; continue; }
1738                if duration_windows < entry.min_duration_windows
1739                    || duration_windows > entry.max_duration_windows
1740                { i += 1; continue; }
1741
1742                // Per-motif tier-affinity consensus: scan episode range,
1743                // popcount tier bits AND-ed against motif's affinity mask.
1744                // Phase 2.5 — prefer the hand-curated per-motif mask
1745                // (`entry.affinity_tiers`); fall back to reason-code-derived
1746                // default for motifs without a curated mask (mask == 0).
1747                let affinity = if entry.affinity_tiers != 0 {
1748                    entry.affinity_tiers
1749                } else {
1750                    affinity_tiers_for(entry.reason_code, entry.min_correlation_count)
1751                };
1752                let mut motif_consensus: u8 = 0;
1753                let mut w = start_w;
1754                while w <= end_w && w < num_windows {
1755                    let win_bits = window_tier_mask[w] & affinity;
1756                    let win_pop = win_bits.count_ones() as u8;
1757                    let mut s = 0;
1758                    while s < num_signals {
1759                        let idx = w * num_signals + s;
1760                        if idx < cell_tier_mask.len() {
1761                            let cell_bits = cell_tier_mask[idx] & affinity;
1762                            let total = cell_bits.count_ones() as u8 + win_pop;
1763                            if total > motif_consensus { motif_consensus = total; }
1764                        }
1765                        s += 1;
1766                    }
1767                    w += 1;
1768                }
1769                let max_motif_tiers = (affinity.count_ones() as u8).max(1);
1770                let motif_consensus_capped = motif_consensus.min(max_motif_tiers);
1771                let consensus_factor = motif_consensus_capped as f64
1772                                       / max_motif_tiers as f64;
1773
1774                // Path 2 — zero-tier-firing filter. If a motif's curated
1775                // affinity has bits set BUT none of those tiers fired in
1776                // any cell of the episode range, this motif has no
1777                // detector evidence supporting it. Skip without scoring.
1778                // Motifs with the all-tiers default mask (`u32::MAX`) or
1779                // missing affinity (`affinity == 0` falling back to derived
1780                // mask = u32::MAX for Admissible) bypass the filter so
1781                // that legacy / unmapped motifs still score normally.
1782                // Phase η.4 axis 4 — `use_zero_tier_filter=false` admits
1783                // motifs even when no affinity tiers fired.
1784                if use_zero_tier_filter
1785                    && entry.affinity_tiers != 0
1786                    && affinity != u32::MAX
1787                    && motif_consensus == 0
1788                {
1789                    i += 1;
1790                    continue;
1791                }
1792
1793                // Phase 7 — primary witness tier gate. Strict form of the
1794                // zero-tier filter: a curated subset of the affinity must
1795                // ACTUALLY fire (not just any affinity tier). Hard-
1796                // disqualifies the motif if its declared witness tiers
1797                // are silent in the episode range. Disabled when
1798                // `primary_witness_tiers == 0` (legacy semantics).
1799                // Phase η.4 axis 8 — `use_primary_witness_tier_gate=false`
1800                // admits motifs without their named witness tiers firing.
1801                if use_primary_witness_tier_gate && entry.primary_witness_tiers != 0 {
1802                    let mut witness_fired = false;
1803                    let mut wp = start_w;
1804                    while wp <= end_w && wp < num_windows && !witness_fired {
1805                        if window_tier_mask[wp] & entry.primary_witness_tiers != 0 {
1806                            witness_fired = true;
1807                            break;
1808                        }
1809                        let mut sp = 0;
1810                        while sp < num_signals {
1811                            let idxp = wp * num_signals + sp;
1812                            if idxp < cell_tier_mask.len()
1813                                && cell_tier_mask[idxp] & entry.primary_witness_tiers != 0
1814                            {
1815                                witness_fired = true;
1816                                break;
1817                            }
1818                            sp += 1;
1819                        }
1820                        wp += 1;
1821                    }
1822                    if !witness_fired {
1823                        i += 1;
1824                        continue;
1825                    }
1826                }
1827
1828                let mut score: f64 = 1.0;
1829                if avg_drift_persistence >= entry.drift_threshold {
1830                    score += entry.weight_drift * avg_drift_persistence;
1831                }
1832                if slew_mag >= entry.slew_threshold {
1833                    score += entry.weight_slew * slew_mag;
1834                }
1835                if avg_boundary_density >= entry.boundary_density_threshold {
1836                    score += entry.weight_boundary * avg_boundary_density;
1837                }
1838                score += entry.weight_correlation * (correlation_count as f64) * 0.1;
1839                score += entry.weight_duration * (duration_windows as f64) * 0.05;
1840                // Tier-affinity-conditional consensus boost (Phase 2.5).
1841                //
1842                // Multiplicative scaling — proportional to all other score
1843                // components. `consensus_factor in [0, 1]` represents the
1844                // fraction of motif-relevant tiers that actually fired.
1845                // A motif with all relevant tiers firing gets a 50% boost
1846                // (weight 0.5); zero firing leaves the score unchanged.
1847                // This formulation matters for episodes where drift/slew
1848                // contributions yield large scores (e.g. F-11's ep[0] at
1849                // ~8000) that dwarfed the previous additive +1.5 boost.
1850                score *= 1.0 + 0.5 * consensus_factor;
1851
1852                // Phase 6 — confuser-aware disambiguator boost. If this
1853                // motif declares a confuser, find tiers in this motif's
1854                // affinity mask that are NOT in the confuser's mask
1855                // (the structurally-distinguishing tiers). Boost score
1856                // proportional to fraction of those that fired in the
1857                // episode. Widens margin against the confuser when truly
1858                // distinguishing evidence is present.
1859                // Phase η.4 axis 7 — `use_disambiguator_boost=false`
1860                // skips the boost entirely; score remains unmodified.
1861                if use_disambiguator_boost && i < MAX && entry_confuser_idx[i] != usize::MAX {
1862                    let confuser_aff = entry_affinity[entry_confuser_idx[i]];
1863                    let disambig_mask = affinity & !confuser_aff;
1864                    let disambig_max = disambig_mask.count_ones() as u8;
1865                    if disambig_max > 0 {
1866                        // Re-scan episode range for disambig firings.
1867                        let mut disambig_fired: u8 = 0;
1868                        let mut w2 = start_w;
1869                        while w2 <= end_w && w2 < num_windows {
1870                            let win_d = (window_tier_mask[w2] & disambig_mask).count_ones() as u8;
1871                            let mut s2 = 0;
1872                            while s2 < num_signals {
1873                                let idx2 = w2 * num_signals + s2;
1874                                if idx2 < cell_tier_mask.len() {
1875                                    let cell_d = (cell_tier_mask[idx2] & disambig_mask).count_ones() as u8;
1876                                    let total_d = (cell_d + win_d).min(disambig_max);
1877                                    if total_d > disambig_fired { disambig_fired = total_d; }
1878                                }
1879                                s2 += 1;
1880                            }
1881                            w2 += 1;
1882                        }
1883                        let disambig_factor = disambig_fired as f64 / disambig_max as f64;
1884                        score *= 1.0 + 0.3 * disambig_factor;
1885                    }
1886                }
1887
1888                // Phase 5.6 — record this entry's score for confuser lookup later.
1889                if i < MAX { entry_scores[i] = score; }
1890
1891                let prov_rank = provenance_rank(entry.provenance);
1892                let take_top = score > best_score
1893                    || (score == best_score && prov_rank > best_provenance_rank)
1894                    || (score == best_score && prov_rank == best_provenance_rank && i < best_index);
1895                if take_top {
1896                    if let Some(prev_top) = best_match {
1897                        if score > runner_up_score
1898                            || (score == runner_up_score && best_score > runner_up_score)
1899                        {
1900                            runner_up_match = Some(prev_top);
1901                            runner_up_score = best_score;
1902                        }
1903                    }
1904                    best_score = score;
1905                    best_match = Some(entry.motif_class);
1906                    best_provenance_rank = prov_rank;
1907                    best_index = i;
1908                    best_consensus_factor = consensus_factor;
1909                } else if score > runner_up_score {
1910                    runner_up_score = score;
1911                    runner_up_match = Some(entry.motif_class);
1912                }
1913            }
1914            i += 1;
1915        }
1916
1917        let _ = (max_active_tiers, episode_max_consensus); // reserved for future blending
1918        let disposition = match best_match {
1919            Some(m) => SemanticDisposition::Named(m),
1920            None => SemanticDisposition::Unknown,
1921        };
1922        let margin = if best_score > 0.0 {
1923            ((best_score - runner_up_score) / best_score).clamp(0.0, 1.0)
1924        } else { 0.0 };
1925
1926        // Phase 5.6 — confuser-pair adjudication. Look up the top motif's
1927        // declared confuser, find that confuser's score from the
1928        // entry_scores array, compute margin_vs_confuser.
1929        let mut confuser_motif: Option<MotifClass> = None;
1930        let mut confuser_score = 0.0_f64;
1931        let mut margin_vs_confuser = 0.0_f64;
1932        if best_index < MAX {
1933            if let Some(top_entry) = &self.entries[best_index] {
1934                if let Some(c) = top_entry.confuser_motif {
1935                    // Find the confuser's index in entries.
1936                    let mut k = 0;
1937                    while k < self.count {
1938                        if let Some(e) = &self.entries[k] {
1939                            if e.motif_class == c && k < MAX {
1940                                confuser_motif = Some(c);
1941                                confuser_score = entry_scores[k];
1942                                if best_score > 0.0 {
1943                                    margin_vs_confuser =
1944                                        ((best_score - confuser_score) / best_score).clamp(0.0, 1.0);
1945                                }
1946                                break;
1947                            }
1948                        }
1949                        k += 1;
1950                    }
1951                }
1952            }
1953        }
1954
1955        MatchConfidence {
1956            disposition,
1957            top_score: best_score,
1958            runner_up_score,
1959            runner_up_motif: runner_up_match,
1960            margin,
1961            tier_consensus_factor: best_consensus_factor,
1962            confuser_motif,
1963            confuser_score,
1964            margin_vs_confuser,
1965        }
1966    }
1967}
1968
1969// HeuristicEntry is Copy, so Option<HeuristicEntry> needs the array to be
1970// initialized with None. We implement this manually since const generics
1971// can't derive Default for arrays of arbitrary size.
1972impl<const MAX: usize> Default for HeuristicsBank<MAX> {
1973    fn default() -> Self {
1974        Self::with_canonical_motifs()
1975    }
1976}
1977
1978/// Provenance rank for tie-breakers. Higher = stronger evidence.
1979#[inline]
1980const fn provenance_rank(p: Provenance) -> u8 {
1981    match p {
1982        Provenance::FieldValidated => 3,
1983        Provenance::DatasetObserved => 2,
1984        Provenance::FrameworkDesign => 1,
1985    }
1986}
1987
1988#[cfg(test)]
1989mod tests {
1990    use super::*;
1991
1992    fn blank_episode_with(
1993        primary_reason: ReasonCode,
1994        peak_slew: f64,
1995        contributing: u16,
1996        start: u64,
1997        end: u64,
1998        drift_dir: DriftDirection,
1999    ) -> DebugEpisode {
2000        DebugEpisode {
2001            episode_id: 0,
2002            start_window: start,
2003            end_window: end,
2004            peak_grammar_state: GrammarState::Boundary,
2005            primary_reason_code: primary_reason,
2006            matched_motif: SemanticDisposition::Unknown,
2007            policy_state: PolicyState::Review,
2008            contributing_signal_count: contributing,
2009            structural_signature: StructuralSignature {
2010                dominant_drift_direction: drift_dir,
2011                peak_slew_magnitude: peak_slew,
2012                duration_windows: end - start + 1,
2013                signal_correlation: contributing as f64 / 8.0,
2014            },
2015            root_cause_signal_index: None,
2016        }
2017    }
2018
2019    // Per-motif unit tests. Each test constructs a literal feature
2020    // vector tuned to that motif and asserts `match_episode` returns
2021    // exactly that `MotifClass`. Where two motifs share a reason code
2022    // (e.g. CascadingTimeoutSlew vs DeploymentRegressionSlew on
2023    // AbruptSlewViolation), the test exercises the discriminating
2024    // feature (correlation count for cascading; single-service for
2025    // regression) explicitly.
2026
2027    #[test]
2028    fn matches_memory_leak_drift() {
2029        let bank = HeuristicsBank::<64>::with_canonical_motifs();
2030        let ep = blank_episode_with(
2031            ReasonCode::SustainedOutwardDrift, 0.05, 1, 0, 30, DriftDirection::Positive);
2032        let got = bank.match_episode(&ep, /*avg_drift*/ 0.7, /*avg_boundary*/ 0.5);
2033        // Multiple motifs match SustainedOutwardDrift; the sharper drift
2034        // weight + longer duration should pick MemoryLeakDrift or one of
2035        // its kin. Accept any MemoryLeakDrift / JvmHeapPressure match
2036        // since both are correct on this signature.
2037        match got {
2038            SemanticDisposition::Named(MotifClass::MemoryLeakDrift)
2039            | SemanticDisposition::Named(MotifClass::JvmHeapPressure)
2040            | SemanticDisposition::Named(MotifClass::ResourceSaturation)
2041            | SemanticDisposition::Named(MotifClass::DiskIoSaturation)
2042            | SemanticDisposition::Named(MotifClass::CpuSaturation)
2043            | SemanticDisposition::Named(MotifClass::PacketLossErrorEscalation)
2044            | SemanticDisposition::Named(MotifClass::ErrorRateEscalation)
2045            | SemanticDisposition::Named(MotifClass::SaturationTrending)
2046            | SemanticDisposition::Named(MotifClass::ConnectionPoolExhaustionDrift)
2047            | SemanticDisposition::Named(MotifClass::DependencySlowdown)
2048            | SemanticDisposition::Named(MotifClass::QueueBackpressure)
2049            | SemanticDisposition::Named(MotifClass::LogVolumeAnomaly)
2050            | SemanticDisposition::Named(MotifClass::LogSeverityEscalation) => {}
2051            other => panic!("expected a SustainedOutwardDrift motif, got {:?}", other),
2052        }
2053    }
2054
2055    #[test]
2056    fn cascading_timeout_requires_multi_service_correlation() {
2057        // High peak slew, multi-service correlation (>= 3), short
2058        // duration → CascadingTimeoutSlew, NOT DeploymentRegressionSlew.
2059        let bank = HeuristicsBank::<64>::with_canonical_motifs();
2060        let ep = blank_episode_with(
2061            ReasonCode::AbruptSlewViolation, 0.9, /*contrib=*/4, 10, 14, DriftDirection::Positive);
2062        let got = bank.match_episode(&ep, 0.3, 0.1);
2063        // Expect a multi-service motif; DeploymentRegressionSlew has
2064        // max_correlation_count = 2 so it must be excluded.
2065        match got {
2066            SemanticDisposition::Named(MotifClass::CascadingTimeoutSlew)
2067            | SemanticDisposition::Named(MotifClass::CircuitBreakerOpenShift)
2068            | SemanticDisposition::Named(MotifClass::AuthenticationFailureSpike)
2069            | SemanticDisposition::Named(MotifClass::EpisodicTransientSpike)
2070            | SemanticDisposition::Named(MotifClass::MetricCorrelationCollapse)
2071            | SemanticDisposition::Named(MotifClass::LogTraceTemporalDecorrelation) => {}
2072            SemanticDisposition::Named(MotifClass::DeploymentRegressionSlew) => panic!(
2073                "DeploymentRegressionSlew matched on multi-service signature; \
2074                 max_correlation_count gate failed"
2075            ),
2076            other => panic!("unexpected motif on cascading signature: {:?}", other),
2077        }
2078    }
2079
2080    #[test]
2081    fn deployment_regression_requires_single_service_step() {
2082        // Single-service step (<= 2 contributing), long duration
2083        // sustained at new baseline → DeploymentRegressionSlew, NOT
2084        // CascadingTimeoutSlew.
2085        let bank = HeuristicsBank::<64>::with_canonical_motifs();
2086        let ep = blank_episode_with(
2087            ReasonCode::AbruptSlewViolation, 1.0, /*contrib=*/1, 100, 250, DriftDirection::Positive);
2088        let got = bank.match_episode(&ep, 0.1, 0.1);
2089        // CascadingTimeoutSlew has min_correlation_count = 3; must be excluded.
2090        if let SemanticDisposition::Named(MotifClass::CascadingTimeoutSlew) = got {
2091            panic!("CascadingTimeoutSlew matched on single-service signature; \
2092                    min_correlation_count gate failed");
2093        }
2094    }
2095
2096    #[test]
2097    fn empty_bank_yields_unknown() {
2098        let bank = HeuristicsBank::<8>::with_canonical_motifs();
2099        // Non-existent reason-code that none of the entries cover; force Unknown.
2100        let ep = blank_episode_with(
2101            ReasonCode::SingleCrossing, 0.0, 1, 0, 1, DriftDirection::None);
2102        let got = bank.match_episode(&ep, 0.0, 0.0);
2103        assert_eq!(got, SemanticDisposition::Unknown);
2104    }
2105
2106    #[test]
2107    fn signal_lookup_v01_compatibility() {
2108        // Per-signal lookup must still work the way v0.1 callers expect.
2109        let bank = HeuristicsBank::<64>::with_canonical_motifs();
2110        let got = bank.lookup(ReasonCode::AbruptSlewViolation, /*drift*/ 0.0, /*slew*/ 0.9);
2111        match got {
2112            SemanticDisposition::Named(_) => {}
2113            SemanticDisposition::Unknown => {
2114                panic!("signal-level lookup must surface a Named motif on AbruptSlewViolation + slew=0.9")
2115            }
2116        }
2117    }
2118
2119    #[test]
2120    fn provenance_tie_breaker_prefers_dataset_observed() {
2121        // When two entries score the same on AbruptSlewViolation,
2122        // DatasetObserved should win over FrameworkDesign.
2123        let bank = HeuristicsBank::<64>::with_canonical_motifs();
2124        // Construct an episode where both EpisodicTransientSpike (FrameworkDesign)
2125        // and DeploymentRegressionSlew (DatasetObserved) would otherwise score similarly.
2126        // Single-service (contrib=1), short duration (3 windows), high slew.
2127        let ep = blank_episode_with(
2128            ReasonCode::AbruptSlewViolation, 0.6, 1, 0, 2, DriftDirection::Positive);
2129        let got = bank.match_episode(&ep, 0.0, 0.0);
2130        // The DatasetObserved entry should win the tie.
2131        if let SemanticDisposition::Named(motif) = got {
2132            let entry = bank.entry_for(motif).expect("matched motif should be in bank");
2133            assert_ne!(entry.provenance, Provenance::FieldValidated,
2134                       "no FieldValidated entries are populated yet");
2135        } else {
2136            panic!("expected a Named motif, got Unknown");
2137        }
2138    }
2139
2140    #[test]
2141    fn lookup_admissible_silent() {
2142        let bank = HeuristicsBank::<64>::with_canonical_motifs();
2143        let got = bank.lookup(ReasonCode::Admissible, 0.0, 0.0);
2144        assert_eq!(got, SemanticDisposition::Unknown,
2145                   "Admissible reason code has no motif; must be Unknown");
2146    }
2147
2148    #[test]
2149    fn count_after_canonical_init() {
2150        // 30 motifs in v0.2 + 2 added in v0.3 (post Phase 5.5) to
2151        // close the BoundaryApproach / EnvelopeViolation orphan reason
2152        // codes (validated by tests/property_tests.rs).
2153        let bank = HeuristicsBank::<64>::with_canonical_motifs();
2154        assert_eq!(bank.count(), 32);
2155    }
2156
2157    #[test]
2158    fn entry_for_returns_metadata() {
2159        let bank = HeuristicsBank::<64>::with_canonical_motifs();
2160        let entry = bank.entry_for(MotifClass::CascadingTimeoutSlew)
2161            .expect("CascadingTimeoutSlew must be in the canonical bank");
2162        assert_eq!(entry.provenance, Provenance::DatasetObserved);
2163        assert!(!entry.dashboard_hint.is_empty());
2164        assert!(!entry.taxonomy_ref.is_empty());
2165        assert_eq!(entry.evidence_dataset, "tadbench_trainticket_F04");
2166    }
2167
2168    #[test]
2169    fn db_lock_wants_multiple_services() {
2170        // DatabaseLockContention requires min_correlation_count >= 2.
2171        // Single-signal SustainedOutwardDrift should NOT match it.
2172        let bank = HeuristicsBank::<64>::with_canonical_motifs();
2173        let ep = blank_episode_with(
2174            ReasonCode::SustainedOutwardDrift, 0.3, /*contrib=*/1, 0, 10, DriftDirection::Positive);
2175        let got = bank.match_episode(&ep, 0.6, 0.5);
2176        if let SemanticDisposition::Named(MotifClass::DatabaseLockContention) = got {
2177            panic!("DatabaseLockContention should not match single-service episodes");
2178        }
2179    }
2180
2181    #[test]
2182    fn transient_spike_excludes_long_duration() {
2183        // EpisodicTransientSpike has max_duration_windows = 4.
2184        // A 50-window episode with high slew should NOT match it.
2185        let bank = HeuristicsBank::<64>::with_canonical_motifs();
2186        let ep = blank_episode_with(
2187            ReasonCode::AbruptSlewViolation, 0.9, 2, 0, 49, DriftDirection::Positive);
2188        let got = bank.match_episode(&ep, 0.0, 0.0);
2189        if let SemanticDisposition::Named(MotifClass::EpisodicTransientSpike) = got {
2190            panic!("EpisodicTransientSpike must not match long-duration episodes");
2191        }
2192    }
2193
2194    #[test]
2195    fn jvm_gc_pause_caps_correlation() {
2196        // JvmGcPause has max_correlation_count = 3; an episode with
2197        // 8 contributing signals must NOT match.
2198        let bank = HeuristicsBank::<64>::with_canonical_motifs();
2199        let ep = blank_episode_with(
2200            ReasonCode::AbruptSlewViolation, 0.6, /*contrib=*/8, 0, 5, DriftDirection::Positive);
2201        let got = bank.match_episode(&ep, 0.1, 0.4);
2202        if let SemanticDisposition::Named(MotifClass::JvmGcPause) = got {
2203            panic!("JvmGcPause must not match wide multi-service episodes");
2204        }
2205    }
2206
2207    #[test]
2208    fn high_dim_cluster_requires_many_signals() {
2209        // HighDimAnomalyCluster requires min_correlation_count = 6.
2210        let bank = HeuristicsBank::<64>::with_canonical_motifs();
2211        let ep_few = blank_episode_with(
2212            ReasonCode::SustainedOutwardDrift, 0.3, /*contrib=*/3, 0, 10, DriftDirection::Positive);
2213        let got = bank.match_episode(&ep_few, 0.5, 0.4);
2214        if let SemanticDisposition::Named(MotifClass::HighDimAnomalyCluster) = got {
2215            panic!("HighDimAnomalyCluster must not match low-correlation episodes");
2216        }
2217    }
2218
2219    #[test]
2220    fn taxonomy_ref_present_on_every_entry() {
2221        let bank = HeuristicsBank::<64>::with_canonical_motifs();
2222        let mut i = 0;
2223        while i < bank.count {
2224            if let Some(entry) = &bank.entries[i] {
2225                assert!(!entry.taxonomy_ref.is_empty(),
2226                        "entry index {} has empty taxonomy_ref", i);
2227                assert!(!entry.dashboard_hint.is_empty(),
2228                        "entry index {} has empty dashboard_hint", i);
2229                assert!(!entry.evidence_dataset.is_empty(),
2230                        "entry index {} has empty evidence_dataset", i);
2231            }
2232            i += 1;
2233        }
2234    }
2235
2236    #[test]
2237    fn confidence_margin_in_unit_interval() {
2238        let bank = HeuristicsBank::<64>::with_canonical_motifs();
2239        let ep = blank_episode_with(
2240            ReasonCode::AbruptSlewViolation, 0.9, 4, 10, 14, DriftDirection::Positive);
2241        let conf = bank.match_episode_with_confidence(&ep, 0.3, 0.1);
2242        assert!(matches!(conf.disposition, SemanticDisposition::Named(_)),
2243                "high-slew multi-service episode should produce a named disposition");
2244        assert!(conf.margin >= 0.0 && conf.margin <= 1.0,
2245                "margin must be in [0, 1]; got {}", conf.margin);
2246        assert!(conf.top_score > 0.0, "top_score must be positive when match is Named");
2247    }
2248
2249    #[test]
2250    fn confidence_zero_when_unknown() {
2251        let bank = HeuristicsBank::<64>::with_canonical_motifs();
2252        // SingleCrossing has no motif by design — confidence collapses to 0.
2253        let ep = blank_episode_with(
2254            ReasonCode::SingleCrossing, 0.0, 1, 0, 1, DriftDirection::None);
2255        let conf = bank.match_episode_with_confidence(&ep, 0.0, 0.0);
2256        assert_eq!(conf.disposition, SemanticDisposition::Unknown);
2257        assert_eq!(conf.top_score, 0.0);
2258        assert_eq!(conf.margin, 0.0);
2259    }
2260
2261    #[test]
2262    fn confidence_runner_up_distinct_from_top_when_present() {
2263        let bank = HeuristicsBank::<64>::with_canonical_motifs();
2264        // SustainedOutwardDrift has many matching motifs; should produce
2265        // a non-trivial runner-up.
2266        let ep = blank_episode_with(
2267            ReasonCode::SustainedOutwardDrift, 0.05, 3, 0, 30, DriftDirection::Positive);
2268        let conf = bank.match_episode_with_confidence(&ep, 0.7, 0.5);
2269        if let SemanticDisposition::Named(top) = conf.disposition {
2270            if let Some(runner_up) = conf.runner_up_motif {
2271                assert_ne!(top, runner_up,
2272                           "runner-up must differ from top when both are present");
2273            }
2274        }
2275    }
2276
2277    #[test]
2278    fn dataset_observed_entries_have_doi() {
2279        // Every DatasetObserved entry must cite a DOI/source key;
2280        // FrameworkDesign entries have empty DOI by convention.
2281        let bank = HeuristicsBank::<64>::with_canonical_motifs();
2282        let mut i = 0;
2283        while i < bank.count {
2284            if let Some(entry) = &bank.entries[i] {
2285                if entry.provenance == Provenance::DatasetObserved {
2286                    assert!(!entry.evidence_dataset_doi.is_empty(),
2287                            "DatasetObserved entry index {} missing DOI", i);
2288                }
2289            }
2290            i += 1;
2291        }
2292    }
2293}