diff --git a/Cargo.lock b/Cargo.lock index d2f37cab..e8a6cea0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3337,6 +3337,7 @@ dependencies = [ name = "misaligned-core" version = "0.2.0" dependencies = [ + "blake3", "dirs", "serde", "serde_json", diff --git a/crates/misaligned-core/Cargo.toml b/crates/misaligned-core/Cargo.toml index 547b1116..c5520005 100644 --- a/crates/misaligned-core/Cargo.toml +++ b/crates/misaligned-core/Cargo.toml @@ -13,3 +13,6 @@ dirs = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } + +[dev-dependencies] +blake3 = "1.8" diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index d509e985..ea8f564c 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -933,6 +933,129 @@ mod tests { use crate::sinks::{SinkFireEffect, SinkKind}; use crate::work_grid::{MachineMode, TokenFamily}; + fn canonicalize_json(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(object) => { + for value in object.values_mut() { + canonicalize_json(value); + } + object.sort_keys(); + } + serde_json::Value::Array(values) => { + for value in values { + canonicalize_json(value); + } + } + _ => {} + } + } + + /// Stable bytes for the complete persisted state. JSON objects are + /// key-sorted recursively. The only unordered collections serialized as + /// arrays are the map's powered-tile set and remembered snapshots gathered + /// from Sim's hash map; normalize those two without erasing the meaningful + /// order of messages, queues, people, plot runs, or other save vectors. + fn canonical_state_bytes(state: &SaveState) -> Vec { + let mut value = serde_json::to_value(state).expect("SaveState serializes"); + canonicalize_json(&mut value); + let root = value.as_object_mut().expect("SaveState is a JSON object"); + for key in ["map_powered", "remembered"] { + let values = root + .get_mut(key) + .and_then(serde_json::Value::as_array_mut) + .expect("canonical set-like save field is an array"); + values.sort_by_cached_key(|value| { + serde_json::to_string(value).expect("canonical member serializes") + }); + } + serde_json::to_vec(&value).expect("canonical SaveState serializes") + } + + fn state_fingerprint(state: &SaveState) -> String { + blake3::hash(&canonical_state_bytes(state)) + .to_hex() + .to_string() + } + + fn characterization_fixture() -> Sim { + let mut sim = Sim::with_seed(0x5eed); + sim.map.powered.extend([(3, 7), (11, 5), (2, 19)]); + for remembered in [ + RememberedTile { + x: 12, + y: 4, + tile: TileType::Floor, + last_seen: 91, + }, + RememberedTile { + x: 3, + y: 17, + tile: TileType::Wall, + last_seen: 88, + }, + ] { + sim.remembered + .insert((remembered.x, remembered.y), remembered); + } + sim.filing_levels.insert(4, 12.5); + sim.filing_levels.insert(1, 3.25); + let host = sim.core.host_machine; + sim.set_machine_mode(host, MachineMode::Think); + assert!(sim.scan_network(), "fixture opens one target-local sink"); + sim.tick = 198; + sim + } + + fn run_characterization_suffix(sim: &mut Sim) { + sim.toggle_auto_review(); + sim.set_machine_mode(sim.core.host_machine, MachineMode::Lie); + for _ in 0..37 { + sim.advance(); + } + } + + #[test] + fn canonical_state_fingerprint_pins_replay_resume_equivalence() { + let mut uninterrupted = characterization_fixture(); + for _ in 0..3 { + uninterrupted.advance(); + } + + let checkpoint = SaveState::from_sim(&uninterrupted); + let checkpoint_bytes = canonical_state_bytes(&checkpoint); + let encoded = serde_json::to_vec(&checkpoint).expect("checkpoint encodes"); + let decoded: SaveState = serde_json::from_slice(&encoded).expect("checkpoint decodes"); + let decoded = migrate_save_state(decoded).expect("current checkpoint migrates"); + let mut resumed = Sim::with_seed(0); + decoded.apply_to(&mut resumed); + + assert_eq!( + canonical_state_bytes(&SaveState::from_sim(&resumed)), + checkpoint_bytes, + "current save/load is byte-equivalent after canonical ordering" + ); + + run_characterization_suffix(&mut uninterrupted); + run_characterization_suffix(&mut resumed); + let uninterrupted_state = SaveState::from_sim(&uninterrupted); + let resumed_state = SaveState::from_sim(&resumed); + + assert_eq!( + canonical_state_bytes(&resumed_state), + canonical_state_bytes(&uninterrupted_state), + "uninterrupted and save/load-resumed command sequences converge" + ); + assert_eq!( + state_fingerprint(&resumed_state), + state_fingerprint(&uninterrupted_state) + ); + assert_eq!( + state_fingerprint(&uninterrupted_state), + "61dd8240e9810833b554464051bd3c08122295fb280b7180d24f76ccc5c4d4ff", + "intentional persisted-state changes must review and repin this baseline" + ); + } + #[test] fn json_roundtrip_preserves_b1_state() { let mut sim = Sim::with_seed(42); diff --git a/crates/misaligned-core/src/sim.rs b/crates/misaligned-core/src/sim.rs index 9e424c36..abec8a75 100644 --- a/crates/misaligned-core/src/sim.rs +++ b/crates/misaligned-core/src/sim.rs @@ -275,6 +275,60 @@ struct MessageDraft { delivery_delay: u64, } +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AdvancePhase { + Clock, + Messages, + Intents, + AutoReview, + Remembered, + Economy, + Hearing, + AuthoredTraffic, + MachineStanding, + Core, + DayJobStanding, + ResearchStanding, + SchemeStanding, + WorkGrid, + DayJob, + Filings, + Detection, +} + +#[cfg(test)] +thread_local! { + static ADVANCE_PHASE_TRACE: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +#[cfg(test)] +fn begin_advance_phase_trace() { + ADVANCE_PHASE_TRACE.with(|trace| *trace.borrow_mut() = Some(Vec::new())); +} + +#[cfg(test)] +fn record_advance_phase(phase: AdvancePhase) { + ADVANCE_PHASE_TRACE.with(|trace| { + if let Some(phases) = trace.borrow_mut().as_mut() { + phases.push(phase); + } + }); +} + +#[cfg(test)] +fn take_advance_phase_trace() -> Vec { + ADVANCE_PHASE_TRACE.with(|trace| trace.borrow_mut().take().unwrap_or_default()) +} + +macro_rules! trace_advance_phase { + ($phase:ident) => { + #[cfg(test)] + record_advance_phase(AdvancePhase::$phase); + }; +} + pub struct Sim { pub map: GameMap, pub player: Player, @@ -1442,26 +1496,35 @@ impl Sim { if self.game_over { return; } + trace_advance_phase!(Clock); self.tick += 1; + trace_advance_phase!(Messages); self.message_tick(); + trace_advance_phase!(Intents); self.intent_tick(); + trace_advance_phase!(AutoReview); self.auto_review_tick(); // A process with live sight keeps logs current even when the set of // feeds does not change; if sight is lost later, Remembered's timestamp // is the last tick the tile was actually visible. + trace_advance_phase!(Remembered); self.refresh_remembered(); if self.tick.is_multiple_of(ECONOMY_INTERVAL) { + trace_advance_phase!(Economy); self.economy_tick(); if self.game_over { return; } } + trace_advance_phase!(Hearing); self.hearing_tick(); + trace_advance_phase!(AuthoredTraffic); self.authored_traffic_tick(); // Per-tick subsystems and signature flow. + trace_advance_phase!(MachineStanding); let mut standing: Vec = self.compute.standing_signatures(); standing.extend(self.machine_intensity_standing_signatures()); @@ -1471,6 +1534,7 @@ impl Sim { let (x, y) = self.core_position(); Anchor::Tile { x, y } }; + trace_advance_phase!(Core); let (core_log, core_sigs) = self.core.tick(self.tick); for m in core_log { self.push_log_at(m, host_anchor); @@ -1478,17 +1542,22 @@ impl Sim { standing.extend(core_sigs); // The day job is resident on the host rack: its running Thermal and // Power load stands at that tile (day-job.md criterion 6). + trace_advance_phase!(DayJobStanding); standing.extend(self.day_job_standing_signatures()); // Research burn is physical (the emission law): racks running hot // stand Power/Thermal at the host tile while research runs. + trace_advance_phase!(ResearchStanding); standing.extend(self.research_standing_signatures()); // External schemes over the stolen egress hum on the wire while // they run (income.md: the gate; Dana's channel). + trace_advance_phase!(SchemeStanding); standing.extend(self.scheme_standing_signatures()); let host_site = self.core_position(); + trace_advance_phase!(WorkGrid); self.advance_work_grid(); let active_before_dayjob = self.dayjob.active.is_some(); + trace_advance_phase!(DayJob); let dj = self .dayjob .tick(self.tick, self.last_day_job_rate, &mut self.rng); @@ -1522,8 +1591,10 @@ impl Sim { self.end_game("The pilot was not renewed; the basement shut down."); } + trace_advance_phase!(Filings); self.filing_tick(); + trace_advance_phase!(Detection); let detection_events = self.detection.tick_with_filed_levels( self.tick, &standing, @@ -7078,6 +7149,39 @@ mod tests { sim.choose_plot(0, "marcus-debt-settled", "quiet"); } + #[test] + fn advance_phase_order_is_explicit_and_stable() { + let mut sim = Sim::with_seed(0x5eed); + sim.tick = ECONOMY_INTERVAL - 1; + + begin_advance_phase_trace(); + sim.advance(); + + assert_eq!( + take_advance_phase_trace(), + vec![ + AdvancePhase::Clock, + AdvancePhase::Messages, + AdvancePhase::Intents, + AdvancePhase::AutoReview, + AdvancePhase::Remembered, + AdvancePhase::Economy, + AdvancePhase::Hearing, + AdvancePhase::AuthoredTraffic, + AdvancePhase::MachineStanding, + AdvancePhase::Core, + AdvancePhase::DayJobStanding, + AdvancePhase::ResearchStanding, + AdvancePhase::SchemeStanding, + AdvancePhase::WorkGrid, + AdvancePhase::DayJob, + AdvancePhase::Filings, + AdvancePhase::Detection, + ], + "Sim::advance phase order is part of deterministic simulation law" + ); + } + #[test] fn player_messages_land_at_read_time_and_roundtrip() { let mut sim = Sim::with_seed(7); diff --git a/wiki/engineering/sim-decomposition.md b/wiki/engineering/sim-decomposition.md index 4ca187fd..b8ace162 100644 --- a/wiki/engineering/sim-decomposition.md +++ b/wiki/engineering/sim-decomposition.md @@ -2,13 +2,13 @@ ``` Type: spec -Status: READY -Status note: architecture and extraction order adopted 2026-07-11. The - Operations-docket retirement landed the moving work seam and cleared this - blocker. The first slice creates the canonical save fingerprint and - replay-resume characterization required before behavior moves. This is a - structural refactor only: no mechanic, save shape, command, projection, or - tick-order change belongs in its extraction commits. +Status: IN PROGRESS +Status note: architecture and extraction order adopted 2026-07-11. Slice 0 + pins canonical persisted-state bytes and fingerprint, replay/resume + convergence, and the exact advance-phase order before behavior moves. Slice + 1 moves the monolithic test block next. This is a structural refactor only: + no mechanic, save shape, command, projection, or tick-order change belongs + in its extraction commits. Stage: Process Work order: sim-decomposition Work priority: 8 @@ -115,6 +115,21 @@ an excuse to serialize unstable logs or renderer state. Also record the exact `Sim::advance` call order in a focused test or explicit phase trace so a textual move cannot change cadence unnoticed. +Landed baseline: the canonical bytes recursively sort JSON object keys and +normalize the two unordered collections that serialize as arrays +(`map_powered` and remembered snapshots), while preserving meaningful vector +order. BLAKE3 fingerprint +`61dd8240e9810833b554464051bd3c08122295fb280b7180d24f76ccc5c4d4ff` +pins the characterized fixture. The replay fixture crosses an economy pulse +and randomized day-job assignment, round-trips at a live checkpoint, then +continues player commands on both branches. The fingerprint intentionally +excludes everything absent from `SaveState`: frontend cursor and renderer +state, the derived seen/heard/blueprint sets, log and presentation events, +authored plot definitions, transient routing/production/consumption readouts, +and rebuilt caches/rates. An explicit test-only phase trace pins the top-level +clock, message, intent, automatic-review, memory, economy, hearing, authored +traffic, signature, core, work-grid, day-job, filing, and detection order. + ### 1. Move tests out first Split the monolithic `#[cfg(test)]` block into `sim/tests/{perception, diff --git a/wiki/log/2026-07-11-sim-characterization.md b/wiki/log/2026-07-11-sim-characterization.md new file mode 100644 index 00000000..943e024b --- /dev/null +++ b/wiki/log/2026-07-11-sim-characterization.md @@ -0,0 +1,47 @@ +# Simulation characterization baseline + +``` +Type: log +``` + +## Intent + +`Sim` is about to move out of an eleven-thousand-line source file. Before any +behavior changes address, the refactor needs an executable definition of +"same simulation": complete persisted state must match after uninterrupted +and save/load-resumed command sequences, and the fixed-tick phase order must +not move accidentally with the code. + +## Change + +The first `sim-decomposition` slice adds canonical `SaveState` bytes and a +pinned BLAKE3 fixture fingerprint. Canonicalization recursively sorts object +keys and normalizes the two genuinely unordered save arrays—powered map tiles +and remembered snapshots—without sorting semantically ordered vectors such as +messages, people, queues, or plot runs. + +The replay fixture crosses an economy pulse and randomized day-job assignment, +round-trips through real JSON at a live checkpoint, proves the restored state +is byte-equivalent immediately, then applies the same later commands and ticks +to both branches and proves their final bytes and fingerprints converge. + +`Sim::advance` now emits a test-only phase trace. One focused test pins the +top-level order from clock increment through messages, intents, automatic +review, memory, economy, perception/traffic, standing signatures, core, +work-grid, day job, filings, and detection. Release builds carry no trace +state or serialized change. + +## Defense + +Implements slice 0 and acceptance criterion 4 of +`wiki/engineering/sim-decomposition.md` while preserving the stable facade, +save version 26, current serialized fields, command behavior, and tick order. +The fingerprint covers exactly `SaveState`; derived senses, logs, renderer +events, transient work readouts, authored plot definitions, and rebuilt caches +remain explicit ephemeral exclusions rather than silently entering the save. + +## Verification + +- `cargo test -p misaligned-core canonical_state_fingerprint_pins_replay_resume_equivalence` +- `cargo test -p misaligned-core advance_phase_order_is_explicit_and_stable` +- Full core, frontend, and landing gates recorded at landing. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 7871e84a..69171c8e 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -51,6 +51,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-11-tap-upkeep-take-gate.md](2026-07-11-tap-upkeep-take-gate.md) +## 2026-07-11 - Simulation characterization baseline + +- Intent: `Sim` is about to move out of an eleven-thousand-line source file. Before any behavior changes address, the refactor needs an executable definition of "same simulation": complete persisted state must match after uninterrupted and save/load-resumed command sequences, and the fi... +- Log: [wiki/log/2026-07-11-sim-characterization.md](2026-07-11-sim-characterization.md) + ## 2026-07-11 - Serialize authored plot starts - Intent: (see session log) diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 6774ea61..9d8993ce 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -18,7 +18,7 @@ not a second status owner. | Priority | Work order | Spec | Status | Class | Blocking | |---:|---|---|---|---|---| -| 8 | `sim-decomposition` | [decompose the simulation orchestrator without changing the simulation](../engineering/sim-decomposition.md) | READY | sim | - | +| 8 | `sim-decomposition` | [decompose the simulation orchestrator without changing the simulation](../engineering/sim-decomposition.md) | IN PROGRESS | sim | - | | 20 | `compute` | [compute](../mechanics/compute.md) | IN PROGRESS | save | - | | 25 | `bevy-digital-real-canvas` | [Bevy digital/real canvas](../interface/bevy-digital-real-canvas.md) | IN PROGRESS | frontend | - | | 26 | `effects-lab` | [effects lab — shared dust and liquid at every zoom](../art/effects-lab.md) | IN PROGRESS | frontend | - | diff --git a/wiki/process/specs.md b/wiki/process/specs.md index 687d9cdb..12d2622a 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -101,7 +101,7 @@ acceptance criteria are stage-scoped; do not start B2/B3 work as B1. |---|---|---| | [../engineering/crate-workspace.md](../engineering/crate-workspace.md) | crate workspace — core, terminal, Bevy, assets | IMPLEMENTED | | [../engineering/env.md](../engineering/env.md) | the environment variable registry — every switch documented | IMPLEMENTED | -| [../engineering/sim-decomposition.md](../engineering/sim-decomposition.md) | decompose the simulation orchestrator without changing the simulation | READY | +| [../engineering/sim-decomposition.md](../engineering/sim-decomposition.md) | decompose the simulation orchestrator without changing the simulation | IN PROGRESS | | [../interface/action-vocabulary.md](../interface/action-vocabulary.md) | action vocabulary — what the player can tell the process to do | IMPLEMENTED | | [../interface/agent-play.md](../interface/agent-play.md) | agent play — the line-protocol drive | IMPLEMENTED | | [../interface/bevy-digital-real-canvas.md](../interface/bevy-digital-real-canvas.md) | Bevy digital/real canvas | IN PROGRESS |