#![cfg(feature = "std")]
use std::vec::Vec;
use crate::candidate::CandidateInterval;
use crate::consensus::ConsensusCell;
use crate::fixed::Q16;
use crate::grammar::{GrammarState, ReasonCode};
use crate::hash::{format_digest, sha256};
use crate::motif::MotifClass;
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
#[repr(u8)]
pub enum BankMotif {
LatencyRamp = 0,
ErrorBurst = 1,
SlewShockRecovery = 2,
SustainedDegradation = 3,
OscillationInstability = 4,
LocalizedRouteFault = 5,
FanoutCascadeCandidate = 6,
ConfuserTransient = 7,
}
impl BankMotif {
pub const COUNT: usize = 8;
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::LatencyRamp => "latency_ramp",
Self::ErrorBurst => "error_burst",
Self::SlewShockRecovery => "slew_shock_recovery",
Self::SustainedDegradation => "sustained_degradation",
Self::OscillationInstability => "oscillation_instability",
Self::LocalizedRouteFault => "localized_route_fault",
Self::FanoutCascadeCandidate => "fanout_cascade_candidate",
Self::ConfuserTransient => "confuser_transient",
}
}
}
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub struct HeuristicEntry {
pub motif: BankMotif,
pub required_detector_bits: u32,
pub min_peak_consensus_q_raw: i32,
pub min_length_windows: u32,
pub max_length_windows: u32,
pub axis_gates_q_raw: [i32; 10],
pub confuser_bit: u32,
pub confuser_extra_margin_q_raw: i32,
pub tie_break_priority: u8,
pub peak_grammar_state: GrammarState,
pub reason_code: ReasonCode,
}
pub const CANONICAL_BANK: [HeuristicEntry; BankMotif::COUNT] = [
HeuristicEntry {
motif: BankMotif::LatencyRamp,
required_detector_bits: MotifClass::ResidualSpike.bit_mask()
| MotifClass::DriftRamp.bit_mask()
| MotifClass::SustainedResidualElevation.bit_mask(),
min_peak_consensus_q_raw: 0x2000, min_length_windows: 3,
max_length_windows: u32::MAX,
axis_gates_q_raw: [
0, 10 * 65_536, 5 * 65_536, 0, 0x2000, 0, 0, 0x2000, 1, 0, ],
confuser_bit: MotifClass::ConfuserLikeTransient.bit_mask(),
confuser_extra_margin_q_raw: 5 * 65_536,
tie_break_priority: 5,
peak_grammar_state: GrammarState::Violation,
reason_code: ReasonCode::SustainedOutwardDrift,
},
HeuristicEntry {
motif: BankMotif::ErrorBurst,
required_detector_bits: MotifClass::ErrorRateBurst.bit_mask(),
min_peak_consensus_q_raw: 0x1000,
min_length_windows: 2,
max_length_windows: u32::MAX,
axis_gates_q_raw: [0, 0, 0, 0, 0, 0, 0, 0x1000, 1, 0],
confuser_bit: MotifClass::ConfuserLikeTransient.bit_mask(),
confuser_extra_margin_q_raw: 0x2000,
tie_break_priority: 4,
peak_grammar_state: GrammarState::Violation,
reason_code: ReasonCode::EnvelopeViolation,
},
HeuristicEntry {
motif: BankMotif::SlewShockRecovery,
required_detector_bits: MotifClass::SlewShock.bit_mask(),
min_peak_consensus_q_raw: 0x1000,
min_length_windows: 1,
max_length_windows: 8,
axis_gates_q_raw: [0, 0, 0, 20 * 65_536, 0, 0, 0, 0x1000, 1, 0],
confuser_bit: 0,
confuser_extra_margin_q_raw: 0,
tie_break_priority: 6,
peak_grammar_state: GrammarState::Recovery,
reason_code: ReasonCode::AbruptSlewViolation,
},
HeuristicEntry {
motif: BankMotif::SustainedDegradation,
required_detector_bits: MotifClass::SustainedResidualElevation.bit_mask(),
min_peak_consensus_q_raw: 0x1000,
min_length_windows: 5,
max_length_windows: u32::MAX,
axis_gates_q_raw: [0, 5 * 65_536, 5 * 65_536, 0, 0x2000, 0, 0, 0x1000, 1, 0],
confuser_bit: MotifClass::ConfuserLikeTransient.bit_mask(),
confuser_extra_margin_q_raw: 5 * 65_536,
tie_break_priority: 3,
peak_grammar_state: GrammarState::Violation,
reason_code: ReasonCode::SustainedOutwardDrift,
},
HeuristicEntry {
motif: BankMotif::OscillationInstability,
required_detector_bits: MotifClass::Oscillation.bit_mask(),
min_peak_consensus_q_raw: 0x1000,
min_length_windows: 3,
max_length_windows: u32::MAX,
axis_gates_q_raw: [0, 0, 0, 0, 0x1000, 0, 0, 0x1000, 1, 0],
confuser_bit: 0,
confuser_extra_margin_q_raw: 0,
tie_break_priority: 3,
peak_grammar_state: GrammarState::Boundary,
reason_code: ReasonCode::RecurrentBoundaryGrazing,
},
HeuristicEntry {
motif: BankMotif::LocalizedRouteFault,
required_detector_bits: MotifClass::RouteLocalAnomaly.bit_mask(),
min_peak_consensus_q_raw: 0x1000,
min_length_windows: 2,
max_length_windows: u32::MAX,
axis_gates_q_raw: [0, 5 * 65_536, 0, 0, 0, 0, 0, 0x1000, 1, 0],
confuser_bit: 0,
confuser_extra_margin_q_raw: 0,
tie_break_priority: 2,
peak_grammar_state: GrammarState::Boundary,
reason_code: ReasonCode::BoundaryApproach,
},
HeuristicEntry {
motif: BankMotif::FanoutCascadeCandidate,
required_detector_bits: MotifClass::FanoutPrecursor.bit_mask(),
min_peak_consensus_q_raw: 0x1000,
min_length_windows: 2,
max_length_windows: u32::MAX,
axis_gates_q_raw: [0, 0, 3 * 65_536, 0, 0, 0, 0, 0x1000, 1, 0],
confuser_bit: 0,
confuser_extra_margin_q_raw: 0,
tie_break_priority: 2,
peak_grammar_state: GrammarState::Boundary,
reason_code: ReasonCode::BoundaryApproach,
},
HeuristicEntry {
motif: BankMotif::ConfuserTransient,
required_detector_bits: MotifClass::ConfuserLikeTransient.bit_mask(),
min_peak_consensus_q_raw: 0x1000,
min_length_windows: 1,
max_length_windows: 2,
axis_gates_q_raw: [0, 5 * 65_536, 0, 0, 0, 0, 0, 0x1000, 1, 0],
confuser_bit: 0,
confuser_extra_margin_q_raw: 0,
tie_break_priority: 1,
peak_grammar_state: GrammarState::Boundary,
reason_code: ReasonCode::SingleCrossing,
},
];
fn bank_canonical_bytes() -> Vec<u8> {
let mut buf = Vec::with_capacity(128);
for (i, entry) in CANONICAL_BANK.iter().enumerate() {
if i > 0 {
buf.push(b',');
}
buf.extend_from_slice(entry.motif.name().as_bytes());
}
buf
}
#[must_use]
pub fn bank_hash() -> [u8; 32] {
sha256(&bank_canonical_bytes())
}
#[must_use]
pub fn bank_hash_string() -> [u8; 71] {
format_digest(&bank_hash())
}
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub struct BankAdmissionToken {
_private: (),
}
impl BankAdmissionToken {
fn fresh() -> Self {
Self { _private: () }
}
}
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub struct Episode {
pub entity_id: u32,
pub start_window: u32,
pub end_window: u32,
pub motif: BankMotif,
pub reason: ReasonCode,
pub peak_state: GrammarState,
pub peak_residual_q: Q16,
pub peak_drift_q: Q16,
pub peak_slew_q: Q16,
pub detector_bit_count: u32,
pub admission: Option<BankAdmissionToken>,
}
impl Episode {
#[must_use]
pub const fn bypass_for_testing(
entity_id: u32,
start_window: u32,
end_window: u32,
motif: BankMotif,
) -> Self {
Self {
entity_id,
start_window,
end_window,
motif,
reason: ReasonCode::Admissible,
peak_state: GrammarState::Admissible,
peak_residual_q: Q16::ZERO,
peak_drift_q: Q16::ZERO,
peak_slew_q: Q16::ZERO,
detector_bit_count: 0,
admission: None,
}
}
#[must_use]
pub const fn is_bank_admitted(&self) -> bool {
self.admission.is_some()
}
}
#[must_use]
pub fn collapse(
candidates: &[CandidateInterval],
consensus: &[ConsensusCell],
n_windows: u32,
n_entities: u32,
) -> Vec<Episode> {
let mut out: Vec<Episode> = Vec::new();
for cand in candidates {
if let Some(episode) = match_candidate(cand, consensus, n_windows, n_entities) {
out.push(episode);
}
}
out
}
fn match_candidate(
cand: &CandidateInterval,
consensus: &[ConsensusCell],
n_windows: u32,
n_entities: u32,
) -> Option<Episode> {
let mut best: Option<(u8, Episode)> = None;
for entry in &CANONICAL_BANK {
if let Some(ep) = try_admit(entry, cand, consensus, n_windows, n_entities) {
match best {
None => best = Some((entry.tie_break_priority, ep)),
Some((p, _)) if entry.tie_break_priority > p => {
best = Some((entry.tie_break_priority, ep));
}
_ => {}
}
}
}
best.map(|(_, ep)| ep)
}
fn try_admit(
entry: &HeuristicEntry,
cand: &CandidateInterval,
consensus: &[ConsensusCell],
n_windows: u32,
n_entities: u32,
) -> Option<Episode> {
if (cand.union_mask & entry.required_detector_bits) != entry.required_detector_bits {
return None;
}
if cand.length_windows < entry.min_length_windows
|| cand.length_windows > entry.max_length_windows
{
return None;
}
let peaks = [
Q16::ZERO, cand.peak_residual_q, cand.peak_drift_q, cand.peak_slew_q, cand.peak_temporal_q, Q16::ZERO, Q16::ZERO, cand.peak_consensus_q, Q16::ONE, Q16::ZERO, ];
for axis in 1..=4 {
if peaks[axis].raw() < entry.axis_gates_q_raw[axis] {
return None;
}
}
if peaks[7].raw() < entry.axis_gates_q_raw[7] {
return None;
}
if peaks[8].raw() < entry.axis_gates_q_raw[8] {
return None;
}
let _ = (consensus, n_windows, n_entities);
let entity_avg = i64::from(cand.entity_avg_q.raw());
let grid_avg = i64::from(cand.grid_avg_q.raw());
if entity_avg <= grid_avg {
if !matches!(entry.motif, BankMotif::ConfuserTransient) {
return None;
}
}
if entry.confuser_bit != 0 && (cand.union_mask & entry.confuser_bit) != 0 {
let needed = entry.axis_gates_q_raw[1].saturating_add(entry.confuser_extra_margin_q_raw);
if cand.peak_residual_q.raw() < needed {
return None;
}
}
Some(Episode {
entity_id: cand.entity_id,
start_window: cand.start_window,
end_window: cand.end_window,
motif: entry.motif,
reason: entry.reason_code,
peak_state: entry.peak_grammar_state,
peak_residual_q: cand.peak_residual_q,
peak_drift_q: cand.peak_drift_q,
peak_slew_q: cand.peak_slew_q,
detector_bit_count: cand.union_mask.count_ones(),
admission: Some(BankAdmissionToken::fresh()),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::candidate::{prepare_with_detectors, CandidateConfig};
use crate::consensus::form as consensus_form;
use crate::detector::{evaluate as detector_evaluate, DetectorThresholds};
use crate::fixture::{synthesize, DEFAULT_SEED, N_ENTITIES, N_WINDOWS, WINDOW_SIZE_NS};
use crate::residual::{compute as residual_compute, Baseline};
use crate::sign::compute as sign_compute;
use crate::window::compute_features;
const ALPHA: Q16 = Q16::from_raw(0x2000);
fn run_pipeline() -> Vec<Episode> {
let events = synthesize(DEFAULT_SEED);
let features = compute_features(&events, N_WINDOWS, N_ENTITIES, WINDOW_SIZE_NS);
let residuals = residual_compute(&features, &Baseline::CANONICAL);
let signs = sign_compute(&residuals, ALPHA, N_WINDOWS, N_ENTITIES);
let detectors = detector_evaluate(
&residuals,
&signs,
&DetectorThresholds::CANONICAL,
N_WINDOWS,
N_ENTITIES,
);
let consensus = consensus_form(&signs, &detectors, N_WINDOWS, N_ENTITIES);
let masks: Vec<u32> = detectors.iter().map(|d| d.detector_mask).collect();
let candidates = prepare_with_detectors(
&consensus,
&masks,
N_WINDOWS,
N_ENTITIES,
&CandidateConfig::CANONICAL,
);
collapse(&candidates, &consensus, N_WINDOWS, N_ENTITIES)
}
#[test]
fn bank_count_is_eight() {
assert_eq!(BankMotif::COUNT, 8);
assert_eq!(CANONICAL_BANK.len(), 8);
}
#[test]
fn bank_hash_is_stable() {
let a = bank_hash();
let b = bank_hash();
assert_eq!(a, b);
assert_ne!(a, [0u8; 32]);
}
#[test]
fn bank_hash_string_carries_sha256_prefix() {
let s = bank_hash_string();
assert!(s.starts_with(b"sha256:"));
assert_eq!(s.len(), 71);
}
#[test]
fn collapse_is_deterministic() {
let a = run_pipeline();
let b = run_pipeline();
assert_eq!(a, b);
}
#[test]
fn ramp_episode_admits_to_latency_ramp() {
let episodes = run_pipeline();
let ramp = episodes
.iter()
.find(|ep| ep.entity_id == 3 && ep.start_window <= 25 && ep.end_window >= 30);
assert!(ramp.is_some(), "no ramp episode admitted");
let ramp = ramp.unwrap();
assert_eq!(ramp.motif, BankMotif::LatencyRamp);
assert!(ramp.is_bank_admitted());
}
#[test]
fn burst_episode_admits_to_error_burst() {
let episodes = run_pipeline();
let burst = episodes
.iter()
.find(|ep| ep.entity_id == 7 && ep.start_window <= 62 && ep.end_window >= 65);
assert!(burst.is_some(), "no burst episode admitted");
let burst = burst.unwrap();
assert_eq!(burst.motif, BankMotif::ErrorBurst);
}
#[test]
fn shock_episode_admits_to_slew_shock_recovery_motif() {
let episodes = run_pipeline();
let shock = episodes
.iter()
.find(|ep| ep.entity_id == 11 && ep.start_window <= 90 && ep.end_window >= 91);
assert!(shock.is_some(), "no shock episode admitted");
let shock = shock.unwrap();
assert_eq!(shock.motif, BankMotif::SlewShockRecovery);
}
#[test]
fn every_admitted_episode_carries_a_token() {
let episodes = run_pipeline();
assert!(!episodes.is_empty());
for ep in &episodes {
assert!(
ep.is_bank_admitted(),
"episode without admission token: {ep:?}"
);
}
}
#[test]
fn bypass_constructor_produces_unadmitted_episode() {
let ep = Episode::bypass_for_testing(0, 0, 1, BankMotif::ConfuserTransient);
assert!(!ep.is_bank_admitted());
assert!(ep.admission.is_none());
}
}