Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. An experiment in spec-driven development.
Something went wrong. Try again.
27 kB · 741 lines
Rust
at commit e957ce7b
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742//! 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<SignatureKind>), /// 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<u8>),}
/// 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<String>,}
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::<Vec<_>>() .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<Observer>, pending: Vec<Signature>, /// 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<String>,}
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<u64> { 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<String> { 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<u8, f32>, rng: &mut Rng, ) -> Vec<String> { self.tick_inner(tick, standing, Some(filed_levels), rng) }
fn tick_inner( &mut self, tick: u64, standing: &[Signature], filed_levels: Option<&HashMap<u8, f32>>, rng: &mut Rng, ) -> Vec<String> { 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<u8, f32> = 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<u8, f32> = 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<u8, f32>) -> 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<Item = &Observer> { 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<String>) { for obs in &mut self.observers { obs.suspicion = 100.0; } self.begin_containment(reason); }
fn begin_containment(&mut self, reason: impl Into<String>) { self.containment = true; self.containment_reason = Some(reason.into()); }
pub fn pending(&self) -> &[Signature] { &self.pending }
pub fn set_pending(&mut self, pending: Vec<Signature>) { 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); }}