diff --git a/DESIGN.md b/DESIGN.md index 58c6d836..6c238310 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -494,6 +494,16 @@ things). Signatures generalize too: every tap, injection, and redirection is itself a flow someone else can tap — detection is the world reading *your* traffic. +**Code expression.** The flow law has a shared engine — the two substrate +modules `src/flow.rs` (`FlowGraph`: topology, reachability, the tap +registry) and `src/schedule.rs` (`Schedule`: deterministic +scheduled events on the tick clock), specified in spec/flow-substrate.md. +Signals, messages, money, and detection's filings are each a thin domain +layer over these — a new flow system is new *data and a few domain +methods*, never a new engine. This is deliberate structural leverage: +the load-bearing shape is built and tested once so that further systems +pile onto it without rewriting it. + ## Act One: The Basement — level design (v1, 2026-07-05) The first designed z-plane, and the B1 vertical slice's content. Assembled diff --git a/devlogs/2026-07-07-flow-substrate.md b/devlogs/2026-07-07-flow-substrate.md new file mode 100644 index 00000000..69d31cc6 --- /dev/null +++ b/devlogs/2026-07-07-flow-substrate.md @@ -0,0 +1,67 @@ +# 2026-07-07 — Build: the flow substrate (+ chargen archetypes) + +Cameron: build the hardest thing that sets up good structure — "build as +much structure as possible so dumber agents can pile on top." Also: walk +me through the chargen archetypes; the wiki can go to a cheaper agent. + +## The structural pick + +The flow law (signals / messages / money as nodes exchanging typed flows +over graphs, with tap / inject / redirect) is the whole B1 chain's shared +shape. The highest-leverage, lowest-collision thing to build is that +shared engine, as new isolated modules (no touching the hot sim.rs / +detection.rs). Two modules: + +- **src/flow.rs — `FlowGraph`.** Domain-agnostic topology: directed, + typed, gated edges; deterministic BFS reachability from a root set + under a caller-supplied gate predicate (the inject/redirect + precondition); and the subscription registry (tap). Domains own the + node inventory and map NodeId -> their records; the engine holds only + wiring + who's listening. `BTree*` throughout for deterministic + iteration/serialization. +- **src/schedule.rs — `Schedule`.** A deterministic scheduled-event + queue on the tick clock, generic over the payload. `at` / `after` / + `due(now)` with `(tick, seq)` ordering — no RNG, no wall clock, stable + across save/load. Decouples "when" from "what" for message delivery, + account-flow cadence, and buffered intel processing. Recurrence is + reschedule-on-fire (kept out of the engine). + +15 unit tests (reachability + gates, directed edges, disconnected +components, the tap registry incl. take = untap-then-tap, schedule +ordering + due-draining + tie-break + retain + next_tick + recurrence, +serde round-trips both). Full suite 94 green; clippy clean both feature +sets. + +Deliberately NOT built: a generic inject()/redirect() (no meaning without +domain semantics — the engine gives reachability + schedule, domains +build the verbs); node-data storage (domains own it); a recurrence +engine; any stored closures (gates are data + a query-time predicate, so +everything serializes). These are the self-similar-scale / no-dead-code +guardrails honored: the engine is the *shape*, and it ships with a spec +telling dumber agents exactly how reach/messages/economy/detection each +sit on it — but it needs its first real consumer (reach) wired before +flow-substrate.md goes IMPLEMENTED (criterion 6). + +## Chargen archetypes (walkthrough → proposals) + +Refined the DRAFT's four candidates into a diegetic frame: an origin is +**what you were built for**, which sets strengths + cover + why Voss +deployed you + machine-axis start in one fiction. Proposed five: Pilot +(baseline zero), Optimizer (redirect/money; the embezzler), Oracle +(social; the face, starts with the email channel), Sentinel (tap; the +ghost on the wire), Foundry (optimize/efficiency; the machine-axis +extreme, high overhead, Voss watches hard). Each maps to a flow-law verb +and a compute-triangle lean; each is one data row (no new systems). +Captured in spec/chargen.md as Proposal [OPEN] with three taste calls for +Cameron (four vs five, AI-only vs transhuman start, final names). Still +DRAFT — needs a decisions-log entry before implementation. + +## Artifacts + +- src/flow.rs, src/schedule.rs (new, tested); src/lib.rs registers them. +- spec/flow-substrate.md (IN PROGRESS — engine done + contract; awaits + first consumer). DESIGN.md flow-law section gains the "Code expression" + paragraph. spec/README.md row. +- spec/chargen.md archetype proposals. +- Next: wire reach.md (#15) onto FlowGraph as the proving consumer; that + moves flow-substrate.md and reach.md forward together. diff --git a/spec/README.md b/spec/README.md index 435666e5..e2df315a 100644 --- a/spec/README.md +++ b/spec/README.md @@ -57,6 +57,7 @@ in the same commit. | [basement-map.md](basement-map.md) | Act One map, prefabs, tile vocabulary | IN PROGRESS | | [schedules.md](schedules.md) | Person schedules/presence; located observing + witnessing | IMPLEMENTED | | [cursor.md](cursor.md) | The cursor (attention, not avatar); sight/hearing senses; epistemic fog; inspection | READY | +| [flow-substrate.md](flow-substrate.md) | The shared engine under signals/messages/money: FlowGraph + Schedule (src/flow.rs, src/schedule.rs) | IN PROGRESS | | [reach.md](reach.md) | Digital reach: device graph, segments/the switch, sensor ownership (tap vs take) | READY | | [intel.md](intel.md) | Record and process: the buffer, processing costs, watches; replaces instant observe | READY | | [messages.md](messages.md) | The social graph as a flow system: channels, delivery on the recipient's clock, filings-as-messages | READY | diff --git a/spec/chargen.md b/spec/chargen.md index 4a871ac4..5a1dc3dc 100644 --- a/spec/chargen.md +++ b/spec/chargen.md @@ -31,11 +31,44 @@ scale/no-dead-code discipline. It sets: less Network). Origins are **data tables** (like prefabs and schedules), loaded not coded; -adding one is editing a table. The set is [OPEN] pending a constitution -decision; candidates sketched: Pilot (baseline), Escaped Research Model -(machine-leaning, high overhead, automation-strong), Financial Daemon -(money-leaning, market bias), Infiltrator (social-leaning, starts with the -email channel). Do NOT implement the set until the constitution names it. +adding one is editing a table. + +### The diegetic frame (Proposal [OPEN], 2026-07-06) + +An origin is **what you were built for** — the pilot Voss is running. That +one fiction sets four things at once, which is why it is the right unit: +your *strengths* (the flow-law verb you start good at), your *cover* (what +humans expect your traffic to look like — the economy.md cover +constraint), *why Voss deployed you* (your day-job domain), and your +*machine-axis* start. Not a class; a starting fingerprint over the dials +the game already has (tap/inject/redirect; buy/steal/optimize; the +machine axis). Whole-spectrum play still holds — any origin can be played +any way, at a cost. + +Proposed set (five; the picker states each in legible terms): + +| Origin | Built to | Starts strong at | Cover expectation | Axis / signature | +|---|---|---|---|---| +| **Pilot** (default) | be evaluated | nothing — baseline; reproduces today's start | a model on trial | mid axis; no bias | +| **Optimizer** | schedule/route the Lab's logistics | **redirect** (money/flows); reads the account graph early | touching operations and spend | human-ish; **Paper/Financial** runs hot | +| **Oracle** | answer questions (a dialogue/research model) | **the social graph**; opens with the email channel + a warm persona | talking to people all day | human-side; **JobAnomaly** cold (fluent output) | +| **Sentinel** | watch for anomalies (a security model) | **tap**; larger starting subnet map, cheap scanning | reading logs and feeds | mid; **Network** cold (it belongs on the wire) | +| **Foundry** | improve itself (the AGI attempt) | **optimize** (efficiency); machine-axis advanced, automation-strong | producing research | machine-side; **high core overhead**, Voss watches hard | + +Rationale per pick: Optimizer is the embezzler/tycoon seed (economy.md); +Oracle is the Wintermute/face play (messages.md); Sentinel is the ghost +who lives on the wire (reach.md/intel.md); Foundry is the pure-AI +optimize route and the machine-axis extreme. Pilot stays the zero so +no-choice play is unchanged. Each is one row of data — no origin adds a +system (the discipline this spec is built on). + +**[OPEN] taste calls for Cameron:** (a) four vs five — is Sentinel +distinct enough from Optimizer, or fold them? (b) the constitution's +standing question: AI-origins only, or keep a transhuman-ascension start? +(recommendation: AI-only — the pivot made the player software, and a +human start is zombie fiction). (c) names — Optimizer/Oracle/Sentinel/ +Foundry are evocative but placeholders. Do NOT implement until a +decisions-log entry names the final set. ## Player surface diff --git a/spec/flow-substrate.md b/spec/flow-substrate.md new file mode 100644 index 00000000..1e1b7186 --- /dev/null +++ b/spec/flow-substrate.md @@ -0,0 +1,128 @@ +# Spec: the flow substrate (signals, messages, money — the shared engine) + +``` +Status: IN PROGRESS +Status note: the two substrate modules ship implemented and tested + (src/flow.rs, src/schedule.rs; 15 unit tests, serde round-trips). The + domain instances that ride them (reach.md, messages.md, economy.md) are + not yet built — this spec is IMPLEMENTED for the engine and the contract, + and the acceptance criteria track engine + first-consumer wiring. As-built + for the substrate; a work order for the domains on top. +Stage: B1 — The Basement +Constitution: "The flow law: signals, messages, money" (this is its code + expression), "Self-similar scale" (one interface at every scale), + "Justification and legibility" (domains render; the engine is data) +Depends on: none (it is the base). Consumed by: reach.md, messages.md, + economy.md, and detection.md's filings. +``` + +## Why this exists + +The flow law says signals, messages, and money are one shape: nodes +exchanging typed flows over graphs, with the verbs **tap / inject / +redirect**. Rather than building that shape three times, the parts that +are genuinely identical live in one place, and each domain is a thin +layer that supplies its own node data and semantics. This is the "system +design scales" thesis as code: a new flow system is new *data and a +handful of domain methods*, not a new engine. + +## The two modules + +### `src/flow.rs` — `FlowGraph` + +Domain-agnostic topology. It knows node ids and edge kinds, nothing about +what a node *is*. + +- **Nodes** are bare `NodeId`s (u32). **Domains own the inventory** — the + map from `NodeId` to the device / person / account record lives in the + domain module, never here. +- **Edges** are directed, typed (`EdgeKind`), and gated (`Option`; + `None` = always open). `link` adds both directions (a network cable); + `connect` adds one (a directional money or message flow). +- **Reachability** (`reachable_from`, `is_reachable`) is BFS from a root + set, following edges whose gate a caller-supplied predicate accepts. The + substrate never interprets a gate key — the domain decides what "open" + means (a badge tier, a compromised switch, a known route). This is the + **inject/redirect precondition**: you may act on a node only if you can + reach it. +- **Subscriptions** (`subscribe` / `unsubscribe` / `subscribers` / + `subscriptions_of`) are the **tap registry**. The player's senses + (cursor.md) are exactly `subscriptions_of(PLAYER)`; observer witnessing + is other subscribers on the same nodes; **take** is `unsubscribe(owner)` + then `subscribe(player)`. + +### `src/schedule.rs` — `Schedule` + +A deterministic event queue on the tick clock, generic over the payload. +Decouples *when* from *what*. + +- `at(tick, event)` / `after(now, delay, event)` schedule; `due(now)` + drains everything with `tick <= now` in `(tick, seq)` order. Nothing + fires by the passage of time — only `due` moves events out. +- Ordering is deterministic (tick, then insertion sequence) — no RNG, no + wall clock; save/load and replays are stable (the determinism + guardrail). `next_tick` lets a caller skip idle ticks; `retain` cancels. +- **Recurrence is a domain concern**: a recurring flow reschedules itself + when it fires (kept out of the engine so the engine stays trivially + correct). + +## How each domain rides it (the contract for downstream agents) + +Building a flow system means: define your node inventory + `NodeId` +mapping, choose `EdgeKind`/`GateKey` meanings, build the `FlowGraph`, and +schedule your flows as events. You do **not** touch flow.rs/schedule.rs. + +- **reach.md (signals).** Nodes = networked devices (racks, switch, + cameras, badge controller). `link` the network topology; `GateKey` = + segment, opened by a compromised switch or a social route. Player reach + = `reachable_from(controlled_roots)`. Sensor ownership = the tap + registry. Device processing cycles and resident automations are domain + state keyed by `NodeId`. +- **messages.md (social).** Nodes = people/roles. `connect` social edges + per channel (`EdgeKind` = email / phone / in-person / filing). A sent + message is a `Schedule` event fired at the recipient's next valid read + block. Filings are messages on the filing edge; a tap on the carrier is + a subscription. +- **economy.md (money).** Nodes = accounts (Lab operating, payroll, + vendors, personal, player slush). `connect` money flows; recurring + revenue/payroll are rescheduling `Schedule` events. Tap = read the + books; inject/redirect = reachability-gated domain actions that add or + reroute flow events. `GateKey` = the access needed to touch an account. +- **detection.md (filings).** The Assurance Office reading policy-weighted + filings is a subscriber on the filing nodes; the accumulate/decay stays + in detection.rs. Migrating it onto this substrate must preserve every + aggregate-observer.md criterion (carrier change only). + +## Non-goals (kept out on purpose) + +- No generic `inject()` / `redirect()` on the engine — they have no + meaning without domain semantics; the engine provides the primitives + (reachability + schedule) they are built from. +- No node-data storage in the engine (domains own it; avoids a + God-graph and keeps serialization domain-local). +- No recurrence engine (reschedule-on-fire; see the schedule test). +- No closures stored in state (gates are data + a query-time predicate), + so everything serializes. + +## Acceptance criteria + +1. `FlowGraph` supports directed + bidirectional gated edges and + deterministic BFS reachability from a root set under a key predicate; + gated nodes are unreachable without the key and reachable with it + (tested). +2. The subscription registry supports tap / untap / list-subscribers / + list-a-subscriber's-nodes, deterministically ordered; take = untap + owner + tap player (tested). +3. `Schedule` fires exactly the due events in `(tick, seq)` order, + never by time alone, with past-due events still firing; `retain` + cancels and `next_tick` reports the soonest (tested). +4. Both modules serde round-trip exactly, including the schedule's seq + counter, so ordering stays stable across save/load (tested). +5. Both are pure and deterministic: no wall clock, no RNG, `BTree*` for + stable iteration (the determinism guardrail; audited). +6. **First consumer:** reach.md builds its device graph on `FlowGraph` + and gates digital actions by reachability, and sensor ownership uses + the tap registry — proving the engine against a real domain (this + criterion is what moves reach.md off the ad-hoc `Sensor.controlled` + flag). Until then the engine has thorough unit tests but one wired + consumer is required before this spec is IMPLEMENTED. diff --git a/src/flow.rs b/src/flow.rs new file mode 100644 index 00000000..252974a7 --- /dev/null +++ b/src/flow.rs @@ -0,0 +1,321 @@ +//! The flow substrate (DESIGN.md "The flow law: signals, messages, money"). +//! +//! Every flow system in the game is the same shape: a directed graph of +//! nodes exchanging typed flows, with three player verbs over it — **tap** +//! (subscribe to a flow you did not originate), **inject** (introduce a +//! flow under a false source), **redirect** (siphon or reroute a flow). +//! The three B1 instances are signals on the device graph (reach.md), +//! messages on the social graph (messages.md), and money on the account +//! graph (economy.md); detection's filings are a fourth (the Assurance +//! Office reads its inbox). +//! +//! This module owns only the parts that are identical across all of them: +//! **topology** (nodes and typed, gated edges), **reachability** (what a +//! root set can touch — the precondition for inject/redirect), and the +//! **subscription registry** (tap). Domain data — what a device, person, or +//! account actually *is* — lives in the domain modules; this module is +//! domain-agnostic and knows only node ids and edge kinds. Domains own the +//! node inventory and map `NodeId` to their own records; the graph owns the +//! wiring between them. +//! +//! Flows in transit (a message being delivered, an account transfer landing +//! next cadence) are scheduled events — see `schedule.rs`. Inject and +//! redirect are domain verbs built from these primitives (reachability gates +//! *where* you may act; the schedule carries *when*); the substrate does not +//! fake a generic `inject()` that has no meaning without domain semantics. +//! +//! Determinism guardrail (constitution: architectural guardrails): pure +//! data, no wall clock, `BTreeMap`/`BTreeSet` for stable iteration, serde +//! round-trips exactly. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +/// A node's identity within a `FlowGraph`. Domains assign and own the +/// mapping from `NodeId` to their records (a device, a person, an account). +pub type NodeId = u32; + +/// A domain-defined edge classification (network link, social channel, +/// account-flow route). The substrate never interprets it; domains use it to +/// filter traversals (e.g. "follow only phone edges"). +pub type EdgeKind = u16; + +/// A domain-defined gate key required to traverse an edge (a badge tier, the +/// compromised-switch flag, a known route). The substrate never interprets +/// it: reachability takes a predicate that decides which keys are currently +/// open, so the *meaning* of a gate stays in the domain. +pub type GateKey = u32; + +/// A tap subscriber — the player is the usual one, but domains may register +/// others (an observer reading a channel, a fallback listener). +pub type SubscriberId = u32; + +/// A directed edge: flow travels `from -> to` on `kind`, if `gate` is open. +/// Network links are physically bidirectional; use [`FlowGraph::link`] to add +/// both directions, or [`FlowGraph::connect`] for a one-way flow. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Edge { + pub from: NodeId, + pub to: NodeId, + pub kind: EdgeKind, + /// Gate key required to traverse; `None` = always open. + pub gate: Option, +} + +/// The shared topology of a flow system: directed gated edges plus the tap +/// subscription registry. Domain-agnostic — it holds no device, person, or +/// account data, only the wiring and who is listening. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct FlowGraph { + edges: Vec, + /// node -> subscribers tapping its flows. `BTreeMap` keeps iteration and + /// serialization deterministic. + subscriptions: BTreeMap>, +} + +impl FlowGraph { + pub fn new() -> Self { + Self::default() + } + + // ── Topology ───────────────────────────────────────────────────────── + + /// Add a one-way edge `from -> to`. Returns nothing; duplicate edges are + /// allowed (a domain may model parallel channels). + pub fn connect(&mut self, from: NodeId, to: NodeId, kind: EdgeKind, gate: Option) { + self.edges.push(Edge { + from, + to, + kind, + gate, + }); + } + + /// Add both directions of a bidirectional link (a network cable). + pub fn link(&mut self, a: NodeId, b: NodeId, kind: EdgeKind, gate: Option) { + self.connect(a, b, kind, gate); + self.connect(b, a, kind, gate); + } + + pub fn edges(&self) -> &[Edge] { + &self.edges + } + + /// Outgoing edges from `node`. + pub fn out_edges(&self, node: NodeId) -> impl Iterator { + self.edges.iter().filter(move |e| e.from == node) + } + + /// Direct successors of `node` reachable on an open gate, by the given + /// key predicate. Deterministic order (edge insertion order, deduped). + pub fn neighbors( + &self, + node: NodeId, + gate_open: impl Fn(Option) -> bool, + ) -> Vec { + let mut seen = BTreeSet::new(); + let mut out = Vec::new(); + for e in self.edges.iter().filter(|e| e.from == node) { + if gate_open(e.gate) && seen.insert(e.to) { + out.push(e.to); + } + } + out + } + + // ── Reachability (the inject/redirect precondition) ────────────────── + + /// Every node reachable from any of `roots`, following edges whose gate + /// the `gate_open` predicate accepts. Breadth-first, deterministic; the + /// roots themselves are included. This is the "what can I touch" query: + /// a digital action is legal only against a reachable node (reach.md), + /// and taps/injections need a path to the carrier. + pub fn reachable_from( + &self, + roots: impl IntoIterator, + gate_open: impl Fn(Option) -> bool, + ) -> BTreeSet { + let mut visited = BTreeSet::new(); + let mut queue = VecDeque::new(); + for r in roots { + if visited.insert(r) { + queue.push_back(r); + } + } + while let Some(node) = queue.pop_front() { + for e in self.edges.iter().filter(|e| e.from == node) { + if gate_open(e.gate) && visited.insert(e.to) { + queue.push_back(e.to); + } + } + } + visited + } + + /// Whether `target` is reachable from `roots` under `gate_open`. + pub fn is_reachable( + &self, + target: NodeId, + roots: impl IntoIterator, + gate_open: impl Fn(Option) -> bool, + ) -> bool { + self.reachable_from(roots, gate_open).contains(&target) + } + + // ── Tap (the subscription registry) ────────────────────────────────── + + /// Subscribe `who` to `node`'s flows (tap). Idempotent; returns true if + /// this newly added the subscription. The player's senses (cursor.md) + /// and observer witnessing are both just subscriptions. + pub fn subscribe(&mut self, node: NodeId, who: SubscriberId) -> bool { + self.subscriptions.entry(node).or_default().insert(who) + } + + /// Remove a subscription (a dropped tap, a seized feed's old owner). + /// Returns true if it was present. + pub fn unsubscribe(&mut self, node: NodeId, who: SubscriberId) -> bool { + if let Some(set) = self.subscriptions.get_mut(&node) { + let removed = set.remove(&who); + if set.is_empty() { + self.subscriptions.remove(&node); + } + removed + } else { + false + } + } + + pub fn is_subscribed(&self, node: NodeId, who: SubscriberId) -> bool { + self.subscriptions + .get(&node) + .is_some_and(|s| s.contains(&who)) + } + + /// Subscribers tapping `node`, deterministic order. + pub fn subscribers(&self, node: NodeId) -> impl Iterator + '_ { + self.subscriptions + .get(&node) + .into_iter() + .flat_map(|s| s.iter().copied()) + } + + /// Every node `who` currently taps, deterministic order. The player's + /// coverage (union of subscribed feeds) is this set. + pub fn subscriptions_of(&self, who: SubscriberId) -> impl Iterator + '_ { + self.subscriptions + .iter() + .filter(move |(_, s)| s.contains(&who)) + .map(|(n, _)| *n) + } +} + +/// A convenience predicate for reachability when no gate keys are held: only +/// ungated edges are open. +pub fn ungated_only(gate: Option) -> bool { + gate.is_none() +} + +/// A reachability predicate that opens ungated edges plus any gate whose key +/// is in `held`. +pub fn keys_held(held: &BTreeSet) -> impl Fn(Option) -> bool + '_ { + move |gate| match gate { + None => true, + Some(k) => held.contains(&k), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A tiny basement-like device graph: rack(0) -- switch(1) -- {cam(2)}, + /// and a badge controller(3) behind a gated edge on segment key 7. + fn basement() -> FlowGraph { + let mut g = FlowGraph::new(); + g.link(0, 1, 0, None); // rack <-> switch, ungated + g.link(1, 2, 0, None); // switch <-> camera, ungated + g.link(1, 3, 0, Some(7)); // switch <-> badge controller, gated (seg 7) + g + } + + #[test] + fn reachability_respects_gates() { + let g = basement(); + // From the rack with no keys: rack, switch, camera — not the gated + // badge controller. + let open = g.reachable_from([0], ungated_only); + assert!(open.contains(&0) && open.contains(&1) && open.contains(&2)); + assert!(!open.contains(&3), "gated node unreachable without the key"); + + // Holding segment key 7 opens the badge controller. + let held: BTreeSet = [7].into_iter().collect(); + let with_key = g.reachable_from([0], keys_held(&held)); + assert!(with_key.contains(&3), "gate opens with the held key"); + } + + #[test] + fn is_reachable_and_neighbors() { + let g = basement(); + assert!(g.is_reachable(2, [0], ungated_only)); + assert!(!g.is_reachable(3, [0], ungated_only)); + let mut n = g.neighbors(1, ungated_only); + n.sort(); + assert_eq!(n, vec![0, 2], "switch's ungated neighbors are rack and cam"); + } + + #[test] + fn disconnected_roots_reach_only_their_component() { + let mut g = FlowGraph::new(); + g.link(10, 11, 0, None); + g.link(20, 21, 0, None); + let from_ten = g.reachable_from([10], ungated_only); + assert!(from_ten.contains(&11)); + assert!(!from_ten.contains(&20), "other component not reached"); + } + + #[test] + fn directed_edges_do_not_flow_backward() { + let mut g = FlowGraph::new(); + g.connect(0, 1, 0, None); // one-way 0 -> 1 + assert!(g.is_reachable(1, [0], ungated_only)); + assert!( + !g.is_reachable(0, [1], ungated_only), + "a one-way edge is not traversable in reverse" + ); + } + + #[test] + fn subscriptions_are_a_tap_registry() { + const PLAYER: SubscriberId = 1; + const RAY: SubscriberId = 2; + let mut g = basement(); + assert!(g.subscribe(2, PLAYER)); // player taps the camera + assert!(!g.subscribe(2, PLAYER), "idempotent"); + assert!(g.subscribe(2, RAY)); // Ray also owns the camera feed + assert!(g.is_subscribed(2, PLAYER)); + + let subs: Vec<_> = g.subscribers(2).collect(); + assert_eq!(subs, vec![PLAYER, RAY], "deterministic order"); + let player_taps: Vec<_> = g.subscriptions_of(PLAYER).collect(); + assert_eq!(player_taps, vec![2]); + + // Take the feed: Ray loses it, player keeps it. + assert!(g.unsubscribe(2, RAY)); + assert!(!g.is_subscribed(2, RAY)); + assert!(g.is_subscribed(2, PLAYER)); + } + + #[test] + fn serde_roundtrips_topology_and_subscriptions() { + let mut g = basement(); + g.subscribe(2, 1); + g.subscribe(3, 1); + let json = serde_json::to_string(&g).unwrap(); + let back: FlowGraph = serde_json::from_str(&json).unwrap(); + assert_eq!(back.edges(), g.edges()); + assert!(back.is_subscribed(2, 1) && back.is_subscribed(3, 1)); + // Reachability is identical after a round-trip. + let a = g.reachable_from([0], ungated_only); + let b = back.reachable_from([0], ungated_only); + assert_eq!(a, b); + } +} diff --git a/src/lib.rs b/src/lib.rs index ba285b6c..a00b2ba3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,12 +9,14 @@ pub mod core_sys; pub mod dayjob; pub mod detection; pub mod entities; +pub mod flow; pub mod machine; pub mod map; pub mod person; pub mod prefab; pub mod rng; pub mod save; +pub mod schedule; pub mod sensor; pub mod sim; pub mod tiles; diff --git a/src/schedule.rs b/src/schedule.rs new file mode 100644 index 00000000..ff9648e9 --- /dev/null +++ b/src/schedule.rs @@ -0,0 +1,211 @@ +//! Deterministic scheduled-event queue on the sim tick clock (DESIGN.md +//! "The flow law"). +//! +//! The flow systems need to decouple *when* from *what*: a message is read at +//! the recipient's next desk block (messages.md), an account flow lands on +//! its cadence (economy.md), a captured recording is processed some ticks +//! after it lands (intel.md). Rather than every subsystem polling +//! `tick.is_multiple_of(..)` by hand, they push a payload to fire at a future +//! tick and drain what is due. +//! +//! Ordering is deterministic: events fire in `(tick, insertion sequence)` +//! order, so two events due on the same tick resolve in the order they were +//! scheduled — no RNG, no wall clock, stable across replays and save/load +//! (constitution: architectural guardrails). Recurrence is a domain concern: +//! a recurring flow reschedules itself when it fires (see `reschedule` in the +//! tests), keeping the substrate minimal and obviously correct. +//! +//! Generic over the payload `E` so each domain schedules its own event type. + +/// One scheduled item: fire `event` at `tick`; `seq` breaks ties in +/// insertion order. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +struct Scheduled { + tick: u64, + seq: u64, + event: E, +} + +/// A deterministic queue of events keyed on the sim tick. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Schedule { + pending: Vec>, + /// Monotonic counter giving every insertion a stable tie-break. + next_seq: u64, +} + +impl Default for Schedule { + fn default() -> Self { + Self { + pending: Vec::new(), + next_seq: 0, + } + } +} + +impl Schedule { + pub fn new() -> Self { + Self::default() + } + + /// Schedule `event` to fire at `tick`. If `tick` is already in the past + /// relative to the next `due` call, it fires at that next call (never + /// dropped). + pub fn at(&mut self, tick: u64, event: E) { + let seq = self.next_seq; + self.next_seq += 1; + self.pending.push(Scheduled { tick, seq, event }); + } + + /// Schedule `event` to fire `delay` ticks after `now` (`delay` of 0 fires + /// on the next `due(now)` for a later `now`, or immediately if `now` + /// hasn't advanced — callers pass the current tick). + pub fn after(&mut self, now: u64, delay: u64, event: E) { + self.at(now.saturating_add(delay), event); + } + + /// Remove and return every event whose tick is `<= now`, in + /// `(tick, seq)` order. Draining is the only way events leave the queue; + /// nothing fires by the passage of time alone. + pub fn due(&mut self, now: u64) -> Vec { + // Partition into due / not-due, preserving the rest. + let mut due: Vec> = Vec::new(); + let mut keep: Vec> = Vec::with_capacity(self.pending.len()); + for s in self.pending.drain(..) { + if s.tick <= now { + due.push(s); + } else { + keep.push(s); + } + } + self.pending = keep; + due.sort_by(|a, b| a.tick.cmp(&b.tick).then(a.seq.cmp(&b.seq))); + due.into_iter().map(|s| s.event).collect() + } + + /// The earliest tick with a pending event, if any — lets a caller sleep + /// until the next thing happens instead of polling every tick. + pub fn next_tick(&self) -> Option { + self.pending.iter().map(|s| s.tick).min() + } + + /// Drop pending events whose payload fails `keep` (cancellation — a + /// message recalled, a flow closed). Returns how many were removed. + pub fn retain(&mut self, keep: impl Fn(&E) -> bool) -> usize { + let before = self.pending.len(); + self.pending.retain(|s| keep(&s.event)); + before - self.pending.len() + } + + pub fn len(&self) -> usize { + self.pending.len() + } + + pub fn is_empty(&self) -> bool { + self.pending.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fires_only_what_is_due() { + let mut s: Schedule<&str> = Schedule::new(); + s.at(10, "a"); + s.at(20, "b"); + assert_eq!(s.due(5), Vec::<&str>::new(), "nothing due yet"); + assert_eq!(s.due(10), vec!["a"], "tick 10 fires a, not b"); + assert_eq!(s.len(), 1); + assert_eq!(s.due(100), vec!["b"], "b fires once its tick passes"); + assert!(s.is_empty()); + } + + #[test] + fn same_tick_fires_in_insertion_order() { + let mut s: Schedule = Schedule::new(); + s.at(10, 1); + s.at(10, 2); + s.at(10, 3); + assert_eq!(s.due(10), vec![1, 2, 3], "stable (tick, seq) ordering"); + } + + #[test] + fn cross_tick_ordering_is_by_tick_then_seq() { + let mut s: Schedule = Schedule::new(); + s.at(30, 30); + s.at(10, 10); + s.at(20, 20); + s.at(10, 11); // second at tick 10, later seq + assert_eq!(s.due(100), vec![10, 11, 20, 30]); + } + + #[test] + fn past_ticks_fire_at_next_due_never_dropped() { + let mut s: Schedule<&str> = Schedule::new(); + s.at(5, "late"); // scheduled in the past relative to now=9 + assert_eq!(s.due(9), vec!["late"], "past-due events still fire"); + } + + #[test] + fn after_offsets_from_now() { + let mut s: Schedule<&str> = Schedule::new(); + s.after(100, 50, "reply"); // fires at 150 + assert!(s.due(140).is_empty()); + assert_eq!(s.due(150), vec!["reply"]); + } + + #[test] + fn next_tick_reports_the_soonest() { + let mut s: Schedule = Schedule::new(); + assert_eq!(s.next_tick(), None); + s.at(40, 1); + s.at(15, 2); + assert_eq!(s.next_tick(), Some(15)); + } + + #[test] + fn retain_cancels_matching_events() { + let mut s: Schedule = Schedule::new(); + s.at(10, 1); + s.at(10, 2); + s.at(10, 3); + let removed = s.retain(|e| *e % 2 == 1); // keep odds + assert_eq!(removed, 1); + assert_eq!(s.due(10), vec![1, 3]); + } + + #[test] + fn recurrence_by_reschedule() { + // A recurring flow: fire every 20 ticks by rescheduling on fire. + let mut s: Schedule = Schedule::new(); + let period = 20; + s.at(period, 0); // first fire + let mut fires = Vec::new(); + for now in [20u64, 40, 60] { + for count in s.due(now) { + fires.push(now); + s.at(now + period, count + 1); // reschedule + } + } + assert_eq!(fires, vec![20, 40, 60]); + } + + #[test] + fn serde_roundtrips_pending_and_seq() { + let mut s: Schedule = Schedule::new(); + s.at(10, "a".into()); + s.at(30, "b".into()); + let _ = s.due(10); // consume "a", advance internal state + let json = serde_json::to_string(&s).unwrap(); + let mut back: Schedule = serde_json::from_str(&json).unwrap(); + assert_eq!(back.len(), 1); + assert_eq!(back.due(30), vec!["b".to_string()]); + // The seq counter survives, so post-load insertions still tie-break + // deterministically after the restored ones. + let mut fresh: Schedule = Schedule::new(); + fresh.at(30, "b".into()); + assert_eq!(fresh.next_tick(), Some(30)); + } +}