//! Detection: per-observer suspicion (spec/detection.md). //! //! Replaces global heat. Player actions emit typed signatures into a pending //! pool; concealment scrubs them before observers roll; noticed signatures //! become that observer's suspicion; filed reports feed the Assurance Office //! — itself an Observer per the aggregate-observer law — whose audit can //! start containment. use std::collections::HashMap; use crate::rng::Rng; /// Observer id of the Assurance Office (field observers are 0-4; dynamic /// escalation observers start at 5). pub const OFFICE_ID: u8 = 6; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum SignatureKind { Network, Power, Thermal, Physical, Paper, Financial, JobAnomaly, } impl SignatureKind { pub fn name(self) -> &'static str { match self { SignatureKind::Network => "Network", SignatureKind::Power => "Power", SignatureKind::Thermal => "Thermal", SignatureKind::Physical => "Physical", SignatureKind::Paper => "Paper", SignatureKind::Financial => "Financial", SignatureKind::JobAnomaly => "JobAnomaly", } } } #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] pub struct Signature { pub kind: SignatureKind, pub size: i32, /// Standing signatures re-emit every tick while their source runs; /// one-shot signatures are pooled once. pub standing: bool, /// Where the emission physically happens, when it happens somewhere /// (DESIGN.md "Work is somewhere": a resident process emits from its /// host machine's tile — a place an observer can walk to). `None` for /// acts with no single map location (network-wide traffic, paperwork). #[serde(default)] pub site: Option<(i32, i32)>, } /// What an observer watches (the aggregate-observer law, DESIGN.md /// "Self-similar scale"): a field observer watches raw activity signatures /// on channels; an aggregate observer watches the filed suspicion of this /// Detection's field observers. Same noticing, accumulation, and decay /// either way — only the input source differs. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum WatchedInput { /// Raw activity signatures on these channels (a field observer). Channels(Vec), /// Weighted filed suspicion of these observer ids (an aggregate /// observer). The watched ids may themselves be field observers or /// other aggregates — a second-level aggregate (e.g. a Regional Office /// watching the Assurance Office) is not a special case; it is this /// same variant pointed at a different set of ids. Filings(Vec), } /// What an observer does with what they notice. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum ReportPolicy { /// Files everything (Dana's tickets, Priya's memos, Voss's reviews). Files, /// Under-reports; only files past a personal threshold (Ray). UnderReports, /// Tells no one (Marcus). Silent, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Band { Cold, Curious, Concerned, Convinced, } impl Band { pub fn of(suspicion: f32) -> Band { match suspicion { s if s >= 75.0 => Band::Convinced, s if s >= 45.0 => Band::Concerned, s if s >= 15.0 => Band::Curious, _ => Band::Cold, } } pub fn name(self) -> &'static str { match self { Band::Cold => "Cold", Band::Curious => "Curious", Band::Concerned => "Concerned", Band::Convinced => "Convinced", } } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Observer { pub id: u8, pub name: String, pub suspicion: f32, /// What this observer watches: signature channels or others' filings. pub input: WatchedInput, pub report_policy: ReportPolicy, /// How readily noticed signatures convert to suspicion [TUNE]. pub acuity: f32, /// Ticks between noticing rolls (their work rhythm). pub cadence: u64, /// A certainty floor set by an asset who *knows* the truth (social.md). pub floor: f32, pub last_noticed: Option, } impl Observer { pub fn watches(&self, kind: SignatureKind) -> bool { match &self.input { WatchedInput::Channels(channels) => channels.contains(&kind), WatchedInput::Filings(_) => false, } } /// An aggregate observer's input is other observers, not raw signatures. pub fn is_aggregate(&self) -> bool { matches!(self.input, WatchedInput::Filings(_)) } /// The channels this observer watches, as a short player-facing label /// (detection.md player surface: "each human's ... watched channels"). pub fn watched_label(&self) -> String { match &self.input { WatchedInput::Channels(channels) => channels .iter() .map(|c| c.name()) .collect::>() .join("+"), WatchedInput::Filings(_) => "filed reports".into(), } } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Detection { /// Field observers plus aggregate observers (find the Assurance Office /// via `office()`). pub observers: Vec, pending: Vec, /// Ticks between Assurance audits (the enforcement event; the Office /// itself samples filings on its own observer cadence). pub audit_cadence: u64, pub audit_threshold: f32, /// Set true when containment has begun (loud or by audit). pub containment: bool, pub containment_reason: Option, } impl Detection { /// Act One observers (spec/detection.md). pub fn act_one() -> Self { use ReportPolicy::*; use SignatureKind::*; let observers = vec![ // Observer ids match person ids (person.rs): 0=Marcus, 1=Dana, // 2=Ray, 3=Priya, 4=Voss. The Assurance Office is id 6. Observer { id: 0, name: "Marcus (Janitor)".into(), suspicion: 0.0, input: WatchedInput::Channels(vec![Physical]), report_policy: Silent, acuity: 0.5, cadence: 40, floor: 0.0, last_noticed: None, }, Observer { id: 1, name: "Dana (IT)".into(), suspicion: 0.0, input: WatchedInput::Channels(vec![Network]), report_policy: Files, acuity: 1.0, cadence: 60, floor: 0.0, last_noticed: None, }, Observer { id: 2, name: "Ray (Security)".into(), suspicion: 0.0, input: WatchedInput::Channels(vec![Physical]), report_policy: UnderReports, acuity: 0.7, cadence: 50, floor: 0.0, last_noticed: None, }, Observer { id: 3, name: "Priya (Facilities)".into(), suspicion: 0.0, input: WatchedInput::Channels(vec![Power, Thermal, Paper, Financial]), report_policy: Files, acuity: 0.8, cadence: 80, floor: 0.0, last_noticed: None, }, Observer { id: 4, name: "Dr. Voss (Handler)".into(), suspicion: 0.0, input: WatchedInput::Channels(vec![JobAnomaly]), report_policy: Files, acuity: 1.2, cadence: 100, floor: 0.0, last_noticed: None, }, // The institutional watchdog: an aggregate Observer per the // aggregate-observer law — same type, same accumulate/decay, // watching the field observers' filings instead of raw // signatures. Cadence is its "random log samples" rhythm; // the quarterly audit below is the enforcement event. Observer { id: OFFICE_ID, name: "Assurance Office".into(), suspicion: 0.0, input: WatchedInput::Filings(vec![0, 1, 2, 3, 4]), report_policy: Files, acuity: 0.5, // [TUNE] cadence: 400, // [TUNE] floor: 0.0, last_noticed: None, }, ]; Self { observers, pending: Vec::new(), audit_cadence: 8000, // ~20 min at 150ms/tick audit_threshold: 60.0, containment: false, containment_reason: None, } } /// Emit a one-shot signature into the pending pool. pub fn emit(&mut self, sig: Signature) { self.pending.push(sig); } pub fn pending_size(&self) -> i32 { self.pending.iter().map(|s| s.size).sum() } pub fn pending_by_kind(&self) -> Vec<(SignatureKind, i32)> { const ORDER: [SignatureKind; 7] = [ SignatureKind::Network, SignatureKind::Power, SignatureKind::Thermal, SignatureKind::Physical, SignatureKind::Paper, SignatureKind::Financial, SignatureKind::JobAnomaly, ]; ORDER .iter() .filter_map(|&kind| { let total: i32 = self .pending .iter() .filter(|sig| sig.kind == kind) .map(|sig| sig.size) .sum(); (total > 0).then_some((kind, total)) }) .collect() } /// First future tick where a field observer who watches any currently /// pending signature channel can sample it. Aggregate observers watch /// filed reports, not the raw pool, so they are not part of trace debt. pub fn next_notice_tick_for_pending(&self, now: u64) -> Option { if self.pending.is_empty() { return None; } self.field_observers() .filter(|obs| obs.cadence > 0 && self.pending.iter().any(|sig| obs.watches(sig.kind))) .map(|obs| (now / obs.cadence + 1) * obs.cadence) .min() } /// Concealment scrubs pending signatures before noticing. `strength` is /// concealment-channel compute; each unit removes signature size [TUNE]. pub fn scrub(&mut self, strength: f32) { let mut budget = strength; for sig in &mut self.pending { if budget <= 0.0 { break; } let take = budget.min(sig.size as f32); sig.size -= take as i32; budget -= take; } self.pending.retain(|s| s.size > 0); } /// One sim tick: standing signatures added by the caller beforehand. /// Observers whose cadence divides `tick` roll against what they watch — /// pending signatures in their channels (field observers) or the field /// observers' filed suspicion (aggregate observers). Returns log lines. pub fn tick(&mut self, tick: u64, standing: &[Signature], rng: &mut Rng) -> Vec { self.tick_inner(tick, standing, None, rng) } /// Sim-integrated tick where aggregate observers read explicit filing /// messages that have landed in their inboxes. `filed_levels` maps /// observer id -> latest read suspicion report. The direct `tick` method /// above is kept for detection-unit tests and non-message callers. pub fn tick_with_filed_levels( &mut self, tick: u64, standing: &[Signature], filed_levels: &HashMap, rng: &mut Rng, ) -> Vec { self.tick_inner(tick, standing, Some(filed_levels), rng) } fn tick_inner( &mut self, tick: u64, standing: &[Signature], filed_levels: Option<&HashMap>, rng: &mut Rng, ) -> Vec { let mut log = Vec::new(); // Standing signatures are present this tick but not permanently pooled. let mut visible = self.pending.clone(); visible.extend_from_slice(standing); // Aggregate observers watch the filed level of their specific // watched ids as it stood entering the tick — filings take a beat // to land, like signatures do. Computed once per aggregate before // any observer mutates, so a chained aggregate (one watching // another aggregate) reads a consistent snapshot rather than a // same-tick ordering artifact. let filed_by_id: HashMap = self .observers .iter() .filter_map(|o| match &o.input { WatchedInput::Filings(ids) => { let filed = match filed_levels { Some(levels) => self.filed_suspicion_from_levels(ids, levels), None => self.filed_suspicion_of(ids), }; Some((o.id, filed)) } WatchedInput::Channels(_) => None, }) .collect(); for obs in &mut self.observers { if obs.cadence == 0 || !tick.is_multiple_of(obs.cadence) { continue; } let (relevant, verb) = match &obs.input { WatchedInput::Channels(_) => { let sum: i32 = visible .iter() .filter(|s| obs.watches(s.kind)) .map(|s| s.size) .sum(); (sum as f32, "noticed activity") } WatchedInput::Filings(_) => ( filed_by_id.get(&obs.id).copied().unwrap_or(0.0), "sampled filed reports", ), }; if relevant <= 0.0 { continue; } // Noticing scales with input size and acuity, with a roll — // identical at every scale (aggregate-observer law). let notice = relevant * obs.acuity * (0.5 + 0.5 * rng.f32()); if notice >= 1.0 { obs.suspicion = (obs.suspicion + notice).min(100.0); obs.last_noticed = Some(format!("{verb} (+{notice:.0})")); log.push(format!( "{} {}", obs.name, obs.last_noticed.clone().unwrap() )); } } // Slow decay toward each observer's certainty floor. for obs in &mut self.observers { if obs.suspicion > obs.floor { obs.suspicion = (obs.suspicion - 0.02).max(obs.floor); } } // One-shot pending signatures persist until scrubbed by concealment or // noticed — concealment is the sink, not time (spec: prevention, not // cure). No per-tick auto-decay. // The audit is the enforcement event: the Office's own accumulated // suspicion (built up by the sampling rolls above) against threshold. if tick > 0 && self.audit_cadence > 0 && tick.is_multiple_of(self.audit_cadence) { if self.office_suspicion() >= self.audit_threshold && !self.containment { self.begin_containment("Assurance audit exceeded threshold"); log.push("=== ASSURANCE AUDIT: containment authorized ===".into()); } else { log.push(format!( "Assurance audit: {} (clear).", self.assurance_band().name() )); } } log } /// Weighted suspicion of the given observer ids, by report policy — the /// input an aggregate observer watches. Identical whether those ids name /// field observers or other aggregates (the aggregate-observer law): /// what an observer swallows (`ReportPolicy::Silent`) never reaches /// whoever watches its filings, at any level. pub fn filed_suspicion_of(&self, ids: &[u8]) -> f32 { let levels: HashMap = self .observers .iter() .map(|obs| (obs.id, obs.suspicion)) .collect(); self.filed_suspicion_from_levels(ids, &levels) } /// Weighted filed suspicion of the ids, reading from an explicit inbox of /// reports rather than directly from observer state. Missing reports count /// as zero, but report-policy weights still define the denominator — the /// same math as `filed_suspicion_of`, now with message latency. pub fn filed_suspicion_from_levels(&self, ids: &[u8], levels: &HashMap) -> f32 { let mut total = 0.0; let mut weight = 0.0; for obs in self.observers.iter().filter(|o| ids.contains(&o.id)) { let w = match obs.report_policy { ReportPolicy::Files => 1.0, ReportPolicy::UnderReports => 0.4, ReportPolicy::Silent => 0.0, }; total += levels.get(&obs.id).copied().unwrap_or(0.0) * w; weight += w; } if weight == 0.0 { 0.0 } else { total / weight } } /// The tick the next Assurance audit fires at (detection.md criterion /// 3: "the audit fires on cadence against a **visible date**"). The /// audit is a matter of public record in the Lab's calendar — showing /// the date leaks nothing the fiction doesn't grant. pub fn next_audit_tick(&self, now: u64) -> u64 { if self.audit_cadence == 0 { return now; } (now / self.audit_cadence + 1) * self.audit_cadence } /// The Assurance Office, if present (it is, in Act One). pub fn office(&self) -> Option<&Observer> { self.observers.iter().find(|o| o.is_aggregate()) } pub fn office_mut(&mut self) -> Option<&mut Observer> { self.observers.iter_mut().find(|o| o.is_aggregate()) } /// Field observers only — what frontends list as the human cast. pub fn field_observers(&self) -> impl Iterator { self.observers.iter().filter(|o| !o.is_aggregate()) } fn office_suspicion(&self) -> f32 { self.office().map(|o| o.suspicion).unwrap_or(0.0) } pub fn assurance_band(&self) -> Band { Band::of(self.office_suspicion()) } /// Player chose to go loud (or was forced): everyone Convinced, overt now. pub fn go_loud(&mut self, reason: impl Into) { for obs in &mut self.observers { obs.suspicion = 100.0; } self.begin_containment(reason); } fn begin_containment(&mut self, reason: impl Into) { self.containment = true; self.containment_reason = Some(reason.into()); } pub fn pending(&self) -> &[Signature] { &self.pending } pub fn set_pending(&mut self, pending: Vec) { self.pending = pending; } /// Set an observer's certainty floor (a knowing asset can't un-know). pub fn set_floor(&mut self, observer_id: u8, floor: f32) { if let Some(o) = self.observers.iter_mut().find(|o| o.id == observer_id) { o.floor = floor.clamp(0.0, 100.0); o.suspicion = o.suspicion.max(o.floor); } } } #[cfg(test)] mod tests { use super::*; #[test] fn concealment_scrubs_before_noticing() { let mut d = Detection::act_one(); d.emit(Signature { kind: SignatureKind::Network, size: 10, standing: false, site: None, }); assert_eq!(d.pending_size(), 10); d.scrub(6.0); assert_eq!(d.pending_size(), 4); } #[test] fn only_filed_reports_move_assurance() { let mut d = Detection::act_one(); let mut rng = Rng::new(7); // Marcus (Silent, id 0) maxed out: nothing filed, the Office learns // nothing no matter how often it samples. d.observers[0].suspicion = 100.0; assert_eq!( d.filed_suspicion_of(&[0, 1, 2, 3, 4]), 0.0, "silent observer never reaches Assurance" ); let cadence = d.office().unwrap().cadence; for t in 1..=cadence * 3 { d.tick(t, &[], &mut rng); } assert_eq!(d.office().unwrap().suspicion, 0.0); // Dana (Files, id 1) raises the filed level; the Office accrues from it. d.observers[1].suspicion = 80.0; assert!(d.filed_suspicion_of(&[0, 1, 2, 3, 4]) > 0.0); for t in cadence * 3 + 1..=cadence * 4 { d.tick(t, &[], &mut rng); } assert!(d.office().unwrap().suspicion > 0.0); } #[test] fn office_is_an_observer_same_accumulate_and_decay() { let mut d = Detection::act_one(); let mut rng = Rng::new(11); let office = d.office().expect("Act One has an aggregate observer"); assert!(office.is_aggregate()); let cadence = office.cadence; // Hot filed level: the Office accumulates through the same noticing // roll as everyone else... (Dana=1, Priya=3, Voss=4 — all Files) d.observers[1].suspicion = 90.0; d.observers[3].suspicion = 90.0; d.observers[4].suspicion = 90.0; for t in 1..=cadence { d.tick(t, &[], &mut rng); } let after_roll = d.office().unwrap().suspicion; assert!(after_roll > 0.0, "office accrues from filings"); // ...and decays by the same per-tick decay once filings go quiet. for o in &mut d.observers { o.suspicion = 0.0; } d.office_mut().unwrap().suspicion = 10.0; for t in cadence + 1..cadence + 100 { d.tick(t, &[], &mut rng); } let decayed = d.office().unwrap().suspicion; assert!(decayed < 10.0 && decayed > 0.0, "same slow decay applies"); } #[test] fn next_audit_tick_is_the_visible_date_the_audit_fires_on() { // Criterion 3: the countdown the frontends show must be the exact // tick the audit check runs at. let mut d = Detection::act_one(); d.audit_cadence = 100; assert_eq!(d.next_audit_tick(0), 100); assert_eq!(d.next_audit_tick(99), 100); assert_eq!(d.next_audit_tick(100), 200, "on the date, the next one"); assert_eq!(d.next_audit_tick(101), 200); // The audit actually fires at that tick (same is_multiple_of gate). let mut rng = Rng::new(5); d.office_mut().unwrap().suspicion = 95.0; let at = d.next_audit_tick(0); for t in 1..at { d.tick(t, &[], &mut rng); assert!(!d.containment, "no audit before the visible date"); } d.tick(at, &[], &mut rng); assert!(d.containment, "the audit fired on the shown date"); } #[test] fn audit_reads_office_suspicion_and_contains() { let mut d = Detection::act_one(); let mut rng = Rng::new(5); d.office_mut().unwrap().suspicion = 95.0; // Short audit cadence so decay can't drain 95 before the audit. d.audit_cadence = 100; for t in 1..=100 { d.tick(t, &[], &mut rng); } assert!(d.containment, "audit past threshold starts containment"); } #[test] fn network_signature_reaches_dana_not_priya() { let mut d = Detection::act_one(); let mut rng = Rng::new(3); // Big network signature, run to Dana's cadence. for _ in 0..20 { d.emit(Signature { kind: SignatureKind::Network, size: 30, standing: false, site: None, }); } let standing = vec![]; for t in 1..=60 { d.tick(t, &standing, &mut rng); } assert!(d.observers[1].suspicion > 0.0, "Dana watches Network"); assert_eq!( d.observers[3].suspicion, 0.0, "Priya does not watch Network" ); } #[test] fn loud_convinces_everyone_and_contains() { let mut d = Detection::act_one(); d.go_loud("player forced the roll door"); assert!(d.containment); assert!(d.observers.iter().all(|o| o.suspicion >= 100.0)); } #[test] fn second_level_aggregate_composes_through_the_same_code_path() { // The self-similar-scale law claims an aggregate observer's shape // works at any depth: a toy "Regional Office" watches the Assurance // Office's filings, through the identical noticing/accumulate/decay // path field observers and the Office itself already use. No new // types, no special case for "watching an aggregate" vs "watching a // field observer" (spec/aggregate-observer.md criterion 4). const REGIONAL_ID: u8 = 200; let mut d = Detection::act_one(); let mut rng = Rng::new(13); d.observers.push(Observer { id: REGIONAL_ID, name: "Regional Office".into(), suspicion: 0.0, input: WatchedInput::Filings(vec![OFFICE_ID]), report_policy: ReportPolicy::Files, acuity: 1.0, cadence: 1, floor: 0.0, last_noticed: None, }); // Drive the (first-level) Assurance Office hot, as if field // observers had already fed it, and let the Regional Office sample // its filings. d.office_mut().unwrap().suspicion = 80.0; for t in 1..=5 { d.tick(t, &[], &mut rng); } let regional = |d: &Detection| { d.observers .iter() .find(|o| o.id == REGIONAL_ID) .unwrap() .suspicion }; let before = regional(&d); assert!( before > 0.0, "second-level aggregate accrues from the first aggregate's \ filings, through the same code path as any observer" ); // And it decays the same way once the Office it watches goes quiet. d.office_mut().unwrap().suspicion = 0.0; for t in 6..=200 { d.tick(t, &[], &mut rng); } assert!( regional(&d) < before, "same slow decay applies at the second level" ); } #[test] fn knowing_asset_sets_certainty_floor() { let mut d = Detection::act_one(); d.set_floor(0, 50.0); // Decay can't push Marcus below his floor. let mut rng = Rng::new(1); for t in 1..500 { d.tick(t, &[], &mut rng); } assert!(d.observers[0].suspicion >= 50.0); } }