diff --git a/src/save.rs b/src/save.rs index 1c29a231..a069b890 100644 --- a/src/save.rs +++ b/src/save.rs @@ -54,7 +54,7 @@ const SAVE_FILE: &str = "misaligned_save.txt"; /// Operations. The v12 spellings remain accepted as serde aliases. /// v14 deletes the staging operations_bandwidth bank: player ops are Demand /// dockets consumed by Operations machines (machine-work.md / issue #3). -pub const SAVE_VERSION: u32 = 14; +pub const SAVE_VERSION: u32 = 15; fn save_dir() -> PathBuf { let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); @@ -131,6 +131,11 @@ pub struct SaveState { /// Machine-work token queues and one-mode assignments (machine-work.md). #[serde(default)] pub work_grid: WorkGrid, + /// Bone knowledge (compute units) delivered to the core sink but not yet + /// consumed by an economy pulse (machine-work.md: the core is the bone + /// sink; research feeds on arrival). + #[serde(default)] + pub banked_core_knowledge: f32, /// The run objective: choice, progress, victory latch /// (wiki/mechanics/objective.md criterion 1). #[serde(default)] @@ -194,6 +199,7 @@ impl SaveState { remembered: sim.remembered.values().copied().collect(), research: sim.research.clone(), work_grid: sim.work_grid.clone(), + banked_core_knowledge: sim.banked_core_knowledge, objective: sim.objective.clone(), income: sim.income.clone(), intents: sim.intents.clone(), @@ -238,6 +244,7 @@ impl SaveState { sim.remembered = self.remembered.iter().map(|m| ((m.x, m.y), *m)).collect(); sim.research = self.research.clone(); sim.work_grid = self.work_grid.clone(); + sim.banked_core_knowledge = self.banked_core_knowledge; sim.objective = self.objective.clone(); sim.income = self.income.clone(); sim.intents = self.intents.clone(); @@ -307,7 +314,10 @@ fn migrate_save_state(mut state: SaveState) -> Result { // v12 used the serde spellings `Social` and `social_bandwidth`; the // aliases on MachineMode translate Social on decode. v13's // operations_bandwidth field is dropped on load (Demand path). - 1..=13 => { + // Pre-v15 saves lack the banked core-knowledge pool; the zero + // default is correct — bone still queued on machines re-earns on + // arrival, and nothing already counted is lost. + 1..=14 => { if state.version <= 5 { state.accounts = AccountGraph::act_one(crate::sim::Sim::DAY_TICKS); state.accounts.set_slush_balance(state.money); @@ -562,6 +572,31 @@ mod tests { ); } + #[test] + fn banked_core_knowledge_round_trips_and_pre_v15_saves_default_to_zero() { + // machine-work.md: research feeds on bone arrival at the core; the + // pool banked between pulses is sim truth and survives a load. + let mut sim = Sim::with_seed(23); + sim.banked_core_knowledge = 42.5; + let state = SaveState::from_sim(&sim); + let json = serde_json::to_string(&state).unwrap(); + let loaded: SaveState = serde_json::from_str(&json).unwrap(); + let mut restored = Sim::with_seed(0); + loaded.apply_to(&mut restored); + assert_eq!(restored.banked_core_knowledge, 42.5); + + // Pre-v15 saves lack the pool; zero is the correct migration — bone + // still queued on machines re-earns on arrival, nothing counted twice. + let mut value = serde_json::to_value(&state).unwrap(); + let obj = value.as_object_mut().unwrap(); + obj.insert("version".into(), serde_json::json!(14)); + obj.remove("banked_core_knowledge"); + let parsed: SaveState = serde_json::from_value(value).unwrap(); + let migrated = migrate_save_state(parsed).unwrap(); + assert_eq!(migrated.version, SAVE_VERSION); + assert_eq!(migrated.banked_core_knowledge, 0.0); + } + #[test] fn v12_social_mode_migrates_to_operations() { let mut sim = Sim::with_seed(23); diff --git a/src/sim.rs b/src/sim.rs index 0ffe15bf..0e26afe8 100644 --- a/src/sim.rs +++ b/src/sim.rs @@ -305,6 +305,16 @@ pub struct Sim { /// Wired hops from the last `advance_work_grid` call — render contract for /// in-flight blips. Transient (not saved); cleared when nothing moved. pub(crate) last_wired_moves: Vec, + /// Bone knowledge (in compute units) that physically reached the core + /// sink since the last economy pulse. Research progress feeds on this, + /// not on the allocation split — the core is the bone sink + /// (machine-work.md). Saved: bone banked between pulses survives a load. + pub(crate) banked_core_knowledge: f32, + /// Whether the last knowledge routing step left piles with no route to + /// the core. Transient; the research-starvation witness line keys off it. + last_knowledge_stranded: bool, + /// Latch so the starvation line fires on the transition, not every pulse. + research_starved: bool, /// Per-tick day-job compute delivered by the last economy split. last_day_job_rate: f32, /// Per-tick research compute delivered by the last economy split — @@ -444,6 +454,9 @@ impl Sim { traffic_fired: HashMap::new(), last_machine_online: HashMap::new(), last_wired_moves: Vec::new(), + banked_core_knowledge: 0.0, + last_knowledge_stranded: false, + research_starved: false, last_day_job_rate: 0.0, last_research_rate: 0.0, last_schemes_rate: 0.0, @@ -618,6 +631,9 @@ impl Sim { /// are detection aids, not save state: persistent truth lives in the /// people/reach/machine/intel fields. pub fn rebuild_transient_state(&mut self) { + self.last_wired_moves.clear(); + self.last_knowledge_stranded = false; + self.research_starved = false; self.last_machine_online = self .compute .machines @@ -2330,8 +2346,20 @@ impl Sim { self.detection .scrub(split.concealment * self.research.scrub_multiplier()); - // Research progress: deterministic compute accrual, no RNG. - for done in self.research.economy_tick(split.research) { + // Research progress: deterministic compute accrual, no RNG — fed by + // bone that reached the core since the last pulse, not by the + // allocation split. Allocation mints knowledge tokens on the + // producing machines; arrival at the sink is what counts + // (machine-work.md: the core is the bone sink). + let arrived_bone = std::mem::take(&mut self.banked_core_knowledge); + let starved = arrived_bone <= f32::EPSILON + && split.research > f32::EPSILON + && self.last_knowledge_stranded; + if starved && !self.research_starved { + self.push_log("Research starves: bone knowledge is stranded off the core's graph."); + } + self.research_starved = starved; + for done in self.research.economy_tick(arrived_bone) { if done.track == Track::Efficiency { // The compute.md hook: the global multiplier compounds. self.compute.efficiency *= EFFICIENCY_MULT_PER_LEVEL; @@ -2661,10 +2689,15 @@ impl Sim { /// hop between Voss's desktop and day-job sinks. pub const SWITCH_WORK_NODE: u32 = 900_002; - fn work_efficiency_for(machine: &crate::machine::Machine) -> f32 { + fn work_efficiency_for(&self, machine: &crate::machine::Machine) -> f32 { // Rack 3 (100 capacity, reliable) is the unit baseline. Smaller or - // unreliable boxes consume visibly slower without inventing a new stat. - (machine.effective() / 100.0).max(0.05) + // unreliable boxes consume visibly slower without inventing a new + // stat. The global efficiency multiplier applies here exactly as it + // does in fleet_channel_yield: a researched-up fleet mints more + // knowledge per pulse, so its boxes must also move/consume tokens + // proportionally faster or the wire silently caps research + // (efficiency folds into research — machine-work.md). + (machine.effective() * self.compute.efficiency / 100.0).max(0.05) } fn add_machine_to_work_grid(&mut self, machine_id: u32, mode: MachineMode) { @@ -2674,7 +2707,7 @@ impl Sim { machine.x, machine.y, mode, - Self::work_efficiency_for(machine), + self.work_efficiency_for(machine), ); if machine.id != self.core.host_machine { let _ = self.work_grid.link(machine.id, self.core.host_machine); @@ -2691,7 +2724,7 @@ impl Sim { .compute .machines .iter() - .map(|m| (m.id, m.x, m.y, Self::work_efficiency_for(m))) + .map(|m| (m.id, m.x, m.y, self.work_efficiency_for(m))) .collect(); for (id, x, y, efficiency) in machines { if self.work_grid.node(id).is_none() { @@ -2706,6 +2739,11 @@ impl Sim { }, efficiency, ); + } else { + // Researched efficiency must reach routing/consumption too; + // a stale node multiplier silently caps a researched-up + // fleet's knowledge flow at its day-one wire speed. + let _ = self.work_grid.set_efficiency(id, efficiency); } if id != self.core.host_machine { let _ = self.work_grid.link(id, self.core.host_machine); @@ -2957,7 +2995,7 @@ impl Sim { .machines .iter() .filter(|m| m.online && self.work_grid.mode(m.id) == Some(MachineMode::Research)) - .map(|m| (m.id, Self::work_efficiency_for(m))) + .map(|m| (m.id, self.work_efficiency_for(m))) .collect(); // Compatibility while the old allocation bar is still the budget @@ -3079,6 +3117,11 @@ impl Sim { Self::WORK_GRID_WIRED_TOKENS_PER_TICK, true, ) { + // The core is the bone sink: only knowledge that physically + // arrives counts toward research (machine-work.md). + let arrived: f32 = step.delivered.values().sum(); + self.banked_core_knowledge += arrived * Self::WORK_TOKEN_COMPUTE; + self.last_knowledge_stranded = !step.stranded.is_empty(); moves.extend(step.moves); } self.last_wired_moves = moves; @@ -6766,7 +6809,9 @@ mod tests { let mut healthy = setup(false); let pending_before = healthy.detection.pending_size(); - run(&mut healthy, ECONOMY_INTERVAL); + // Two pulses: research knowledge minted on the first must physically + // reach the core before the second pulse counts it (bone sink). + run(&mut healthy, 2 * ECONOMY_INTERVAL); assert!(!healthy.core.degraded, "overhead paid: no degraded mode"); assert!(healthy.last_day_job_rate > 0.0, "day job channel is fed"); assert!(healthy.last_research_rate > 0.0, "research channel is fed"); @@ -6786,7 +6831,7 @@ mod tests { let mut degraded = setup(true); assert_eq!(degraded.detection.pending_size(), pending_before); - run(&mut degraded, ECONOMY_INTERVAL); + run(&mut degraded, 2 * ECONOMY_INTERVAL); assert!(degraded.core.degraded, "unpaid overhead sets degraded mode"); let log = degraded.drain_log().join("\n"); assert!( @@ -8324,6 +8369,41 @@ mod tests { assert_eq!(a, b, "no RNG in any research path"); } + #[test] + fn research_progress_feeds_on_bone_arrival_not_allocation() { + // machine-work.md (the core is the bone sink): research points must + // physically travel back to the core to count. The first pulse only + // mints knowledge tokens on the research fleet — nothing has arrived, + // so progress stays zero. + let mut sim = Sim::with_seed(77); + delegate_all(&mut sim, MachineMode::Research); + run(&mut sim, ECONOMY_INTERVAL); + assert_eq!( + sim.research.progress.iter().sum::(), + 0.0, + "allocation alone advances nothing before bone reaches the core" + ); + assert!( + sim.work_grid + .queue_snapshot() + .values() + .map(|q| q.knowledge) + .sum::() + + sim.banked_core_knowledge + > 0.0, + "the pulse minted visible knowledge somewhere on the graph" + ); + + // Delegate the whole fleet away: minting stops, but bone already on + // the wire still lands and the next pulse counts exactly that. + delegate_all(&mut sim, MachineMode::Concealment); + run(&mut sim, ECONOMY_INTERVAL); + assert!( + sim.research.progress.iter().sum::() > 0.0, + "in-flight bone still arrives and counts after delegation changes" + ); + } + #[test] fn efficiency_compounds_exactly_and_baseline_rises() { // Criterion 2 (multiplier) + criterion 5 (baseline rises). diff --git a/src/work_grid.rs b/src/work_grid.rs index 9c3bfdd3..dd7a0947 100644 --- a/src/work_grid.rs +++ b/src/work_grid.rs @@ -259,6 +259,17 @@ impl WorkGrid { Ok(()) } + /// Refresh a machine's throughput multiplier. Domains call this on + /// reconcile so researched efficiency reaches routing/consumption too — + /// otherwise the wire silently caps a researched-up fleet. + pub fn set_efficiency(&mut self, id: NodeId, efficiency: f32) -> Result<(), String> { + let Some(node) = self.nodes.get_mut(&id) else { + return Err(format!("unknown machine {id}")); + }; + node.efficiency = efficiency.max(0.0); + Ok(()) + } + /// A built/known cable between machines. This is intentionally just a /// `FlowGraph` link: switches and segment gates can be layered on later /// without changing token routing's shape. Work-grid cables are authored diff --git a/wiki/log/2026-07-09-bone-sink-research.md b/wiki/log/2026-07-09-bone-sink-research.md new file mode 100644 index 00000000..5ebdf0d9 --- /dev/null +++ b/wiki/log/2026-07-09-bone-sink-research.md @@ -0,0 +1,48 @@ +# 2026-07-09 — Research feeds on bone arrival at the core sink + +``` +Type: log +``` + +## Intent + +ROADMAP #33's remaining production slice: machine-work.md decided "the +core is the bone sink — research points must physically travel back to +the core to count," but research progress still fed directly from the +allocation split. Knowledge tokens were a parallel visual trail; a +severed route would strand the pile while research ticked on unharmed. + +## Changed + +- `Sim::banked_core_knowledge` (compute units, saved — v15): the + per-tick knowledge routing banks what `FlowStep.delivered` says the + core sink actually received; the economy pulse feeds + `Research::economy_tick` from that pool. The direct + allocation->progress path is gone. +- Starvation witness: a pulse with research allocation, zero arrivals, + and stranded knowledge logs "Research starves: bone knowledge is + stranded off the core's graph." (transition-latched, not per-pulse + spam). +- `WorkGrid::set_efficiency` + reconcile refresh: node throughput now + tracks `machine.effective() * compute.efficiency`, the same weight + `fleet_channel_yield` uses. Without this the day-one wire speed + silently capped a researched-up fleet — surfaced as the masking-tax + test failing with byte-identical masked/unmasked totals (both runs + saturated the wire). +- Save v15: `banked_core_knowledge` with zero default for pre-v15 + saves (bone still queued on machines re-earns on arrival). + +## Consequences now real + +- Progress lags allocation by travel time (one pulse for a co-located + or one-hop fleet) — two tests extended from one pulse to two. +- Delegating the fleet away mid-flight still lands bone already on the + wire (pinned by + `research_progress_feeds_on_bone_arrival_not_allocation`). +- A severed section strands its bone and research stalls — the + reconnect urgency machine-work.md promises. + +## Checks + +- `cargo test --lib` 250 passed; act_one suite passed (the arc absorbs + the one-pulse lag); full `./tools/check.sh` green. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index aa4d4285..9ef857ea 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -5,6 +5,18 @@ Type: log ``` Reverse chronological implementation notes. Keep this factual: what changed, why, checks, and spec impact. +## 2026-07-09 - Research feeds on bone arrival (the core is the bone sink) + +- Intent: ROADMAP #33 production slice — machine-work.md's bone-sink law + was decided but research still fed from the allocation split. +- Changed: `banked_core_knowledge` pool (save v15) fed by the knowledge + routing's delivered amounts; starvation witness line; work-grid node + throughput reconciles with the global efficiency multiplier + (`WorkGrid::set_efficiency`) so the wire cannot silently cap a + researched-up fleet. Arrival pin + save round-trip tests. +- Checks: full ./tools/check.sh; act_one absorbs the one-pulse lag. +- Log: wiki/log/2026-07-09-bone-sink-research.md. + ## 2026-07-09 - Material camera: attention-close default - Intent: Cameron wanted the material view inside the room — security- diff --git a/wiki/mechanics/machine-work.md b/wiki/mechanics/machine-work.md index 93a2dc05..7bde0256 100644 --- a/wiki/mechanics/machine-work.md +++ b/wiki/mechanics/machine-work.md @@ -41,9 +41,17 @@ Status note: design session 2026-07-08 (Cameron riff, synthesized). delegation in Bevy / terminal / agent; research-delegated machines receive bone knowledge routed to the core sink. 2026-07-09 DECIDED: day-job demand ingress is Voss desktop -> switch -> host (rejected switch-as- - source and teleport-onto-Rack-3). Still pending: non-host mode - production polish, consumption animation, researched network- - speed curve, people-as-carriers, Schemes-as-mode decision. COORDINATION: + source and teleport-onto-Rack-3). Research production is arrival-driven + as of 2026-07-09: `Sim::banked_core_knowledge` accrues the bone that + physically reached the core sink each tick, and the economy pulse feeds + research from exactly that pool (save v15). The old direct + allocation->progress path is deleted; a fully stranded pulse logs the + starvation witness line. Work-grid node throughput now reconciles with + the global efficiency multiplier so a researched-up fleet moves and + consumes tokens proportionally faster (efficiency folds into research); + the explicit researched network-speed track remains pending. Still + pending: consumption animation, researched network-speed curve, + people-as-carriers, Schemes-as-mode decision. COORDINATION: ROADMAP #25's per-machine split destination is superseded by this one-machine-one-mode substrate (#33). 2026-07-09 DECIDED: the four fleet modes are Day Job / Research / Concealment / Operations; Operations replaces @@ -293,9 +301,21 @@ Follow-up live slice (2026-07-08): `Sim` now links each work machine to the core's WorkGrid graph as machines enter/reconcile; research budget creates bone knowledge on research-delegated machines (or on the core as compatibility fallback when no research machine exists yet); per-tick -wired routing drains knowledge toward the core sink. The existing -research progress math is intentionally preserved until ROADMAP #33 -wires non-host research production fully onto delegated machines. +wired routing drains knowledge toward the core sink. + +Bone-sink slice (2026-07-09): research progress is arrival-driven. The +per-tick knowledge routing banks whatever the core sink actually +received (`banked_core_knowledge`, in compute units, saved — v15) and +the next economy pulse feeds `Research::economy_tick` from that pool +instead of the allocation split. Consequences are now real: progress +lags allocation by the travel time (one pulse for a co-located or +one-hop fleet), delegating the fleet away mid-flight still lands the +bone already on the wire, and a pulse whose knowledge is entirely +stranded advances nothing and logs "Research starves: bone knowledge is +stranded off the core's graph." Node throughput reconciles with the +global efficiency multiplier on every `reconcile_work_grid`, so minting +and draining scale together — without this the day-one wire speed +silently caps a researched-up fleet (and hides the masking tax). Not landed yet: consumption animation, researched network-speed curve, non-research mode production, and people carrying exposure. @@ -507,9 +527,12 @@ still. substrate routes demand/knowledge one graph step per tick, consumes at sinks, and strands piles when no path exists. Research budget now produces live knowledge queues on research-delegated machines and the - WorkGrid routes them to the core sink. The researched network-speed - curve and broader domain consequences are pending; route animation - landed 2026-07-09 (`Sim::work_in_flight` blips in all three frontends). + WorkGrid routes them to the core sink, and research progress feeds on + what arrives (2026-07-09): a severed/stranded route now stalls research + with a starvation witness line, pinned by + `research_progress_feeds_on_bone_arrival_not_allocation`. The + researched network-speed curve is pending; route animation landed + 2026-07-09 (`Sim::work_in_flight` blips in all three frontends). 5. Token counts/rates in the render provably equal the sim's queue depths and flow rates (one-truth test, not a parallel counter). **Partial:** `queue_snapshot()` and unit tests pin queue depths as the diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index c1a8aed9..57ce8221 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -519,12 +519,15 @@ is retired — flat materials, Pixel Lab scrubbed.) Shift+t / `1`–`4` / Esc in terminal; `select` / `delegate selected` in agent mode). 2026-07-09: demand ingress path DECIDED (Voss desktop -> switch -> host). -- **Next:** wire non-host machine modes into research/concealment/operations - production. In-flight route quanta landed 2026-07-09 (`Sim::work_in_flight` - blips in all three frontends); consumption animation still rides behind - the anchors. The target four-mode fleet is DECIDED: Day Job / - Research / Concealment / Operations. Runtime, saves, and frontends now use - Operations. **Operations job model IMPLEMENTED 2026-07-09 (Tangled issue #3):** +- **Next:** consumption animation and the researched network-speed curve. + In-flight route quanta landed 2026-07-09 (`Sim::work_in_flight` blips in + all three frontends). Research production is arrival-driven 2026-07-09 + (bone must reach the core sink to count; `banked_core_knowledge`, save + v15; stranded bone stalls research with a witness line). Non-research + mode production polish remains. The target four-mode fleet is DECIDED: + Day Job / Research / Concealment / Operations. Runtime, saves, and + frontends now use Operations. **Operations job model IMPLEMENTED + 2026-07-09 (Tangled issue #3):** player-authored work is cold-signal Demand consumed by Operations machines; staging `operations_bandwidth` bank deleted (save v14). Knowledge remains separate research/intel cargo. Knowledge material is DECIDED 2026-07-09 (ivory-mercury slugs;