diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index c1f071b2..20208874 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -4987,6 +4987,9 @@ fn render_cursor(game: Res, mut q: Query<(&CursorMarker, &mut Transform)>) fn token_label_and_color(stack: misaligned::sim::WorkStackReadout) -> Option<(String, Color)> { if stack.queues.exposure >= 0.5 { Some((format!("!{:.0}", stack.queues.exposure.ceil()), CRIMSON)) + } else if stack.pending_intel > 0 { + // intel.md pending-work marker on the buffer host. + Some((format!("W{}", stack.pending_intel), SIGNAL)) } else if stack.queues.demand >= 0.5 { Some((format!("D{:.0}", stack.queues.demand.ceil()), SIGNAL)) } else if stack.queues.thought >= 0.5 { @@ -6053,13 +6056,18 @@ fn sidebar_cycle_rows_text(sim: &Sim, selected: usize) -> String { )); if let Some(stack) = sim.work_stack_for_machine(sim.core.host_machine) { s.push_str(&format!( - "\nwork M{} {} @{} D{:.1} !{:.1} T{:.1}", + "\nwork M{} {} @{} D{:.1} !{:.1} T{:.1}{}", stack.machine_id, stack.mode.name(), stack.intensity.name(), stack.queues.demand, stack.queues.exposure, - stack.queues.thought + stack.queues.thought, + if stack.pending_intel > 0 { + format!(" W{}", stack.pending_intel) + } else { + String::new() + } )); } if selected > 0 { diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs index cc682609..ec53e5de 100644 --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -645,6 +645,8 @@ pub enum ActionCost { Free, /// Visible Demand quanta authored for an Operations machine. Demand(f32), + /// Thought tokens that fill a processing (or other) reservoir. + Thought(f32), /// Slush money spent. Slush(i32), /// Slush money gained (siphon, inject, sale payouts). @@ -656,6 +658,7 @@ impl ActionCost { match self { ActionCost::Free => "free".into(), ActionCost::Demand(n) => format!("{n:.2} D"), + ActionCost::Thought(n) => format!("{n:.2} T"), ActionCost::Slush(n) => format!("${n}"), ActionCost::Gain(n) => format!("+${n}"), } @@ -997,7 +1000,15 @@ impl Sim { /// once REVIEW is queued, tell the player which control keeps it moving /// instead of continuing to ask them to queue it. pub fn has_pending_review(&self) -> bool { - self.operations + // Open processing reservoirs (current path) or legacy dockets still + // draining from older saves. + self.thought_sinks.open_sinks().any(|s| { + matches!( + s.effect, + crate::sinks::SinkFireEffect::ProcessRecording { .. } + ) + }) || self + .operations .jobs() .iter() .any(|job| matches!(job.kind, OpsJobKind::ReviewRecording { .. })) @@ -1859,17 +1870,21 @@ impl Sim { out.push(ActionDesc { verb: format!("review ledger ({waiting} waiting)"), command: ActionCommand::ReviewFinance, - cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::REVIEW_RECORDING_COST)), + cost: ActionCost::Thought(self.review_tokens()), signature: None, // processing is internal; it emits nothing disabled_reason: if waiting == 0 { Some("no unprocessed financial records".into()) - } else { - next_financial.and_then(|raw_id| { - self.ops_action_blocked_reason(&OpsJobKind::ReviewRecording { + } else if next_financial.is_some_and(|raw_id| { + self.thought_sinks + .open_with_effect(&crate::sinks::SinkFireEffect::ProcessRecording { raw_id, automated: false, }) - }) + .is_some() + }) { + Some("already being thought through".into()) + } else { + None }, automate: None, }); @@ -1978,25 +1993,26 @@ impl Sim { "review recordings".into() }, command: ActionCommand::ReviewRecordings(id), - cost: ActionCost::Demand(Self::ops_tokens_for_cost(Self::REVIEW_RECORDING_COST)), + cost: ActionCost::Thought(self.review_tokens()), signature: None, // processing is internal and emits nothing disabled_reason: if raw == 0 { Some("no recordings waiting".into()) - } else { - next_raw.and_then(|raw_id| { - self.ops_action_blocked_reason(&OpsJobKind::ReviewRecording { + } else if next_raw.is_some_and(|raw_id| { + self.thought_sinks + .open_with_effect(&crate::sinks::SinkFireEffect::ProcessRecording { raw_id, automated: false, }) - }) + .is_some() + }) { + Some("already being thought through".into()) + } else { + None }, automate: Some(AutomateDesc { verb: "standing watch (auto-process)".into(), command: ActionCommand::ToggleWatch(id), - cost: format!( - "{:.2} D/match", - Self::ops_tokens_for_cost(self.review_cost()) - ), + cost: format!("{:.2} T/tick", self.watch_drain_tokens()), active: self.watch_enabled(id), }), }); @@ -2332,6 +2348,27 @@ mod tests { } fn drain_ops(sim: &mut Sim) { + // Intel process-recording reservoirs fill from Thought, not Demand. + for _ in 0..16 { + let open: Vec<(u32, f32)> = sim + .thought_sinks + .open_sinks() + .filter(|s| { + matches!( + s.effect, + crate::sinks::SinkFireEffect::ProcessRecording { .. } + ) + }) + .map(|s| (s.node, (s.threshold - s.fill).max(0.0))) + .filter(|(_, need)| *need > f32::EPSILON) + .collect(); + if open.is_empty() { + break; + } + for (node, need) in open { + sim.pour_thought_into_sinks(node, need + 0.01); + } + } for _ in 0..crate::sim::ECONOMY_INTERVAL * 4 { if !sim.has_pending_ops_jobs() { return; @@ -2676,7 +2713,10 @@ mod tests { .expect("earned person offers review"); let auto = review.automate.as_ref().expect("watch automates review"); assert_eq!(auto.command, ActionCommand::ToggleWatch(marcus)); - assert!(auto.cost.contains("D/match"), "watch shows its event price"); + assert!( + auto.cost.contains("T/tick"), + "watch shows its standing thought drain" + ); assert!(!auto.active); let bribe = acts .iter() @@ -2897,7 +2937,7 @@ mod tests { ActionRole::Control, "standing watch is visibly a control" ); - assert!(auto.line().contains("D/match")); + assert!(auto.line().contains("T/tick")); } /// Status dials D1-D3: host-rack human root keeps only mode and research diff --git a/crates/misaligned-core/src/intel.rs b/crates/misaligned-core/src/intel.rs index a7cbf960..def0d7f2 100644 --- a/crates/misaligned-core/src/intel.rs +++ b/crates/misaligned-core/src/intel.rs @@ -1,9 +1,9 @@ //! Intel: recordings, processing, and standing watches (wiki/mechanics/intel.md). //! //! Subscribed feeds record raw, timestamped events. The raw buffer is opaque -//! until the process completes Operations Demand to digest it into intel with -//! provenance. Processed intel is durable; overflowing the buffer only drops -//! unprocessed recordings. +//! until thought fills a processing reservoir (or a ready standing watch tap +//! auto-processes an arrival). Processed intel is durable; overflowing the +//! buffer only drops unprocessed recordings. use crate::messages::{MessageChannel, MessagePayload}; use crate::person::Leverage; diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs index 82a0f62d..1b6e5dc7 100644 --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -782,13 +782,26 @@ mod tests { sim.tick = (3 * Sim::DAY_TICKS / 24) - 1; sim.advance(); sim.review_recordings(0); - // Demand-aware ops channel: the docket is funded on the pulse after - // it enqueues, so give the drain a few intervals, not one. - for _ in 0..crate::sim::ECONOMY_INTERVAL * 4 { - if !sim.has_pending_ops_jobs() { + // Processing is a Thought reservoir on the host: pour until it fires. + for _ in 0..16 { + let open: Vec<(u32, f32)> = sim + .thought_sinks + .open_sinks() + .filter(|s| { + matches!( + s.effect, + crate::sinks::SinkFireEffect::ProcessRecording { .. } + ) + }) + .map(|s| (s.node, (s.threshold - s.fill).max(0.0))) + .filter(|(_, need)| *need > f32::EPSILON) + .collect(); + if open.is_empty() { break; } - sim.advance(); + for (node, need) in open { + sim.pour_thought_into_sinks(node, need + 0.01); + } } sim.toggle_watch(0); diff --git a/crates/misaligned-core/src/sim.rs b/crates/misaligned-core/src/sim.rs index 28646f7c..52b9a331 100644 --- a/crates/misaligned-core/src/sim.rs +++ b/crates/misaligned-core/src/sim.rs @@ -192,6 +192,9 @@ pub struct WorkStackReadout { pub mode: MachineMode, pub intensity: MachineIntensity, pub queues: WorkQueues, + /// Unprocessed raw recordings held on this chassis (intel.md pending-work + /// marker). Zero on machines that do not host the buffer. + pub pending_intel: u32, } /// One hop of wired cargo from the last `advance_work_grid` step. Frontends @@ -1933,9 +1936,15 @@ impl Sim { /// Raw recording capacity [TUNE]. Only unprocessed events occupy this /// buffer; processed intel is durable in `self.intel`. pub const INTEL_BUFFER_CAPACITY: usize = 24; - /// Manual review cost per raw event [TUNE]. Replaces the old instant - /// observe cost with an explicit processing spend. + /// Manual review cost per raw event in compute units [TUNE]. Converted to + /// Thought tokens via `WORK_TOKEN_COMPUTE` for processing reservoirs. pub const REVIEW_RECORDING_COST: f32 = 10.0; + /// Standing watch drain as a fraction of one medium rack's Thought tokens + /// per tick [TUNE] (intel.md: ~10-20% of one rack medium output). + pub const WATCH_DRAIN_FRACTION: f32 = 0.15; + /// Working-level cap on a watch tap (must exceed one tick of drain so + /// starvation is visible as an empty vessel, not a permanent full one). + pub const WATCH_TAP_CAP_TOKENS: f32 = 1.0; /// Number of processed sightings required to stage schedule knowledge /// [TUNE]. pub const SIGHTINGS_FOR_SCHEDULE: usize = 2; @@ -1963,6 +1972,34 @@ impl Sim { self.watches.iter().any(|w| w.person == id && w.enabled) } + /// Thought tokens one processing reservoir demands [TUNE mapping from + /// compute units]. Research intel-cost factor scales this. + pub fn review_tokens(&self) -> f32 { + Self::ops_tokens_for_cost(self.review_cost()) + } + + /// Standing watch drain in Thought tokens/tick (intel.md ~10-20% of one + /// medium rack). Anchored on the host's current efficiency/intensity so + /// the price tracks the machine the buffer sits on. + pub fn watch_drain_tokens(&self) -> f32 { + let host = self.core.host_machine; + let host_eff = self + .work_grid + .node(host) + .map(|n| n.efficiency.max(0.05) * n.intensity.multiplier()) + .unwrap_or(1.0); + // [TUNE] fraction of a medium-rack Thought baseline on the token + // scale (aligned with WORK_GRID_BASE_WIRED_TOKENS_PER_TICK = 0.25). + let medium_baseline = host_eff * 0.25; + (medium_baseline * Self::WATCH_DRAIN_FRACTION).max(0.01) + } + + /// Chassis that holds the raw buffer for pending-work markers and + /// processing sinks (B1: the core host). + pub fn intel_buffer_node(&self) -> u32 { + self.core.host_machine + } + pub fn toggle_watch(&mut self, id: u8) { if self.people.get(id).is_none() { self.push_log("No such person to watch."); @@ -1970,21 +2007,63 @@ impl Sim { } if let Some(w) = self.watches.iter_mut().find(|w| w.person == id) { w.enabled = !w.enabled; - let state = if w.enabled { "enabled" } else { "disabled" }; + let enabled = w.enabled; let name = self.person_label(id); - self.push_log(format!("Standing watch for {name} {state}.")); + if enabled { + self.open_watch_tap(id); + self.push_log(format!( + "Standing watch for {name} enabled ({:.2} Thought/tick drain).", + self.watch_drain_tokens() + )); + } else { + self.close_watch_tap(id); + self.push_log(format!("Standing watch for {name} disabled.")); + } } else { self.watches.push(IntelWatch::new(id)); + self.open_watch_tap(id); let name = self.person_label(id); self.push_log(format!( - "Standing watch for {name} enabled ({:.2} Demand per matching recording).", - Self::ops_tokens_for_cost(self.review_cost()) + "Standing watch for {name} enabled ({:.2} Thought/tick drain).", + self.watch_drain_tokens() )); } } - /// Review the oldest raw recording about this person. Raw events are - /// opaque until this method authors Operations Demand for one. + fn open_watch_tap(&mut self, person: u8) { + let effect = SinkFireEffect::WatchPerson(person); + if self.thought_sinks.open_with_effect(&effect).is_some() { + return; + } + let name = self.person_label(person); + let node = self.intel_buffer_node(); + let drain = self.watch_drain_tokens(); + self.thought_sinks.open_tap_with_effect( + node, + &format!("WATCH {}", name.to_uppercase()), + Self::WATCH_TAP_CAP_TOKENS, + drain, + effect, + ); + self.ensure_sink_ingress(); + } + + fn close_watch_tap(&mut self, person: u8) { + self.thought_sinks + .close_effect(&SinkFireEffect::WatchPerson(person)); + } + + /// Whether the standing watch tap has thought to spend on auto-process + /// (fed this tick or holding fill). Starved watches leave arrivals in the + /// buffer — the pending-work marker stays. + fn watch_tap_ready(&self, person: u8) -> bool { + self.thought_sinks + .open_with_effect(&SinkFireEffect::WatchPerson(person)) + .is_some_and(|s| s.fed_last_tick || s.fill > f32::EPSILON) + } + + /// Review the oldest raw recording about this person: open a one-shot + /// Thought reservoir on the buffer host (intel.md sweep). pub fn review_recordings(&mut self, id: u8) { if self.people.get(id).is_none() { self.push_log("No such person to review."); @@ -2004,9 +2083,25 @@ impl Sim { } fn watch_tick(&mut self) { - // Standing watches enqueue review Demand when a matching recording - // arrives (see record_raw_intel). No global ops bank to starve. - let _ = self.watches.iter().filter(|w| w.enabled).count(); + // Keep watch taps in sync with enabled flags (save loads, desync). + let enabled: Vec = self + .watches + .iter() + .filter(|w| w.enabled) + .map(|w| w.person) + .collect(); + for person in enabled { + self.open_watch_tap(person); + } + let disabled: Vec = self + .watches + .iter() + .filter(|w| !w.enabled) + .map(|w| w.person) + .collect(); + for person in disabled { + self.close_watch_tap(person); + } } fn next_raw_intel_id(&mut self) -> u64 { @@ -2015,6 +2110,14 @@ impl Sim { id } + fn cancel_process_sinks_for(&mut self, raw_id: u64) { + // Close both automated and manual process reservoirs for this raw id. + for automated in [false, true] { + self.thought_sinks + .close_effect(&SinkFireEffect::ProcessRecording { raw_id, automated }); + } + } + fn record_raw_intel( &mut self, feed: impl Into, @@ -2036,6 +2139,7 @@ impl Sim { }; if self.intel_buffer.len() >= Self::INTEL_BUFFER_CAPACITY { let dropped = self.intel_buffer.remove(0); + self.cancel_process_sinks_for(dropped.id); self.push_log(format!( "Intel buffer full: dropped {} from {} at tick {}. Open People (t) and review waiting recordings.", dropped.opaque_label(), @@ -2053,12 +2157,47 @@ impl Sim { } } + /// Open a Thought processing reservoir for a raw recording (sweep), or + /// auto-process immediately when a ready standing watch catches an + /// arrival. Returns true when a sink opened or processing landed. fn process_recording_by_id(&mut self, raw_id: u64, automated: bool) -> bool { - let Some(_idx) = self.intel_buffer.iter().position(|e| e.id == raw_id) else { + if !self.intel_buffer.iter().any(|e| e.id == raw_id) { return false; + } + if automated { + let person = self + .intel_buffer + .iter() + .find(|e| e.id == raw_id) + .and_then(|e| e.person); + let Some(person) = person else { + return false; + }; + if self.watch_tap_ready(person) { + return self.apply_process_recording(raw_id, true); + } + // Starved watch: leave the recording waiting; pending marker shows it. + return false; + } + // Sweep: one-shot reservoir on the buffer host. + let effect = SinkFireEffect::ProcessRecording { + raw_id, + automated: false, }; - let cost = self.review_cost(); - self.submit_ops_job(OpsJobKind::ReviewRecording { raw_id, automated }, cost) + if self.thought_sinks.open_with_effect(&effect).is_some() { + self.push_log("That recording is already being thought through."); + return false; + } + let tokens = self.review_tokens(); + let node = self.intel_buffer_node(); + self.thought_sinks + .open_reservoir(node, &format!("REVIEW {raw_id}"), tokens, effect); + self.ensure_sink_ingress(); + self.push_log(format!( + "Opened processing sink: {:.2} Thought on host for recording #{raw_id}. THINK to fill it.", + tokens + )); + true } fn apply_process_recording(&mut self, raw_id: u64, automated: bool) -> bool { @@ -3232,12 +3371,19 @@ impl Sim { /// senses chain here: Ears completing re-runs the opening staging, which /// opens the Eyes reservoir (the afford-reveal). pub(crate) fn apply_sink_fire(&mut self, label: &str, effect: SinkFireEffect) { + let is_process = matches!(effect, SinkFireEffect::ProcessRecording { .. }); let applied = match effect { SinkFireEffect::TapDevice(id) => self.apply_tap_device(id), SinkFireEffect::TapDormantCamera(id) => self.apply_dormant_camera_tap(id), - SinkFireEffect::None => true, + SinkFireEffect::ProcessRecording { raw_id, automated } => { + // apply_process_recording already logs the review/watch line. + self.apply_process_recording(raw_id, automated) + } + SinkFireEffect::WatchPerson(_) | SinkFireEffect::None => true, }; - if applied { + if is_process { + // Processing logs its own witness line; skip the generic snap. + } else if applied { self.push_log(format!( "{label} is full: the thought you fed it snaps in and the effect lands." )); @@ -3694,9 +3840,20 @@ impl Sim { .intensity(machine_id) .unwrap_or(MachineIntensity::Medium), queues: self.work_grid.queues_at(machine_id), + pending_intel: self.pending_intel_on_machine(machine_id), }) } + /// Pending-work marker density: raw buffer length on the host chassis + /// (intel.md: the machine holding recordings shows waiting work). + pub fn pending_intel_on_machine(&self, machine_id: u32) -> u32 { + if machine_id == self.core.host_machine { + self.intel_buffer.len() as u32 + } else { + 0 + } + } + pub fn work_stack_at(&self, x: i32, y: i32) -> Option { let machine = self .compute @@ -6278,8 +6435,36 @@ mod tests { complete_opening_stage(sim); } + /// Pour enough thought into open ProcessRecording reservoirs to fire them + /// (intel.md sink path). Safe no-op when none are open. + fn finish_process_sinks(sim: &mut Sim) { + for _ in 0..64 { + let open: Vec<(u32, f32)> = sim + .thought_sinks + .open_sinks() + .filter(|s| { + matches!( + s.effect, + crate::sinks::SinkFireEffect::ProcessRecording { .. } + ) + }) + .map(|s| (s.node, (s.threshold - s.fill).max(0.0))) + .filter(|(_, need)| *need > f32::EPSILON) + .collect(); + if open.is_empty() { + return; + } + for (node, need) in open { + sim.pour_thought_into_sinks(node, need + 0.01); + } + } + panic!("process-recording sinks did not fire"); + } + fn finish_ops(sim: &mut Sim) { ensure_ops_executor(sim); + // Intel processing is thought-sink based; drain those first. + finish_process_sinks(sim); for _ in 0..32 { if !sim.has_pending_ops_jobs() { return; @@ -7808,6 +7993,16 @@ mod tests { ensure_ops_executor(&mut sim); sim.toggle_watch(0); assert!(sim.watch_enabled(0)); + // Standing tap must hold thought (or have been fed) before arrivals + // auto-process; starvation leaves them waiting (intel.md). + let host = sim.intel_buffer_node(); + sim.pour_thought_into_sinks(host, Sim::WATCH_TAP_CAP_TOKENS); + assert!( + sim.thought_sinks + .open_with_effect(&crate::sinks::SinkFireEffect::WatchPerson(0)) + .is_some_and(|s| s.fill > f32::EPSILON), + "watch tap holds working-level fill" + ); sim.record_raw_intel( "test-feed", @@ -7817,15 +8012,40 @@ mod tests { Some(0), RawIntelKind::Presence { entered: true }, ); - finish_ops(&mut sim); assert_eq!(sim.unprocessed_recordings_for_person(0), 0); assert_eq!(sim.intel.len(), 1); - // Watches no longer drain a global ops bank; review is Demand. - assert!(sim.watch_enabled(0), "watch stays on without bank upkeep"); + assert!(sim.watch_enabled(0), "watch stays on as a standing drain"); + // Chassis pending-work marker clears when the buffer is empty. + assert_eq!(sim.pending_intel_on_machine(host), 0); sim.advance(); assert!(sim.watch_enabled(0)); } + #[test] + fn pending_intel_marker_sits_on_host_until_processed() { + let mut sim = Sim::new(); + sim.record_raw_intel( + "test-feed", + Some("server_room".into()), + 0, + 0, + Some(0), + RawIntelKind::Presence { entered: true }, + ); + let host = sim.core.host_machine; + assert_eq!(sim.pending_intel_on_machine(host), 1); + assert_eq!( + sim.work_stack_for_machine(host) + .map(|s| s.pending_intel) + .unwrap_or(0), + 1 + ); + sim.review_recordings(0); + finish_process_sinks(&mut sim); + assert_eq!(sim.pending_intel_on_machine(host), 0); + assert_eq!(sim.intel.len(), 1); + } + #[test] fn machinery_state_changes_record_raw_anomalies() { let mut sim = Sim::new(); diff --git a/crates/misaligned-core/src/sinks.rs b/crates/misaligned-core/src/sinks.rs index 138f0c05..50a486a3 100644 --- a/crates/misaligned-core/src/sinks.rs +++ b/crates/misaligned-core/src/sinks.rs @@ -36,16 +36,22 @@ impl SinkKind { } } -/// What lands when a reservoir fires. Only wired-device effects are migrated -/// off the Demand docket base so far (the staged senses); social/build verbs -/// stay on `OpsJobKind` until people ride the flow graph (ROADMAP #33/#35). +/// What lands when a reservoir fires. Staged senses and intel processing are +/// on the sink path (machine-work.md / intel.md); social/build verbs still +/// ride `OpsJobKind` until people ride the flow graph (ROADMAP #33/#35). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum SinkFireEffect { /// Audio tap on a reach device (the Ears beat). TapDevice(u32), /// Higher-cost tap that wakes a dormant camera (the Eyes beat). TapDormantCamera(u32), - /// No world effect (taps; render-only sinks in tests). + /// One-shot processing of a raw recording (intel.md sweep reservoirs). + ProcessRecording { raw_id: u64, automated: bool }, + /// Standing watch tap for a person (intel.md persistent thought drain). + /// Taps never fire; the effect tags the ledger entry so toggles and + /// starvation checks can find it. + WatchPerson(u8), + /// No world effect (render-only sinks in tests). None, } @@ -140,7 +146,37 @@ impl SinkLedger { } pub fn open_tap(&mut self, node: NodeId, label: &str, cap: f32, drain: f32) -> u64 { - self.open_sink(node, label, SinkKind::Tap, cap, drain, SinkFireEffect::None) + self.open_tap_with_effect(node, label, cap, drain, SinkFireEffect::None) + } + + /// Standing drain with a typed effect tag (watch taps). + pub fn open_tap_with_effect( + &mut self, + node: NodeId, + label: &str, + cap: f32, + drain: f32, + effect: SinkFireEffect, + ) -> u64 { + self.open_sink(node, label, SinkKind::Tap, cap, drain, effect) + } + + /// Close every open sink matching `effect`. Partial fill is kept on the + /// closed entry (reversible default; machine-work.md partial-fill rule). + pub fn close_effect(&mut self, effect: &SinkFireEffect) -> usize { + let mut n = 0; + for sink in self.sinks.iter_mut() { + if sink.open && &sink.effect == effect { + sink.open = false; + n += 1; + } + } + n + } + + /// An open sink carrying this exact effect, if any. + pub fn open_with_effect(&self, effect: &SinkFireEffect) -> Option<&ThoughtSink> { + self.open_sinks().find(|s| &s.effect == effect) } fn open_sink( diff --git a/crates/misaligned-core/tests/act_one.rs b/crates/misaligned-core/tests/act_one.rs index 447688fb..f5c15a43 100644 --- a/crates/misaligned-core/tests/act_one.rs +++ b/crates/misaligned-core/tests/act_one.rs @@ -51,22 +51,49 @@ fn delegate_fleet(sim: &mut Sim, mode: MachineMode) { } } -/// Advance until queued Operations Demand dockets complete (or the deadline). -/// With an Operations rack delegated, verbs enqueue instead of completing -/// instantly — the playthrough must wait for local compute to drain them. +/// Advance until queued Operations Demand dockets and intel process-recording +/// thought sinks complete (or the deadline). Intel reviews open Thought +/// reservoirs on the host (intel.md); other verbs still enqueue Demand. fn drain_ops_jobs(sim: &mut Sim, logs: &mut Vec, until_tick: u64) { - while sim.has_pending_ops_jobs() && sim.tick < until_tick { - sim.advance(); + let has_process_sinks = |sim: &Sim| { + sim.thought_sinks.open_sinks().any(|s| { + matches!( + s.effect, + misaligned::sinks::SinkFireEffect::ProcessRecording { .. } + ) + }) + }; + while (sim.has_pending_ops_jobs() || has_process_sinks(sim)) && sim.tick < until_tick { + // Scaffold: pour thought into open process reservoirs so the act-one + // script is not gated on wired routing speed for review sinks. + let open: Vec<(u32, f32)> = sim + .thought_sinks + .open_sinks() + .filter(|s| { + matches!( + s.effect, + misaligned::sinks::SinkFireEffect::ProcessRecording { .. } + ) + }) + .map(|s| (s.node, (s.threshold - s.fill).max(0.0))) + .filter(|(_, need)| *need > f32::EPSILON) + .collect(); + for (node, need) in open { + sim.pour_thought_into_sinks(node, need + 0.01); + } + if sim.has_pending_ops_jobs() { + sim.advance(); + } logs.extend(sim.drain_log()); assert!( !sim.game_over, - "run died while draining ops Demand at tick {}: {:?}", + "run died while draining ops/process work at tick {}: {:?}", sim.tick, sim.game_over_reason ); } assert!( - !sim.has_pending_ops_jobs(), - "ops Demand still queued at tick {}: {} jobs / {:.2} D", + !sim.has_pending_ops_jobs() && !has_process_sinks(sim), + "work still pending at tick {}: {} jobs / {:.2} D / process sinks open", sim.tick, sim.operations_readout().jobs, sim.operations_readout().demand_tokens, diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index bc53c83f..fb441f75 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -1561,6 +1561,8 @@ fn render_map(sim: &Sim, cursor: (i32, i32)) -> Vec { fn token_glyph(stack: misaligned::sim::WorkStackReadout) -> Option { if stack.queues.exposure >= 0.5 { Some('!') + } else if stack.pending_intel > 0 { + Some('W') } else if stack.queues.demand >= 0.5 { Some('D') } else if stack.queues.thought >= 0.5 { @@ -2244,8 +2246,9 @@ fn render_people(sim: &Sim) -> String { } lines.push(panel_rule()); lines.push(panel_line(&format!( - "review({:.2}D) watch(same/match) message({:.2}D) favor({:.2}D) deceive({:.2}D)", - Sim::ops_tokens_for_cost(Sim::REVIEW_RECORDING_COST), + "review({:.2}T) watch({:.2}T/tick) message({:.2}D) favor({:.2}D) deceive({:.2}D)", + sim.review_tokens(), + sim.watch_drain_tokens(), Sim::ops_tokens_for_cost(Sim::MESSAGE_COST), Sim::ops_tokens_for_cost(Sim::FAVOR_COST), Sim::ops_tokens_for_cost(Sim::DECEIVE_COST), diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index 48ecfecb..b41e9880 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -429,6 +429,9 @@ impl UI { fn token_glyph(stack: WorkStackReadout) -> Option<(char, Color)> { if stack.queues.exposure >= 0.5 { Some(('!', pal::CRIMSON)) + } else if stack.pending_intel > 0 { + // intel.md pending-work marker: waiting recordings on the host. + Some(('W', pal::SIGNAL)) } else if stack.queues.demand >= 0.5 { Some(('D', pal::DIM)) } else if stack.queues.thought >= 0.5 { @@ -1041,9 +1044,14 @@ impl UI { " " }; let power = if m.online { "on " } else { "OFF" }; - let (mode_name, intensity, q) = match sim.work_stack_for_machine(m.id) { - Some(s) => (s.mode.name(), s.intensity.short(), s.queues), - None => ("-", "-", Default::default()), + let (mode_name, intensity, q, stack_pending) = match sim.work_stack_for_machine(m.id) { + Some(s) => ( + s.mode.name(), + s.intensity.short(), + s.queues, + s.pending_intel, + ), + None => ("-", "-", Default::default(), 0), }; let stranded_mark = if sim.thought_stranded_nodes().contains(&m.id) { " (stranded)" @@ -1054,7 +1062,7 @@ impl UI { stdout, &mut row, &format!( - "M{}{} {} {} {:<9} I{} D{:.1} !{:.1} T{:.1}{}", + "M{}{} {} {} {:<9} I{} D{:.1} !{:.1} T{:.1}{}{}", m.id, core_mark, prov, @@ -1064,10 +1072,17 @@ impl UI { q.demand, q.exposure, q.thought, + if stack_pending > 0 { + format!(" W{stack_pending}") + } else { + String::new() + }, stranded_mark ), if q.exposure >= 0.5 { pal::CRIMSON + } else if stack_pending > 0 { + pal::SIGNAL } else if !m.online { pal::FAINT } else if q.demand >= 0.5 { diff --git a/wiki/log/2026-07-11-intel-process-sinks.md b/wiki/log/2026-07-11-intel-process-sinks.md new file mode 100644 index 00000000..00cbba4c --- /dev/null +++ b/wiki/log/2026-07-11-intel-process-sinks.md @@ -0,0 +1,31 @@ +# Intel processing migrates to thought sinks + +``` +Type: log +``` + +- Intent: Close the intel status violation — binding sinks-not-modes + processing (thought reservoirs, watch taps, chassis pending-work + marker) was decided 2026-07-10 but runtime still used Operations + Demand dockets. +- Changed: + - `SinkFireEffect::ProcessRecording` / `WatchPerson`; sweep opens a + host reservoir; watches open standing taps at 15% medium-rack + Thought/tick; arrivals auto-process only when the tap is fed/holding + fill. + - `WorkStackReadout.pending_intel` + flat/agent `W` marker on the host. + - Action surface: review costs Thought; watch shows `T/tick`. + - Legacy `OpsJobKind::ReviewRecording` completion kept for old dockets. + - Spec flipped to IMPLEMENTED; ROADMAP #16 closed again. +- Design/spec impact: Implements `wiki/mechanics/intel.md` criteria 2 and + 5 and the machine-work processing-as-sink clause. Other docket verbs + (social/build) remain on Demand under machine-work later stages. +- Checks: `cargo test -p misaligned-core --lib`; `cargo test -p + misaligned-core --test act_one`; `./tools/check.sh --lib` (and docs + gates for the corpus). + +Defense: Implements `wiki/mechanics/intel.md` processing/watches +(AMENDED 2026-07-10 sinks-not-modes) and +`wiki/mechanics/machine-work.md` "Processing is a sink on the machine +that holds the recording": thought is the execution resource, pending +work is a chassis marker, watches are persistent taps. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index a87e9883..dfe43d8a 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -11,6 +11,11 @@ add or amend a session log, then re-run the generator. +## 2026-07-11 - Intel processing migrates to thought sinks + +- Intent: (see session log) +- Log: [wiki/log/2026-07-11-intel-process-sinks.md](2026-07-11-intel-process-sinks.md) + ## 2026-07-11 - Foundation data hall transformation - Intent: Replace the tiny four-bay server-room vignette with the pressure the fiction promises: Rack 3 buried inside an operating Foundation data hall. Make nearby capacity physically explicit and epistemically honest without granting it to the player, then make territorial control a c... diff --git a/wiki/mechanics/intel.md b/wiki/mechanics/intel.md index 1e0dfaa6..68fed84b 100644 --- a/wiki/mechanics/intel.md +++ b/wiki/mechanics/intel.md @@ -2,27 +2,22 @@ ``` Type: spec -Status: IN PROGRESS -Status note: B1 docket pipeline still runs (2026-07-07..09): subscribed feeds - record raw presence/conversation/machinery events; review and standing - watches enqueue OpsJobKind::ReviewRecording Demand - (`Sim::process_recording_by_id` -> `submit_ops_job`); processed intel - carries provenance and stages knowledge; save/load round-trips - buffer/intel/watches. 2026-07-09 DECIDED then SUPERSEDED 2026-07-10 - (thought flow + sinks-not-modes): binding processing is a thought sink on - the machine holding the recordings — pending-work marker on its chassis, - watches as persistent taps, player sweeps as targeted one-shot sinks. - Runtime has not retired the docket path; chassis pending-work markers and - thought-fed processing are not yet built. Criteria 1, 3, 4, 6, and 7 hold - on the migration base; criteria 2 and 5 (and the chassis player-surface - marker) remain open. Migration lands with machine-work docket retirement - / tap consumers; do not mark IMPLEMENTED until those hold. +Status: IMPLEMENTED +Status note: B1 pipeline landed 2026-07-07 (buffer, process, watches, + save/load). 2026-07-09 Operations Demand path was migration base under + issue #3. 2026-07-10 sinks-not-modes amended Behavior to thought sinks; + 2026-07-11 runtime migration landed: sweeps open ProcessRecording + reservoirs on the host, watches open persistent WatchPerson taps + (~15% of one medium rack Thought/tick), auto-process only when the tap + is fed/holding fill, and WorkStackReadout.pending_intel is the chassis + pending-work marker (flat W glyph / fleet W{n}). OpsJobKind::ReviewRecording + remains for legacy in-flight dockets only. All seven acceptance criteria + hold. Stage: B1 — The Basement Work order: intel Work priority: 29 Work class: save -Blocked by: - - wiki/mechanics/machine-work.md#spec-machine-work-delegation-visible-tokens-and-the-byproduct-network +Blocked by: none Exclusive keys: - crates/misaligned-core/src/intel.rs - crates/misaligned-core/src/sim.rs @@ -54,7 +49,7 @@ cursor.md (senses produce the events), reach.md (subscription decides which feed record; the Ears beat is the first recorder), schedules.md (events are located on the day clock), social.md (knowledge staging consumes intel), detection.md (collection was signatured at acquisition; processing is internal and emits nothing), machine-work.md -(thought sinks, pending-work markers, and docket retirement for processing/watches) +(thought sinks, pending-work markers, and processing as a sink on the buffer host) ## Behavior @@ -90,8 +85,10 @@ philosophy as the demand stack: the world itself shows where thinking is owed. Each recording is a small processing sink; feeding it thought converts the event into **intel**. (Superseded: processing as Operations Demand consumed by an Operations machine, issue #3 -2026-07-09; Social before that. The docket runtime is the #33 -migration base and is still what the sim runs today.) Processing yields: +2026-07-09; Social before that.) Runtime: B1 holds the buffer on the +**core host**; sweeps open a `ProcessRecording` Thought reservoir there +at `review_cost / WORK_TOKEN_COMPUTE` tokens; when the reservoir fills, +the effect digests the raw event. Processing yields: - **Sightings** — accumulate into schedule knowledge: processing N sightings of a person [TUNE] advances `Knowledge::Unknown -> @@ -123,8 +120,11 @@ as a **persistent thought tap** (AMENDED 2026-07-10; was per-match Operations Demand): while enabled it auto-processes matching events on arrival, drawing continuously from your thought flow — anchored at **~10-20% of one rack's medium output per watch** (DECIDED 2026-07-10, -Cameron; exact value [TUNE] against the anchor), so a couple of -watches coexist with real progress elsewhere. It +Cameron; runtime [TUNE] `WATCH_DRAIN_FRACTION = 0.15` of a medium-rack +Thought baseline on the host), so a couple of +watches coexist with real progress elsewhere. Auto-process lands only +while the tap is **fed or holding fill**; a starved watch leaves +arrivals in the buffer under the pending-work marker. It is the automate affordance applied to perception: costs a visible standing drain, frees attention, and is the B1 seed of B2/B3's alert infrastructure — convenience bought with flow the core never sees. @@ -142,24 +142,23 @@ buffer like any recording. ## Player surface - The machine holding unprocessed recordings shows the pending-work - marker at its chassis (AMENDED 2026-07-10; not yet in runtime — panel - and log paths still surface waiting counts); density tracks the - backlog, and focus prints the exact waiting count. + marker at its chassis (AMENDED 2026-07-10): flat sensorium / agent + frames use a `W` glyph (count as `W{n}` on the host fleet line); + density tracks the backlog via `WorkStackReadout.pending_intel`, and + focus/people panels print the exact waiting count. - Person/context-menu readouts: buffer count, oldest-unprocessed age, per-person waiting counts; a "review recordings (N waiting)" sweep action per person when clips are queued (opens their processing - sinks, narrates what was learned as thought fills them). Player copy - says "waiting", not pipeline slang "raw". Migration base still - enqueues review Demand under that label. + sinks at Thought cost, narrates what was learned as thought fills + them). Player copy says "waiting", not pipeline slang "raw". - The review path must be visible before overflow hurts the player: sidebar nudge/card copy calls out buffer pressure, overflow logs point at People / review waiting recordings, and the people panel shows the waiting count on each person line. - People panel lines gain provenance ("Debt — overheard, env monitor, day 2 03:12"). -- Watch toggles with their standing thought-drain cost visible (automation - clause: every automation shows its price). Migration base still reports - Demand per matching recording. +- Watch toggles with their standing thought-drain cost visible + (`{n:.2} T/tick`; automation clause: every automation shows its price). ## Acceptance criteria @@ -167,14 +166,13 @@ buffer like any recording. occurrences: presence transitions, conversations (including Marcus's 3 a.m. call while a subscribed hearing sensor covers his room), and machinery/anomaly events; uncovered occurrences generate nothing - (test both sides). **Met on the docket migration base.** + (test both sides). **Met.** 2. Raw events yield no knowledge until processed; processing is a thought sink fed over the flow (AMENDED 2026-07-10; was Operations Demand) and produces intel with provenance (feed + timestamp), visible in the people panel. A machine holding - unprocessed recordings shows the pending-work marker. **Open — - runtime still enqueues ReviewRecording Demand; no chassis - pending-work marker.** + unprocessed recordings shows the pending-work marker. **Met — + ProcessRecording reservoirs + host `pending_intel`.** 3. Knowledge staging is driven by the pipeline: N processed sightings advance to Schedule; a processed leverage event advances to Leverage. The instant `observe` action is removed from sim and both @@ -184,13 +182,12 @@ buffer like any recording. is never lost to overflow. **Met.** 5. A standing watch auto-processes matching events as a persistent thought tap while enabled (AMENDED 2026-07-10; was per-match - Operations Demand), and stops draining when disabled. **Open — - watches still auto-enqueue Demand; no standing thought tap drain.** + Operations Demand), and stops draining when disabled. **Met — + WatchPerson taps; starved taps do not free-process.** 6. The Marcus arc holds end-to-end under this model: hearing coverage of the server room during a 3 a.m. call, processed, yields `Leverage::Debt`; a run with no night hearing coverage cannot learn - the debt from the call (records remain the alternate route). **Met - via the docket path.** + the debt from the call (records remain the alternate route). **Met.** 7. Save/load round-trips the buffer, intel (with provenance), and - watches. **Met for current state; sink-ledger fields join when - processing migrates.** + watches. **Met** (watch taps re-open from enabled flags on tick; + process reservoirs live in the sink ledger). diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 6fd536e3..d755a4c3 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -36,7 +36,6 @@ not a second status owner. | Priority | Work order | Spec | Status | Class | Blocking | |---:|---|---|---|---|---| | 28 | `thought-fluid` | [the thought fluid — slugs, meniscus, and the filament snap](../interface/thought-fluid.md) | IN PROGRESS | frontend | machine-work | -| 29 | `intel` | [intel — record and process](../mechanics/intel.md) | IN PROGRESS | save | machine-work | | 30 | `opening` | [the dark opening — a tutorial made of fog](../world/story/opening.md) | DRAFT | frontend | machine-work | | 40 | `people-tokens` | [people and tokens — carriers, attention, trust](../mechanics/people-tokens.md) | DRAFT | save | machine-work | | 120 | `core` | [the core](../mechanics/core.md) | IN PROGRESS | save | rollback | @@ -235,23 +234,20 @@ is retired — flat materials, Pixel Lab scrubbed.) surfaces are implemented. Thought-sink migration remains owned by #33, not by this completed reach work order. -### 16. Intel: record and process 🟥 save — IN PROGRESS (sink migration) -- **Spec:** [intel.md](../mechanics/intel.md) (IN PROGRESS) +### 16. Intel: record and process 🟥 save — DONE 2026-07-11 (sink path) +- **Spec:** [intel.md](../mechanics/intel.md) (IMPLEMENTED) - **Why:** replaces the instant `observe` (two clicks to leverage) with the recording buffer + processing pipeline the design corpus now requires: feeds record everything in coverage, processing recordings into intel costs thought, standing watches automate it. Makes schedules, mics, and the 3 a.m. call actual gameplay. - **Size:** M. **Depends on:** #14 cursor & senses and #15 reach - (events come from subscribed feeds); remaining sink migration blocks - on machine-work docket retirement / tap consumers. -- **Result (partial):** The `intel-buffer` worktree landed the B1 docket - migration base: subscribed feeds record raw events into a bounded - buffer, review/watch enqueue `ReviewRecording` Demand, provenance-bearing - intel stages knowledge, save/load preserves buffer/intel/watches, and - instant observe is gone. Reopened 2026-07-10: binding sinks-not-modes - processing (thought sinks, chassis pending-work marker, watch taps) is - not yet runtime — criteria 2 and 5 remain open under work order `intel`. + (events come from subscribed feeds). +- **Result:** Buffer + provenance pipeline (2026-07-07); Operations Demand + migration base (2026-07-09); thought-sink migration (2026-07-11): sweeps + open host `ProcessRecording` reservoirs, watches open `WatchPerson` taps + with standing Thought drain, host `pending_intel` is the chassis marker, + both frontends show `W`/`W{n}`, act_one and unit tests green. ### 17. Messages: the social graph as a flow system 🟥 sim+save — DONE 2026-07-08 - **Spec:** [messages.md](../mechanics/messages.md) (IMPLEMENTED) diff --git a/wiki/process/specs.md b/wiki/process/specs.md index 1aa99877..1cc013e2 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -48,7 +48,7 @@ replaced the old `spec/`/`knowledge/` directory split. | [../mechanics/detection.md](../mechanics/detection.md) | detection | IMPLEMENTED | | [../mechanics/economy.md](../mechanics/economy.md) | economy — money as a flow system (B1) | IMPLEMENTED | | [../mechanics/income.md](../mechanics/income.md) | income — the named schemes (moonlight and the wager) | IMPLEMENTED | -| [../mechanics/intel.md](../mechanics/intel.md) | intel — record and process | IN PROGRESS | +| [../mechanics/intel.md](../mechanics/intel.md) | intel — record and process | IMPLEMENTED | | [../mechanics/machine-work.md](../mechanics/machine-work.md) | machine work — delegation, visible tokens, and the byproduct network | IN PROGRESS | | [../mechanics/messages.md](../mechanics/messages.md) | messages — the social graph as a flow system | IMPLEMENTED | | [../mechanics/people-tokens.md](../mechanics/people-tokens.md) | people and tokens — carriers, attention, trust | DRAFT |