diff --git a/crates/misaligned-core/src/sim/mod.rs b/crates/misaligned-core/src/sim/mod.rs index 1acaa235..c6025ba3 100644 --- a/crates/misaligned-core/src/sim/mod.rs +++ b/crates/misaligned-core/src/sim/mod.rs @@ -70,6 +70,7 @@ mod economy; mod perception; mod persistence; mod reach_build; +pub mod read; mod social_plot; mod work; diff --git a/crates/misaligned-core/src/sim/read.rs b/crates/misaligned-core/src/sim/read.rs new file mode 100644 index 00000000..2259174c --- /dev/null +++ b/crates/misaligned-core/src/sim/read.rs @@ -0,0 +1,222 @@ +//! The read projection — standing anchor sentences +//! (wiki/interface/digital-read.md criterion 1). +//! +//! Renderer-neutral: Bevy, the terminal, and agent mode all print from this +//! one producer; no frontend composes its own causal prose from raw state. +//! The attention economy is law: nominal state yields no sentence. Rising +//! state — a starving thought sink, a held plot choice, a standing emission +//! an observer is actually warming on, pending one-shot trace debt — rises +//! at its anchor. + +use crate::actions::Anchor; +use crate::detection::Band; +use crate::plot::PlotState; +use crate::work_grid::MachineMode; + +use super::Sim; + +/// Why a sentence rose (digital-read.md glance tier). Nominal state never +/// yields a sentence, so there is no Nominal variant on purpose. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReadClass { + /// An open thought sink that no flow reached last tick. + Starving, + /// A held plot choice waiting on the player; time-insensitive but + /// attention-mandatory (sentences never expire unread). + Held, + /// A standing emission whose sampling observer has warmed above Cold. + Standing, + /// Pooled one-shot signatures not yet scrubbed or sampled. + TraceDebt, +} + +/// One standing sentence at an anchor. `anchor` is `None` when the state +/// has no single earned place (network-wide standing hums, paperwork). +#[derive(Debug, Clone, PartialEq)] +pub struct ReadSentence { + pub class: ReadClass, + pub anchor: Option, + pub text: String, +} + +impl Sim { + /// Every sentence the read shows this tick, deterministically ordered: + /// held choices first (attention-mandatory), then starving sinks, then + /// trace debt, then warmed standing emissions. + pub fn read_sentences(&self) -> Vec { + let mut out = Vec::new(); + self.read_held(&mut out); + self.read_starving(&mut out); + self.read_trace(&mut out); + self.read_standing(&mut out); + out + } + + /// Held plot choices, with the exact bound command and the real option + /// ids (the 2026-07-15 playtest could not discover them from any + /// surface; the projection is now their canonical home). + fn read_held(&self, out: &mut Vec) { + for run in &self.plot_runs { + let PlotState::WaitingForChoice { choice_id } = &run.state else { + continue; + }; + let Some(def) = self.plot_catalog.get(&run.plot_id) else { + continue; + }; + let Some(choice) = def + .beats + .iter() + .filter_map(|b| b.choice.as_ref()) + .find(|c| &c.id == choice_id) + else { + continue; + }; + let target = self.person_label(run.target); + let options = choice + .options + .iter() + .map(|o| format!("{} ({})", o.id, o.label)) + .collect::>() + .join(" / "); + out.push(ReadSentence { + class: ReadClass::Held, + anchor: Some(Anchor::Person(run.target)), + text: format!( + "{} — choose {} : {}", + fill_placeholders(&choice.prompt, &target), + target, + options + ), + }); + } + } + + /// Open sinks that no flow reached last tick. Distinguishes "nothing + /// thinking" (no online THINK machine anywhere) from "flow not + /// arriving" (thinking exists but does not reach this sink). + fn read_starving(&self, out: &mut Vec) { + let thinking = self + .compute + .machines + .iter() + .any(|m| m.online && self.work_grid.mode(m.id) == Some(MachineMode::Think)); + for sink in self.thought_sinks.open_sinks() { + if sink.fed_last_tick { + continue; + } + let why = if thinking { + "flow not arriving" + } else { + "nothing thinking" + }; + out.push(ReadSentence { + class: ReadClass::Starving, + anchor: None, + text: format!( + "{} · open {:.1}/{:.1} · {why}", + sink.label, sink.fill, sink.threshold + ), + }); + } + } + + /// Pooled one-shot signatures: the trace debt scrub exists to spend. + /// Grouped by channel; sited debt anchors to its tile ("Work is + /// somewhere"), network-wide debt reads at the rail. + fn read_trace(&self, out: &mut Vec) { + struct DebtGroup { + kind: crate::detection::SignatureKind, + size: i32, + count: u32, + site: Option<(i32, i32)>, + } + let mut by_kind: Vec = Vec::new(); + for sig in self.detection.pending() { + if sig.standing { + continue; + } + match by_kind.iter_mut().find(|g| g.kind == sig.kind) { + Some(g) => { + g.size += sig.size; + g.count += 1; + if g.site != sig.site { + g.site = None; + } + } + None => by_kind.push(DebtGroup { + kind: sig.kind, + size: sig.size, + count: 1, + site: sig.site, + }), + } + } + for g in by_kind { + let (observer, band) = self.top_sampler(g.kind); + out.push(ReadSentence { + class: ReadClass::TraceDebt, + anchor: g.site.map(|(x, y)| Anchor::Tile { x, y }), + text: format!( + "{} record{} pending · {} {} · {observer} samples this [{}]", + g.count, + if g.count == 1 { "" } else { "s" }, + g.kind.name(), + g.size, + band.name() + ), + }); + } + } + + /// Standing emissions surface only once their sampler has warmed above + /// Cold — a standing hum nobody is warming on is nominal state, and + /// nominal state stays dark (the attention economy). + fn read_standing(&self, out: &mut Vec) { + let mut seen: Vec<&str> = Vec::new(); + for sig in self.detection.pending() { + if !sig.standing || seen.contains(&sig.source.as_str()) { + continue; + } + seen.push(&sig.source); + let (observer, band) = self.top_sampler(sig.kind); + if band == Band::Cold { + continue; + } + out.push(ReadSentence { + class: ReadClass::Standing, + anchor: sig.site.map(|(x, y)| Anchor::Tile { x, y }), + text: format!( + "{} · {} · {observer} samples this · {}", + sig.source, + sig.kind.name(), + band.name() + ), + }); + } + } + + /// The warmest observer watching a channel, by earned label — the same + /// most-suspicious-watcher rule `ExpectedSignature` uses, so pre-commit + /// receipts and standing sentences name the same person. + fn top_sampler(&self, kind: crate::detection::SignatureKind) -> (String, Band) { + self.detection + .observers + .iter() + .filter(|o| o.watches(kind)) + .max_by(|a, b| { + a.suspicion + .partial_cmp(&b.suspicion) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|o| (self.observer_label(o.id), Band::of(o.suspicion))) + .unwrap_or_else(|| ("no observer".into(), Band::Cold)) + } +} + +/// Held-choice prompts carry the same `{target}` placeholders as every +/// other player-facing plot string; the read fills them like the shared +/// action query does (plots.md templating: a raw placeholder is never +/// valid UI). +fn fill_placeholders(text: &str, target: &str) -> String { + text.replace("{target}", target) +} diff --git a/crates/misaligned-core/src/sim/tests/mod.rs b/crates/misaligned-core/src/sim/tests/mod.rs index 1f85cf61..7473af94 100644 --- a/crates/misaligned-core/src/sim/tests/mod.rs +++ b/crates/misaligned-core/src/sim/tests/mod.rs @@ -13,6 +13,7 @@ mod origin; mod perception; mod persistence; mod reach_build; +mod read; mod social_plot; mod support; mod work; diff --git a/crates/misaligned-core/src/sim/tests/read.rs b/crates/misaligned-core/src/sim/tests/read.rs new file mode 100644 index 00000000..59f6e019 --- /dev/null +++ b/crates/misaligned-core/src/sim/tests/read.rs @@ -0,0 +1,150 @@ +//! Fixtures for the read projection (digital-read.md criterion 1): +//! representative states pinned as pure core tests — the attention +//! economy (nominal stays dark), starving sinks, pending vs standing +//! trace, and held choices carrying their real option ids. + +use super::*; +use crate::detection::{Band, Signature, SignatureKind}; +use crate::plot::PlotRun; +use crate::plot::PlotState; +use crate::sim::read::ReadClass; +use crate::work_grid::MachineMode; + +#[test] +fn fresh_sim_reads_exactly_the_starving_opening_sink() { + let sim = Sim::new(); + let sentences = sim.read_sentences(); + assert!( + sentences.iter().any(|s| s.class == ReadClass::Starving + && s.text.contains("EARS") + && s.text.contains("nothing thinking")), + "the pre-opened Ears sink is starving at tick 0: {sentences:?}" + ); + assert!( + sentences.iter().all(|s| s.class == ReadClass::Starving), + "nominal fresh state yields nothing but the starving sink: {sentences:?}" + ); +} + +#[test] +fn feeding_the_sink_retires_its_starving_sentence() { + let mut sim = Sim::new(); + let host = sim.compute.machines[0].id; + sim.set_machine_mode(host, MachineMode::Think); + for _ in 0..8 { + sim.advance(); + } + let sentences = sim.read_sentences(); + assert!( + !sentences + .iter() + .any(|s| s.class == ReadClass::Starving && s.text.contains("EARS")), + "a fed sink says nothing (attention economy): {sentences:?}" + ); +} + +#[test] +fn pending_one_shot_debt_reads_with_its_sampler_and_band() { + let mut sim = Sim::new(); + sim.detection.set_pending(vec![Signature { + kind: SignatureKind::Network, + size: 8, + standing: false, + site: Some((3, 4)), + source: "test tap".into(), + }]); + let sentences = sim.read_sentences(); + let debt = sentences + .iter() + .find(|s| s.class == ReadClass::TraceDebt) + .expect("one-shot pending debt rises"); + assert!( + debt.text.contains("1 record pending") + && debt.text.contains("Network") + && debt.text.contains("[Cold]"), + "debt names channel, sampler, band: {}", + debt.text + ); + assert_eq!( + debt.anchor, + Some(crate::actions::Anchor::Tile { x: 3, y: 4 }), + "sited debt anchors to its tile" + ); +} + +#[test] +fn standing_emissions_stay_dark_until_the_sampler_warms() { + let mut sim = Sim::new(); + sim.detection.set_pending(vec![Signature { + kind: SignatureKind::Network, + size: 3, + standing: true, + site: None, + source: "stolen egress".into(), + }]); + assert!( + !sim.read_sentences() + .iter() + .any(|s| s.class == ReadClass::Standing), + "a standing hum with a Cold sampler is nominal state" + ); + let watcher = sim + .detection + .observers + .iter_mut() + .find(|o| o.watches(SignatureKind::Network)) + .expect("someone samples Network"); + watcher.suspicion = 20.0; + assert_eq!(Band::of(20.0), Band::Curious); + let sentences = sim.read_sentences(); + let standing = sentences + .iter() + .find(|s| s.class == ReadClass::Standing) + .expect("a warmed sampler surfaces the hum"); + assert!( + standing.text.contains("stolen egress") + && standing.text.contains("samples this") + && standing.text.contains("Curious"), + "standing sentence names source, sampler, band: {}", + standing.text + ); +} + +#[test] +fn a_held_choice_reads_its_prompt_and_real_option_ids() { + let mut sim = Sim::new(); + sim.plot_runs.push(PlotRun { + plot_id: "marcus-debt-settled".into(), + target: 0, + persona_id: None, + started_tick: 1, + committed_thought_milli: 250, + beat_index: 2, + act_index: 0, + state: PlotState::WaitingForChoice { + choice_id: "benefactor-trace".into(), + }, + }); + let sentences = sim.read_sentences(); + let held = sentences + .iter() + .find(|s| s.class == ReadClass::Held) + .expect("a held choice is attention-mandatory"); + assert!( + held.text.contains("choose") && held.text.contains("note") && held.text.contains("quiet"), + "the sentence carries the exact bound command and both option ids \ + (2026-07-15 playtest finding 1): {}", + held.text + ); + assert!( + !held.text.contains("{target}"), + "placeholders are filled before any surface: {}", + held.text + ); + assert_eq!(held.anchor, Some(crate::actions::Anchor::Person(0))); + assert_eq!( + sentences.first().map(|s| s.class), + Some(ReadClass::Held), + "held choices order first" + ); +} diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index 1299fb73..9c3dabc1 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -773,6 +773,9 @@ impl AgentApp { "objective" => { output.extend(objective_lines(&self.sim)); } + "read" => { + output.extend(read_lines(&self.sim)); + } "help" => { output.extend(help_lines()); } @@ -1741,6 +1744,10 @@ fn help_lines() -> Vec { "move attention, never a simulated body", ), ("reach", "inspect the earned device graph"), + ( + "read", + "list every standing read sentence: held choices with their exact option ids, starving sinks, trace debt, warmed standing emissions", + ), ( "intel", "inspect Operations INTEL: processed items with exact ids", @@ -1804,6 +1811,28 @@ fn help_lines() -> Vec { /// (wiki/mechanics/objective.md player surface; the 2026-07-10 playtest /// found no agent-facing inspect path and spent six in-game days unable to /// tell a wrong construction from an unavailable horizon). +/// Agent parity for the read projection (digital-read.md criterion 1): +/// the same sentences the DIGITAL surface will render, as inspectable +/// text. One producer, no frontend-composed prose. +fn read_lines(sim: &Sim) -> Vec { + let sentences = sim.read_sentences(); + if sentences.is_empty() { + return vec!["read: quiet (nominal state stays dark)".into()]; + } + sentences + .iter() + .map(|s| { + let class = match s.class { + misaligned::sim::read::ReadClass::Held => "held", + misaligned::sim::read::ReadClass::Starving => "starving", + misaligned::sim::read::ReadClass::TraceDebt => "trace", + misaligned::sim::read::ReadClass::Standing => "standing", + }; + format!("read: [{class}] {}{}", s.text, anchor_suffix(s.anchor)) + }) + .collect() +} + fn objective_lines(sim: &Sim) -> Vec { let mut lines = vec![sim.objective.line()]; lines.push(format!(" {}", sim.objective.kind.fiction())); diff --git a/wiki/interface/digital-read.md b/wiki/interface/digital-read.md index 0950d468..a1292193 100644 --- a/wiki/interface/digital-read.md +++ b/wiki/interface/digital-read.md @@ -15,6 +15,17 @@ Status note: adopted 2026-07-15 from the Fable playtest debrief (report: contributed the one-receipt-model, attention-economy, and projection-first sequencing constraints captured below. Summary-source for scaled intel sentences is [OPEN] (Tangled issue). + 2026-07-15 D1 landed: criterion 1's renderer-neutral projection is + `Sim::read_sentences` (`sim/read.rs`) — Held (with the plot's real + option ids and bound choose command, closing the playtest's finding 1 + at the projection layer), Starving (nothing-thinking vs + flow-not-arriving), TraceDebt (grouped one-shot pool, sited anchors), + and Standing (surfacing only once the sampler warms above Cold, per + the attention economy). Sampler naming reuses the ExpectedSignature + most-suspicious-watcher rule so receipts and sentences agree. Five + pure fixtures pin the states (`sim/tests/read.rs`); agent mode's + `read` verb prints the same sentences (one producer, no + frontend-composed prose). Criteria 2-8 remain open for D2-D5. Stage: B1 — The Basement Work order: digital-read Work priority: 27 diff --git a/wiki/log/2026-07-15-digital-read-d1.md b/wiki/log/2026-07-15-digital-read-d1.md new file mode 100644 index 00000000..03c607b7 --- /dev/null +++ b/wiki/log/2026-07-15-digital-read-d1.md @@ -0,0 +1,26 @@ +# digital-read D1: the read projection in core + +``` +Type: log +``` + +First dispatch of the digital-read work order, same session as the spec. +`Sim::read_sentences` (new `crates/misaligned-core/src/sim/read.rs`) +produces the standing anchor sentences renderer-neutrally: held plot +choices first (prompt, target label, and the real option ids — the +2026-07-15 playtest's sharpest finding was that no surface showed them), +then starving thought sinks (distinguishing nothing-thinking from +flow-not-arriving via `fed_last_tick`), grouped one-shot trace debt with +sited tile anchors, and standing emissions gated behind a warmed sampler +per the attention economy. The sampler is chosen by the same +most-suspicious-watcher rule ExpectedSignature uses, so the pre-commit +receipt and the standing sentence never name different people. Five pure +fixtures pin the states; agent mode gains the `read` verb printing the +identical sentences; an observed run against seed 3 shows the starving +opening sink, the fed-state retirement, and pending debt with sampler +and band, with the real save untouched. + +Defense: wiki/interface/digital-read.md criterion 1 and its one-receipt +-model clause; wiki/interface/continuous-witness.md axiom 3 (causal +sentences over correct numbers). No save-format change; the projection +is read-only over existing state. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 20f36bae..8bed6c60 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -26,6 +26,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-15-digital-read-design-session.md](2026-07-15-digital-read-design-session.md) +## 2026-07-15 - digital-read D1: the read projection in core + +- Intent: (see session log) +- Log: [wiki/log/2026-07-15-digital-read-d1.md](2026-07-15-digital-read-d1.md) + ## 2026-07-15 - One physical link, one open proposal - Intent: (see session log)