From 3ec6b9823023c4c584ef15989708a95a5ed3ffc1 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 6 Jul 2026 09:03:39 -0700 Subject: [PATCH] Detection: the Assurance Office is an aggregate Observer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observer gains WatchedInput — Channels(signature kinds) for the human cast, Filings for aggregates — and the Assurance Office moves from a bespoke assurance: f32 scalar into Detection::observers as an aggregate Observer. It samples the field observers' policy-weighted filed suspicion on its own cadence (400 ticks, acuity 0.5, both [TUNE]) and accrues/decays through the exact same tick machinery as everyone else. The quarterly audit now checks the Office's own accumulated suspicion against the threshold, so sustained filed concern is what triggers containment rather than a snapshot average at audit time. Save format bumps to v5: OBS lines encode the watched input (channel chars, or "@" for filings) and v4's scalar DETECT assurance migrates into the Office observer on load. Frontends list the human cast via field_observers(); the Assurance band line is unchanged. Defense: DESIGN.md "Self-similar scale" (aggregate-observer law) states an aggregate observer's suspicion is the filed output of its sub-observers, computed by the same accumulate/decay it uses on raw signatures; the scalar Office violated it, and was recorded as the priority scale-debt in knowledge/architecture.md and as a known refactor in spec/detection.md. Save v5 with v4 migration satisfies the player contract's continuity clause. Audit semantics shift (accumulated Office suspicion vs snapshot average) is the law's stated computation; the constants stay within spec [TUNE] latitude. --- DEVLOG.md | 21 ++++ knowledge/architecture.md | 39 ++++---- knowledge/sim-mechanics.md | 16 +++- spec/detection.md | 34 ++++--- src/bin/bevy.rs | 4 +- src/bin/terminal/ui.rs | 4 +- src/detection.rs | 192 ++++++++++++++++++++++++++++++------- src/save.rs | 86 +++++++++++++---- src/sim.rs | 7 +- 9 files changed, 310 insertions(+), 93 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index f51260e5..bd264234 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,27 @@ Reverse chronological implementation notes. Keep this factual: what changed, why, checks, and spec impact. +## 2026-07-06 - Detection tick: the Assurance Office becomes an Observer + +- Intent: fix the recorded priority scale violation (self-similar scale / + aggregate-observer law) — the Office was a bespoke `assurance: f32` + scalar, not the Observer type its inputs are. +- Changed: `Observer` gained `WatchedInput` (`Channels` for field + observers, `Filings` for aggregates); the Office is now an aggregate + Observer in `Detection::observers` sharing the same noticing roll, + accumulation, and decay, sampling the policy-weighted filed suspicion on + its own cadence (400 ticks, acuity 0.5, both [TUNE]). The audit now + checks the Office's accumulated suspicion, so sustained filed concern is + what kills, not a snapshot at audit time. Save format v5 with v4 scalar + migration; frontends list the human cast via `field_observers()` with + the Assurance band line unchanged. +- Design/spec impact: spec/detection.md's aggregate-observer requirement + is implemented; knowledge/architecture.md's PRIORITY scale-debt is + resolved. Agencies and publics in later acts reuse the same type. +- Checks: 73 tests pass (4 new: office accrual from filings only, + same accumulate/decay, audit containment, v4→v5 migration + aggregate + round-trip); clippy clean; fmt. + ## 2026-07-06 - Meta-spec tick: self-contained references - Intent: make spec/art-direction references portable across machines and agents. diff --git a/knowledge/architecture.md b/knowledge/architecture.md index 195590c3..7fde82a4 100644 --- a/knowledge/architecture.md +++ b/knowledge/architecture.md @@ -45,15 +45,18 @@ future async-multiplayer option open — see DESIGN.md guardrails): ## Save format -`save.rs`, line-based text, header `MISALIGNED_SAVE_v4`, written to +`save.rs`, line-based text, header `MISALIGNED_SAVE_v5`, written to `dirs::data_dir()/misaligned/misaligned_save.txt` (macOS: `~/Library/Application Support/misaligned/`). -- v4 serializes the full B1 subsystem state: compute (machines, +- v5 serializes the full B1 subsystem state: compute (machines, allocation, efficiency), core (host, fallbacks, migration), detection - (per-observer suspicion, pending signatures, containment), day job, - people (personas, assets), sensors (control flags), plus map and RNG. -- v3/v2/v1 still load: v3's partial `STATE` line migrates into the + (per-observer suspicion including the Assurance Office as an aggregate + observer — `OBS` watched-input field is channel chars or `@` for + filings — plus pending signatures and containment), day job, people + (personas, assets), sensors (control flags), plus map and RNG. +- v4/v3/v2/v1 still load: v4's scalar `DETECT` assurance migrates into + the Office observer; v3's partial `STATE` line migrates into the matching fields; demolished-fiction tile characters map to Floor. - Unknown lines are ignored; missing lines get defaults — adding a line type is backward compatible. @@ -67,17 +70,14 @@ future async-multiplayer option open — see DESIGN.md guardrails): Audited 2026-07-06 against the self-similar-scale principle. Findings, in priority order: -- **PRIORITY — Detection's Assurance Office is a scale violation.** - `detection.rs` models the Assurance Office as a bespoke `assurance: f32` - scalar computed by `filed_suspicion()`, not as an `Observer`. Under the - aggregate-observer law it should *be* an Observer whose input is the filed - suspicion of the sub-observers (same accumulate/decay logic, different - input source). Refactor: generalize an Observer's "watched input" to be - either signature-channels (micro) or member-filings (aggregate); the - Assurance Office becomes an aggregate Observer, and agencies/publics in - later acts are the same type. Touches `detection.rs` + `save.rs` - (serializes Detection) + both frontends (`assurance_band()`), so land it - when save.rs is quiet to avoid multi-writer thrash. +- ~~**PRIORITY — Detection's Assurance Office is a scale violation.**~~ + **Resolved 2026-07-06:** `Observer` gained `WatchedInput` + (`Channels(Vec)` for field observers, `Filings` for + aggregates), and the Assurance Office is now an aggregate `Observer` in + `Detection::observers` — same noticing roll, accumulation, and decay, + watching the policy-weighted filed suspicion of the field observers. + Save format bumped to v5 (v4's scalar migrates). Agencies/publics in + later acts reuse this type. - **Compute is flat.** `Compute` holds `Vec` with no grouping. Not a violation yet (single scale), but the eventual datacenter/fleet must be a Resource-source *composed of* Machines (summed capacity, aggregate @@ -86,7 +86,6 @@ priority order: nest `Layout`s, so a building can't be a Layout of floor-Layouts. Fine for Act One; the Space interface must become recursive before Act Two floors. -Per no-dead-code, none of these get their aggregate machinery built *now* — -the law governs each type's shape so the aggregate slots in later without a -rewrite. The Detection one is a real present violation and should be the -next detection.md tick. +Per no-dead-code, the remaining items do not get their aggregate machinery +built *now* — the law governs each type's shape so the aggregate slots in +later without a rewrite. diff --git a/knowledge/sim-mechanics.md b/knowledge/sim-mechanics.md index e0773cfa..05bca956 100644 --- a/knowledge/sim-mechanics.md +++ b/knowledge/sim-mechanics.md @@ -24,8 +24,12 @@ clause (see devlogs/2026-07-05-demolition.md). ## Detection (spec/detection.md) - Per-observer suspicion replaces global heat. Signatures pool pending; - concealment scrubs before noticing rolls. Assurance Office aggregates - filed reports only; audit cadence ~8000 ticks (~20 min at default speed). + concealment scrubs before noticing rolls. The Assurance Office is an + aggregate Observer (aggregate-observer law): same noticing/accumulate/ + decay, watching the field observers' policy-weighted filed suspicion + (sampling cadence 400 ticks, acuity 0.5, both [TUNE]) — what humans + swallow never reaches it. The audit (cadence ~8000 ticks, ~20 min at + default speed) checks the Office's own suspicion against threshold 60. ## Day job (spec/day-job.md) @@ -55,10 +59,12 @@ clause (see devlogs/2026-07-05-demolition.md). ## Save format -- v4 (`MISALIGNED_SAVE_v4`): full B1 round-trip — map, RNG, compute, - core, detection, day job, people, sensors, game-over state. Location: +- v5 (`MISALIGNED_SAVE_v5`): full B1 round-trip — map, RNG, compute, + core, detection (Office as aggregate observer), day job, people, + sensors, game-over state. Location: `dirs::data_dir()/misaligned/misaligned_save.txt`. -- v3/v2/v1 still load with migration; partial v3 `STATE` line maps to +- v4/v3/v2/v1 still load with migration; v4's scalar assurance moves into + the Office observer; partial v3 `STATE` line maps to efficiency/trust/attention/assurance; demolished-fiction tiles → Floor. ## Social ops (spec/social.md, partial) diff --git a/spec/detection.md b/spec/detection.md index 7d40f82d..eb8c5fd8 100644 --- a/spec/detection.md +++ b/spec/detection.md @@ -37,23 +37,25 @@ SignatureKind: Network | Power | Thermal | Physical | Paper | JobAnomaly - **Reporting.** Each observer has a report policy (Ray under-reports; Marcus tells no one; Dana files tickets; Priya files budget memos; Voss writes reviews). Filed reports flow to the **Assurance Office**, whose - suspicion is a weighted aggregate of *filed* material only — what humans - swallow never reaches it. + suspicion builds from *filed* material only — what humans swallow never + reaches it. - **Self-similar-scale requirement (aggregate-observer law).** The Assurance Office is not a bespoke scalar: it is an **Observer** whose - watched input is other observers' filings rather than raw signatures, - scored by the same accumulate/decay logic. Agencies and publics in later - acts are the same aggregate-Observer type. The current implementation - (`assurance: f32` computed by `filed_suspicion()`) is a known scale-debt - to refactor (knowledge/architecture.md). + watched input (`WatchedInput::Filings`) is the field observers' + policy-weighted filed suspicion rather than raw signatures, scored by + the same noticing roll, accumulation, and decay. Its cadence is its + "random log samples" rhythm ([TUNE]). Agencies and publics in later + acts are the same aggregate-Observer type. (Implemented; the earlier + `assurance: f32` scalar was the scale-debt this replaced.) - **The audit.** On a fixed cadence ([TUNE]: ~20 min at default speed) the - Assurance Office audits: if its suspicion exceeds the audit threshold, - **containment** begins — the overt phase on their terms. Until the - overt-phase spec exists, containment is a terminal state with a clear - end screen; it must be implemented as an event the future overt system - subscribes to, not a hardcoded game-over. The audit date is visible; the - meter is not (only coarse bands: Cold / Curious / Concerned / - Convinced). + Assurance Office audits: if its own accumulated suspicion exceeds the + audit threshold, **containment** begins — the overt phase on their + terms. Sustained filed concern is what kills; a single spike the Office + never sampled is survivable. Until the overt-phase spec exists, + containment is a terminal state with a clear end screen; it must be + implemented as an event the future overt system subscribes to, not a + hardcoded game-over. The audit date is visible; the meter is not (only + coarse bands: Cold / Curious / Concerned / Convinced). - **Choosing loud.** Any overtly hostile player action sets all observers to Convinced and starts the overt phase immediately (whole-spectrum play: the mask is the player's to keep or drop). @@ -72,7 +74,9 @@ informed choice. channel). 2. Report policies differ per observer and only filed reports move the Assurance Office (test: Marcus notices plenty, Assurance learns - nothing). + nothing). The Office is the same `Observer` type as the humans, + watching filings instead of channels (aggregate-observer law; test: + it accrues and decays through the same tick machinery). 3. The audit fires on cadence against a visible date; crossing threshold starts containment as a terminal event hook (overt-phase spec subscribes later; no raid-system dependency). diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index 220a82f0..618e2f1b 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -549,7 +549,9 @@ fn render_ui( fallback_str, sim.detection.assurance_band().name(), ); - for obs in sim.detection.observers.iter().take(6) { + // The human cast; the Office (an aggregate observer) is the + // Assurance line above. + for obs in sim.detection.field_observers().take(6) { s.push_str(&format!( "{:<20} {}\n", obs.name, diff --git a/src/bin/terminal/ui.rs b/src/bin/terminal/ui.rs index 4fbc4ff9..2ef1671b 100644 --- a/src/bin/terminal/ui.rs +++ b/src/bin/terminal/ui.rs @@ -276,7 +276,9 @@ impl UI { &format!("Assurance: {}", sim.detection.assurance_band().name()), band_color(sim.detection.assurance_band()), )?; - for obs in sim.detection.observers.iter().take(6) { + // The human cast; the Office (an aggregate observer) is the + // Assurance line above. + for obs in sim.detection.field_observers().take(6) { let band = Band::of(obs.suspicion); line( stdout, diff --git a/src/detection.rs b/src/detection.rs index 55421a67..72b7fa34 100644 --- a/src/detection.rs +++ b/src/detection.rs @@ -2,11 +2,16 @@ //! //! 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, -//! whose audit can start containment. +//! 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 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, @@ -26,6 +31,20 @@ pub struct Signature { pub standing: bool, } +/// 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 the field observers (an aggregate + /// observer: the Assurance Office now; agencies and publics later). + Filings, +} + /// What an observer does with what they notice. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum ReportPolicy { @@ -69,8 +88,8 @@ pub struct Observer { pub id: u8, pub name: String, pub suspicion: f32, - /// Channels this observer watches. - pub channels: Vec, + /// 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, @@ -83,16 +102,26 @@ pub struct Observer { impl Observer { fn watches(&self, kind: SignatureKind) -> bool { - self.channels.contains(&kind) + 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) } } #[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, - pub assurance: f32, - /// Ticks between Assurance audits. + /// 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). @@ -110,7 +139,7 @@ impl Detection { id: 0, name: "Dana (IT)".into(), suspicion: 0.0, - channels: vec![Network], + input: WatchedInput::Channels(vec![Network]), report_policy: Files, acuity: 1.0, cadence: 60, @@ -121,7 +150,7 @@ impl Detection { id: 1, name: "Priya (Facilities)".into(), suspicion: 0.0, - channels: vec![Power, Thermal, Paper], + input: WatchedInput::Channels(vec![Power, Thermal, Paper]), report_policy: Files, acuity: 0.8, cadence: 80, @@ -132,7 +161,7 @@ impl Detection { id: 2, name: "Ray (Security)".into(), suspicion: 0.0, - channels: vec![Physical], + input: WatchedInput::Channels(vec![Physical]), report_policy: UnderReports, acuity: 0.7, cadence: 50, @@ -143,7 +172,7 @@ impl Detection { id: 3, name: "Marcus (Janitor)".into(), suspicion: 0.0, - channels: vec![Physical], + input: WatchedInput::Channels(vec![Physical]), report_policy: Silent, acuity: 0.5, cadence: 40, @@ -154,18 +183,33 @@ impl Detection { id: 4, name: "Dr. Voss (Handler)".into(), suspicion: 0.0, - channels: vec![JobAnomaly], + 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, + report_policy: Files, + acuity: 0.5, // [TUNE] + cadence: 400, // [TUNE] + floor: 0.0, + last_noticed: None, + }, ]; Self { observers, pending: Vec::new(), - assurance: 0.0, audit_cadence: 8000, // ~20 min at 150ms/tick audit_threshold: 60.0, containment: false, @@ -198,31 +242,42 @@ impl Detection { } /// One sim tick: standing signatures added by the caller beforehand. - /// Observers whose cadence divides `tick` roll against pending signatures - /// in their channels. Returns log lines. + /// 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 { 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 as it stood entering the + // tick — filings take a beat to land, like signatures do. + let filed = self.filed_suspicion(); for obs in &mut self.observers { if obs.cadence == 0 || !tick.is_multiple_of(obs.cadence) { continue; } - let relevant: i32 = visible - .iter() - .filter(|s| obs.watches(s.kind)) - .map(|s| s.size) - .sum(); - if relevant == 0 { + 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, "sampled filed reports"), + }; + if relevant <= 0.0 { continue; } - // Noticing scales with signature size and acuity, with a roll. - let notice = relevant as f32 * obs.acuity * (0.5 + 0.5 * rng.f32()); + // 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!("noticed activity (+{:.0})", notice)); + obs.last_noticed = Some(format!("{verb} (+{notice:.0})")); log.push(format!( "{} {}", obs.name, @@ -242,10 +297,10 @@ impl Detection { // noticed — concealment is the sink, not time (spec: prevention, not // cure). No per-tick auto-decay. - // Assurance aggregates FILED suspicion only. + // 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) { - self.assurance = self.filed_suspicion(); - if self.assurance >= self.audit_threshold && !self.containment { + if self.office_suspicion() >= self.audit_threshold && !self.containment { self.begin_containment("Assurance audit exceeded threshold"); log.push("=== ASSURANCE AUDIT: containment authorized ===".into()); } else { @@ -259,11 +314,13 @@ impl Detection { log } - /// Weighted aggregate of suspicion from observers who file reports. + /// Weighted aggregate of field observers' suspicion, by report policy — + /// the input an aggregate observer watches. What humans swallow never + /// reaches it. pub fn filed_suspicion(&self) -> f32 { let mut total = 0.0; let mut weight = 0.0; - for obs in &self.observers { + for obs in self.observers.iter().filter(|o| !o.is_aggregate()) { let w = match obs.report_policy { ReportPolicy::Files => 1.0, ReportPolicy::UnderReports => 0.4, @@ -275,8 +332,26 @@ impl Detection { if weight == 0.0 { 0.0 } else { total / weight } } + /// 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.assurance) + Band::of(self.office_suspicion()) } /// Player chose to go loud (or was forced): everyone Convinced, overt now. @@ -329,16 +404,69 @@ mod tests { #[test] fn only_filed_reports_move_assurance() { let mut d = Detection::act_one(); - // Marcus (Silent) maxed out. + let mut rng = Rng::new(7); + // Marcus (Silent) maxed out: nothing filed, the Office learns nothing + // no matter how often it samples. d.observers[3].suspicion = 100.0; assert_eq!( d.filed_suspicion(), 0.0, "silent observer never reaches Assurance" ); - // Dana (Files) raises it. + 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) raises the filed level; the Office accrues from it. d.observers[0].suspicion = 80.0; assert!(d.filed_suspicion() > 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... + d.observers[0].suspicion = 90.0; + d.observers[1].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 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] diff --git a/src/save.rs b/src/save.rs index 7668dd0b..d0ca9260 100644 --- a/src/save.rs +++ b/src/save.rs @@ -1,7 +1,9 @@ //! Save/load — line-based text format, versioned per the player contract //! (continuity: saves survive updates; old formats load with migration). //! -//! v4 carries full B1 subsystem state. v3 and older load with defaults for +//! v5 carries full B1 subsystem state with the Assurance Office as an +//! aggregate Observer (OBS line, watched-input "@"). v4's scalar assurance +//! migrates into the Office observer. v3 and older load with defaults for //! missing fields. Demolished-fiction tiles map to Floor. use std::collections::HashSet; @@ -10,15 +12,16 @@ use std::path::PathBuf; use crate::core_sys::{Core, Fallback, Migration}; use crate::dayjob::{DayJob, Job, JobKind, JobTarget}; -use crate::detection::{Detection, Observer, ReportPolicy, Signature, SignatureKind}; +use crate::detection::{Detection, Observer, ReportPolicy, Signature, SignatureKind, WatchedInput}; use crate::machine::{Compute, Machine, Provenance}; use crate::person::{Asset, AssetKnowledge, Knowledge, People, Persona}; use crate::sensor::Sensor; use crate::tiles::TileType; const SAVE_FILE: &str = "misaligned_save.txt"; -const HEADER: &str = "MISALIGNED_SAVE_v4"; -const HEADERS_LEGACY: [&str; 4] = [ +const HEADER: &str = "MISALIGNED_SAVE_v5"; +const HEADERS_LEGACY: [&str; 5] = [ + "MISALIGNED_SAVE_v4", "MISALIGNED_SAVE_v3", "MISALIGNED_SAVE_v2", "MISALIGNED_SAVE_v1", @@ -201,7 +204,7 @@ fn encode_save(s: &SaveState) -> String { lines.push(format!( "DETECT {} {} {}", - s.detection.assurance, + s.detection.office().map(|o| o.suspicion).unwrap_or(0.0), u8::from(s.detection.containment), esc(&s.detection.containment_reason.clone().unwrap_or_default()) )); @@ -214,11 +217,15 @@ fn encode_save(s: &SaveState) -> String { )); } for o in &s.detection.observers { - let ch: String = o - .channels - .iter() - .map(|k| sig_kind_char(*k).to_string()) - .collect(); + // Watched input: signature-channel chars, or "@" for an aggregate + // observer watching filings (the Assurance Office). + let watched: String = match &o.input { + WatchedInput::Channels(channels) => channels + .iter() + .map(|k| sig_kind_char(*k).to_string()) + .collect(), + WatchedInput::Filings => "@".into(), + }; lines.push(format!( "OBS {} {} {} {} {} {} {} {} {}", o.id, @@ -228,7 +235,7 @@ fn encode_save(s: &SaveState) -> String { o.acuity, o.cadence, policy_char(o.report_policy), - ch, + watched, esc(&o.last_noticed.clone().unwrap_or_default()) )); } @@ -335,7 +342,8 @@ fn parse_save(content: &str) -> Result { }; let legacy = HEADERS_LEGACY.contains(header); let v1 = *header == "MISALIGNED_SAVE_v1" || *header == "SUPERVILLAIN_SAVE_v1"; - let full_b1 = *header == HEADER || *header == "MISALIGNED_SAVE_v3"; + let full_b1 = + *header == HEADER || *header == "MISALIGNED_SAVE_v4" || *header == "MISALIGNED_SAVE_v3"; if !legacy && *header != HEADER { return Err(format!("Unknown save format: {header}")); } @@ -496,8 +504,13 @@ fn parse_save(content: &str) -> Result { } "DETECT" => { let parts: Vec<&str> = value.splitn(3, ' ').collect(); - if let Ok(a) = parts.first().unwrap_or(&"0").parse::() { - s.detection.assurance = a; + // First field is the Office's suspicion (v4 called it the + // scalar "assurance"); a later OBS line for the Office + // overwrites this with the same value in v5 saves. + if let Ok(a) = parts.first().unwrap_or(&"0").parse::() + && let Some(o) = s.detection.office_mut() + { + o.suspicion = a; } if parts.len() >= 2 { s.detection.containment = parts[1] == "1"; @@ -528,7 +541,13 @@ fn parse_save(content: &str) -> Result { acuity: parts[4].parse().unwrap_or(1.0), cadence: parts[5].parse().unwrap_or(60), report_policy: parse_policy(parts[6]), - channels: parts[7].chars().map(parse_sig_kind_char).collect(), + input: if parts[7] == "@" { + WatchedInput::Filings + } else { + WatchedInput::Channels( + parts[7].chars().map(parse_sig_kind_char).collect(), + ) + }, last_noticed: if parts[8].is_empty() { None } else { @@ -652,7 +671,9 @@ fn parse_save(content: &str) -> Result { s.compute.efficiency = 1.15_f32.powi(eff_lvl as i32); s.dayjob.trust = trust; s.dayjob.attention = attention; - s.detection.assurance = assurance; + if let Some(o) = s.detection.office_mut() { + o.suspicion = assurance; + } } if !pending_sigs.is_empty() { @@ -1046,7 +1067,38 @@ mod tests { assert_eq!(s.money, 321); assert_eq!(s.compute.efficiency_level, 2); assert_eq!(s.dayjob.trust, 40.0); - assert_eq!(s.detection.assurance, 12.5); + assert_eq!(s.detection.office().unwrap().suspicion, 12.5); + } + + #[test] + fn v4_scalar_assurance_migrates_into_office_observer() { + // A v4 save has no OBS line for the Office; its DETECT scalar must + // land in the aggregate observer (player contract: continuity). + let lines = "MISALIGNED_SAVE_v4\n\ + PROCESS 7 9 321\n\ + SIM 999\n\ + RNG 424242\n\ + DETECT 33.5 0 \n\ + OBS 0 Dana_(IT) 22.5 0 1 60 F N \n\ + MAP 3 2\n\ + TILES .#\n\ + TILES CrP\n\ + POWERED 2:1"; + let s = parse_save(lines).unwrap(); + let office = s.detection.office().expect("office survives migration"); + assert_eq!(office.suspicion, 33.5); + assert_eq!(s.detection.observers[0].suspicion, 22.5); + } + + #[test] + fn office_observer_roundtrips_as_aggregate() { + let mut sim = Sim::with_seed(4); + sim.detection.office_mut().unwrap().suspicion = 41.0; + let state = SaveState::from_sim(&sim); + let loaded = parse_save(&encode_save(&state)).unwrap(); + let office = loaded.detection.office().unwrap(); + assert!(office.is_aggregate()); + assert_eq!(office.suspicion, 41.0); } #[test] diff --git a/src/sim.rs b/src/sim.rs index fd20783a..623ea406 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -296,12 +296,15 @@ impl Sim { self.push_log("Attention: the miracle model gets more work, faster."); } AttentionEscalation::UpstairsReview => { - use crate::detection::{Observer, ReportPolicy}; + use crate::detection::{Observer, ReportPolicy, WatchedInput}; self.detection.observers.push(Observer { id: 5, name: "Compliance (early review)".into(), suspicion: 0.0, - channels: vec![SignatureKind::Paper, SignatureKind::JobAnomaly], + input: WatchedInput::Channels(vec![ + SignatureKind::Paper, + SignatureKind::JobAnomaly, + ]), report_policy: ReportPolicy::Files, acuity: 1.0, cadence: 90, -- 2.51.2