Skip to main content

brink_runtime/
world.rs

1//! Shared story state (`World`), the per-flow override layer (`FlowLocal`),
2//! and the routing view that composes them behind [`ContextAccess`].
3//!
4//! This is the F1.3 stage of the scoped-flow-state restructuring
5//! (`docs/scoped-flow-state-spec.md`): `World` replaces the old monolithic
6//! `Context` as the core mutable-state primitive. The [`ContextView`]
7//! routing view implements [`ContextAccess`] over `(&mut World, &mut
8//! FlowLocal)`.
9//!
10//! F2.1 adds the **policy** types ([`Scope`], [`WorldPolicy`],
11//! [`ResolvedPolicy`]) and their resolution against a [`Program`]'s symbol
12//! table.
13//!
14//! F2.2 gave `FlowLocal` flat override storage (plain maps/options — no
15//! `CoW` chain) and wired [`ContextView`] to route every [`ContextAccess`]
16//! op by consulting `World`'s [`ResolvedPolicy`] with **read-through**
17//! semantics: a `Local`-scoped unit reads its `FlowLocal` override if
18//! present, else falls back to `World`'s value; a `World`-scoped unit
19//! always goes straight to `World`. Writes to a `Local`-scoped unit land in
20//! `FlowLocal`; writes to a `World`-scoped unit land in `World`,
21//! immediately visible to every flow sharing it.
22//!
23//! F3.1 upgraded `FlowLocal`'s storage to a copy-on-write, frozen-base
24//! read-through **chain**: `FlowLocal` gains an optional [`Arc<FrozenLocal>`]
25//! base, an immutable snapshot of another `FlowLocal`'s overrides (captured
26//! via [`FlowLocal::freeze`]) that can itself chain to a further base. A read
27//! walks **own overrides → base (recursively) → [miss]**; a miss falls
28//! through to `World` exactly as before. Writes still land only in the
29//! flow's own top-layer overrides.
30//!
31//! **F3.2 (this stage)** adds **fork + sandbox mode + discard**: [`Mode`]
32//! (`Normal`/`Sandbox`), baked onto a `FlowLocal` at construction/fork time,
33//! and [`FlowLocal::fork`], which builds a child whose `base` is a frozen
34//! snapshot of the parent (via `freeze`) and whose own overrides start
35//! empty. `Normal` fork children route exactly like any other `FlowLocal` —
36//! by policy. `Sandbox` children are the side-effect-proof primitive
37//! watch/eval needs: `ContextView` treats **every** unit as `Local`
38//! regardless of policy, so the shared `World` is a read-only base — reads
39//! chain-read-through to `World`'s live value on a miss, but writes always
40//! land in the sandboxed flow's own overrides and never reach `World`.
41//! Discard is simply dropping the forked `FlowLocal`: since a `Sandbox`
42//! child's writes never touched `World` (and a `Normal` child's writes never
43//! touched its parent or `World` either — only its own top layer), there is
44//! nothing to unwind. A deferred `commit` seam (fold a fork's writes back
45//! into its parent) is documented but intentionally left unimplemented — see
46//! [`CommitError`] and [`commit`].
47//!
48//! No existing construction path calls `fork` or requests `Mode::Sandbox` —
49//! every flow the oracle corpus drives is `Mode::Normal` with `base: None`,
50//! so `ContextView` takes exactly the F3.1 branch on every op. The all-
51//! `World` policy (the default, and the only policy the oracle corpus
52//! exercises) takes the `World` branch on every op, so `ContextView` stays
53//! byte-identical to the F1.3 passthrough for every existing single-flow
54//! construction path.
55
56use std::collections::{BTreeMap, HashMap};
57use std::sync::Arc;
58
59use brink_format::{DefinitionId, Value};
60
61use crate::program::Program;
62use crate::rng::StoryRng;
63use crate::state::ContextAccess;
64
65// ── Policy ───────────────────────────────────────────────────────────────
66//
67// The scoped-flow-state model (`docs/scoped-flow-state-spec.md`, "The
68// policy") homes every unit of story-state — globals, visit/turn counts,
69// turn index, RNG — to either the shared `World` or a flow's private
70// `FlowLocal`. `WorldPolicy` is the host-facing, name-based declaration of
71// that split; `ResolvedPolicy` is the fast id/slot-based form the runtime
72// actually consults, built once at `World` creation.
73//
74// F2.1 introduces both shapes and resolution only. `ResolvedPolicy` is
75// stored on `World` but unread — F2.2 wires `ContextView` to consult it.
76
77/// Where a unit of story-state lives: the shared [`World`] or a flow's
78/// private [`FlowLocal`].
79///
80/// `World` is visible to every flow sharing that world immediately on
81/// write — the coordination path. `Local` is private to one flow; it
82/// persists for that flow's lifetime and only folds back into a parent via
83/// an explicit (currently unimplemented, F3) `commit`.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
85pub enum Scope {
86    /// Shared across every flow over the world. This is the default —
87    /// matches today's single-`Context` behavior byte-for-byte.
88    #[default]
89    World,
90    /// Private to one flow.
91    Local,
92}
93
94/// Host-facing, name-based declaration of the world/local split.
95///
96/// Resolved once (via [`ResolvedPolicy::resolve`]) against a linked
97/// [`Program`]'s symbol table into a fast id/slot-based [`ResolvedPolicy`].
98/// Unlisted variables and knots/stitches fall back to `default`.
99///
100/// The all-`World` default (via [`WorldPolicy::default`]) is the
101/// degenerate, oracle-safety-anchoring policy: every unit homed to
102/// `World`, no overrides — identical to today's single-flow behavior.
103///
104/// **Name precedence:** a name in `overrides` is tried as a global variable
105/// first, then as a knot/stitch path (see [`ResolvedPolicy::resolve`]). If a
106/// name is (unusually) both a declared global VAR and a resolvable knot/
107/// stitch path, the override resolves against the **variable**, never the
108/// knot — the knot path is not consulted once a variable of the same name
109/// is found.
110///
111/// **Knot/stitch overrides are subtree-inclusive** (F6.1c —
112/// `docs/scoped-flow-state-spec.md`'s F6 AMENDMENT, ruling 3): a knot
113/// override covers the knot's own visit/turn count, every stitch nested
114/// directly under it, and every interior container (weave/sequence/choice
115/// container) nested anywhere under the knot or one of its stitches — not
116/// just the knot's own `DefinitionId`. This matters because ink's
117/// sequence/cycle/stopping machinery (`{ Halt! | Back again? }`) keys its
118/// counter off the *sequence's own* interior container id, not the
119/// enclosing knot's id; without subtree inclusion, a knot marked `Local`
120/// would leave those interior counters silently `World`-scoped.
121///
122/// **Most-specific override wins.** If both a knot and one of its stitches
123/// appear in `overrides` (e.g. knot `a` is `Local`, stitch `a.b` is
124/// `World`), every interior container nested under `a.b` resolves `World`;
125/// the rest of `a`'s subtree (the knot's own id, its other stitches, and
126/// any interior container not under `a.b`) resolves `Local`. A stitch's
127/// override always wins over its enclosing knot's for the stitch's own
128/// subtree, regardless of which name appears earlier in `overrides` (see
129/// [`ResolvedPolicy::resolve`] for how this is implemented).
130#[derive(Debug, Clone, Default)]
131pub struct WorldPolicy {
132    /// Scope for any variable or knot/stitch not named in `overrides`.
133    pub default: Scope,
134    /// Per-name exceptions to `default`, for global variables (matched
135    /// against `Program::global_index`'s name grammar) and knot/stitch
136    /// paths (matched against `Program::find_path_target`'s path
137    /// grammar). A name may appear in only one of the two — the resolver
138    /// tries variables first, then knot paths. Knot/stitch overrides are
139    /// subtree-inclusive with most-specific-wins precedence — see the type
140    /// docs above.
141    pub overrides: BTreeMap<String, Scope>,
142    /// Scope of the turn index (a single scalar field).
143    pub turn_index: Scope,
144    /// Scope of the RNG stream (`rng_seed` + `previous_random`, a single
145    /// scalar stream). See the spec's determinism caveat: a `World`-scoped
146    /// RNG interleaves draws from every flow sharing the world by
147    /// execution order.
148    pub rng: Scope,
149}
150
151/// Errors resolving a [`WorldPolicy`] against a [`Program`]'s symbol table.
152///
153/// Resolution happens once, at `World` creation — an unknown name here is
154/// a host configuration error, not a runtime one.
155#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
156pub enum PolicyError {
157    /// A name in `WorldPolicy::overrides` matched neither a declared
158    /// global variable nor a resolvable knot/stitch path.
159    #[error("unknown variable or knot/stitch in world policy overrides: {0}")]
160    UnknownName(String),
161}
162
163/// Fast, id/slot-based resolution of a [`WorldPolicy`] against a specific
164/// [`Program`]. Built once at `World` creation via
165/// [`ResolvedPolicy::resolve`]; consulted on every state access (from F2.2
166/// on) with O(1) lookups — no string matching on the hot path.
167#[derive(Debug, Clone)]
168pub struct ResolvedPolicy {
169    /// Default scope for globals/knots not otherwise listed.
170    default: Scope,
171    /// Per-slot scope for every global variable, dense (length ==
172    /// `Program::global_count()`). Populated with `default` for slots with
173    /// no override, so lookups never need a fallback branch.
174    global_scopes: Vec<Scope>,
175    /// Non-default scope for a knot/stitch (or an interior weave/sequence/
176    /// choice container nested under one), keyed by its defining
177    /// `DefinitionId` (the same id `ContextAccess::visit_count` and friends
178    /// are called with — e.g. `vm.rs`'s `handle_sequence` keys a stopping/
179    /// cycle sequence's counter off the *sequence's own* interior container
180    /// id, not its enclosing knot's).
181    ///
182    /// **Subtree-inclusive (F6.1c):** an override on a knot/stitch name is
183    /// expanded at resolve time (see [`resolve`](Self::resolve)) to cover
184    /// its own id, every stitch nested directly under it (if it's a knot),
185    /// and every interior container nested anywhere under it or one of its
186    /// stitches — not just the literal `DefinitionId` the override name
187    /// resolved to. Only exceptions to `default` are stored — sparse, since
188    /// most programs have far more knots/interior containers than
189    /// overrides.
190    knot_scopes: HashMap<DefinitionId, Scope>,
191    /// Scope of the turn index.
192    turn_index: Scope,
193    /// Scope of the RNG stream.
194    rng: Scope,
195}
196
197impl ResolvedPolicy {
198    /// The all-`World` resolved policy — no name lookups needed. This is
199    /// the fast path for [`WorldPolicy::default()`] and the only policy
200    /// exercised by the oracle-anchored single-flow path.
201    #[must_use]
202    pub fn all_world() -> Self {
203        Self {
204            default: Scope::World,
205            global_scopes: Vec::new(),
206            knot_scopes: HashMap::new(),
207            turn_index: Scope::World,
208            rng: Scope::World,
209        }
210    }
211
212    /// Resolve a host-facing [`WorldPolicy`] against a linked `Program`'s
213    /// symbol table.
214    ///
215    /// Variable names are resolved via [`Program::global_index`]; knot/
216    /// stitch paths via `Program`'s path-to-`DefinitionId` resolution
217    /// (the same table `find_address`/`find_path_target` use). A name is
218    /// tried as a variable first, then as a knot/stitch path; a name
219    /// matching neither is a [`PolicyError::UnknownName`].
220    ///
221    /// **Subtree expansion (F6.1c).** Every knot/stitch override is
222    /// expanded here, once, into every `DefinitionId` in its definition
223    /// subtree — see [`expand_knot_scope`] for the containment mechanism
224    /// (`Program::scope_ids`, the nearest-enclosing-scope table every
225    /// interior container carries, plus `Program::address_by_path`'s
226    /// dotted knot/stitch grammar for the one-level knot→stitch link that
227    /// `scope_ids` alone doesn't carry). `overrides` is a `BTreeMap`, so
228    /// this loop's iteration order is deterministic (sorted by name) — and
229    /// because a knot's name is always a proper prefix of (and therefore
230    /// sorts lexicographically before) any of its stitches' names, a knot
231    /// override's subtree expansion always runs *before* a same-subtree
232    /// stitch override in this same pass, so the stitch override's own
233    /// `insert`s (which land later) win — implementing "most-specific
234    /// override wins" as a natural consequence of processing order, no
235    /// separate precedence pass needed.
236    ///
237    /// The all-`World` default (empty `overrides`) resolves without any
238    /// name lookups (see [`all_world`](Self::all_world)) — this is the
239    /// fast path every existing construction path takes today.
240    pub fn resolve(program: &Program, policy: &WorldPolicy) -> Result<Self, PolicyError> {
241        if policy.overrides.is_empty()
242            && policy.default == Scope::World
243            && policy.turn_index == Scope::World
244            && policy.rng == Scope::World
245        {
246            return Ok(Self::all_world());
247        }
248
249        let mut global_scopes = vec![policy.default; program.global_count() as usize];
250        let mut knot_scopes = HashMap::new();
251        let interior_by_scope = interior_containers_by_scope(program);
252
253        // `overrides` is a `BTreeMap`, so iteration order is deterministic
254        // (sorted by name) — resolution never depends on hash-map order.
255        for (name, &scope) in &policy.overrides {
256            if let Some(slot) = program.global_index(name) {
257                global_scopes[slot as usize] = scope;
258            } else if let Some(id) = program.find_path_target(name) {
259                expand_knot_scope(
260                    program,
261                    &interior_by_scope,
262                    &mut knot_scopes,
263                    name,
264                    id,
265                    scope,
266                );
267            } else {
268                return Err(PolicyError::UnknownName(name.clone()));
269            }
270        }
271
272        Ok(Self {
273            default: policy.default,
274            global_scopes,
275            knot_scopes,
276            turn_index: policy.turn_index,
277            rng: policy.rng,
278        })
279    }
280
281    /// Scope of a global variable by slot index.
282    #[must_use]
283    pub fn scope_of_global(&self, slot: u32) -> Scope {
284        self.global_scopes
285            .get(slot as usize)
286            .copied()
287            .unwrap_or(self.default)
288    }
289
290    /// Scope of a knot/stitch — or of an interior weave/sequence/choice
291    /// container nested under one — by its defining `DefinitionId`. See the
292    /// `knot_scopes` field docs and [`resolve`](Self::resolve) for how a
293    /// knot/stitch override is expanded, at resolve time, to cover every id
294    /// in its definition subtree.
295    #[must_use]
296    pub fn scope_of_knot(&self, id: DefinitionId) -> Scope {
297        self.knot_scopes.get(&id).copied().unwrap_or(self.default)
298    }
299
300    /// Scope of the turn index.
301    #[must_use]
302    pub fn turn_index_scope(&self) -> Scope {
303        self.turn_index
304    }
305
306    /// Scope of the RNG stream.
307    #[must_use]
308    pub fn rng_scope(&self) -> Scope {
309        self.rng
310    }
311}
312
313// ── Subtree-inclusive knot scope (F6.1c) ────────────────────────────────
314//
315// Containment mechanism, verified against a compiled `Program` (see the
316// `subtree_scope_tests` module below and the investigation in the F6.1c
317// build log — not restated here):
318//
319// - `ContainerDef::scope_id` (preserved on `Program` as the parallel
320//   `scope_ids`/`scope_table_idx` tables) gives every container — knot,
321//   stitch, root, or an anonymous interior weave/sequence/choice
322//   container, at any nesting depth — the `DefinitionId` of its *nearest
323//   enclosing* knot/stitch/root scope, correctly propagated through
324//   arbitrarily deep nesting by the codegen's recursive walk. A container
325//   is itself a scope owner (a knot, stitch, or root) exactly when its own
326//   `scope_id` equals its own id — self-scoped, no parent. This gives an
327//   exact, structural (not name-based) map from any interior container to
328//   its owning knot/stitch: `interior_containers_by_scope`, below.
329// - `scope_id` does *not* link a stitch to its enclosing knot (a stitch is
330//   self-scoped, by the same rule above) — ink only nests stitches one
331//   level under a knot, and that one link has to come from
332//   `Program::address_by_path`'s dotted qualified-path grammar (the same
333//   table `find_address`/`find_path_target` already use): a knot named `N`
334//   knows its direct stitches are exactly the `address_by_path` entries
335//   `"N.<segment>"` with no further `.` in `<segment>`, whose target is
336//   itself a scope owner (ruling out an author-labeled gather directly in
337//   the knot, which shares the same two-segment path shape but is *not*
338//   self-scoped).
339//
340// Both of these are compile-time/link-time structural facts already
341// present on `Program` — no id arithmetic or heuristic string matching on
342// unstructured names.
343
344/// Group every non-scope-owning ("interior") container's own id by the
345/// `DefinitionId` of its nearest enclosing knot/stitch/root scope.
346///
347/// Built once per non-fast-path [`ResolvedPolicy::resolve`] call — this is
348/// resolve-time bookkeeping, not a hot-path lookup (the all-`World` fast
349/// path never calls this at all).
350fn interior_containers_by_scope(program: &Program) -> HashMap<DefinitionId, Vec<DefinitionId>> {
351    let mut by_scope: HashMap<DefinitionId, Vec<DefinitionId>> = HashMap::new();
352    for (idx, container) in program.containers.iter().enumerate() {
353        #[expect(
354            clippy::cast_possible_truncation,
355            reason = "container count fits in u32"
356        )]
357        let idx = idx as u32;
358        let owner = program.scope_ids[program.scope_table_idx(idx) as usize];
359        if owner != container.id {
360            by_scope.entry(owner).or_default().push(container.id);
361        }
362    }
363    by_scope
364}
365
366/// Apply `scope` to `id` and every interior container `interior_by_scope`
367/// says is nested directly under it (i.e. `id`'s own subtree, one scope
368/// level — not a recursive walk, since ink knots/stitches never nest
369/// containers whose `scope_id` chain needs more than one enclosing-scope
370/// hop to resolve; see `interior_containers_by_scope`).
371fn apply_scope_to_subtree(
372    interior_by_scope: &HashMap<DefinitionId, Vec<DefinitionId>>,
373    knot_scopes: &mut HashMap<DefinitionId, Scope>,
374    id: DefinitionId,
375    scope: Scope,
376) {
377    knot_scopes.insert(id, scope);
378    if let Some(interior) = interior_by_scope.get(&id) {
379        for &child_id in interior {
380            knot_scopes.insert(child_id, scope);
381        }
382    }
383}
384
385/// Expand a single `WorldPolicy::overrides` knot/stitch entry (`name` →
386/// `id`, already resolved via `Program::find_path_target`) into every
387/// `DefinitionId` in its definition subtree, writing `scope` for each into
388/// `knot_scopes`.
389///
390/// Covers: `id`'s own subtree (its own id plus its direct interior
391/// containers), then — since a stitch override's own call to this function
392/// already covers everything a stitch can own, and ink never nests a
393/// stitch under another stitch — cascades once to `name`'s direct child
394/// stitches (found via `address_by_path`'s dotted grammar, see the module
395/// docs above) and covers each of *their* subtrees too. A child stitch
396/// that itself has a more specific override is still safe to cascade into
397/// here: `resolve`'s `BTreeMap` iteration order guarantees the stitch's own
398/// (later, more specific) entry is processed after `name`'s and overwrites
399/// whatever this cascade wrote — see `resolve`'s docs.
400fn expand_knot_scope(
401    program: &Program,
402    interior_by_scope: &HashMap<DefinitionId, Vec<DefinitionId>>,
403    knot_scopes: &mut HashMap<DefinitionId, Scope>,
404    name: &str,
405    id: DefinitionId,
406    scope: Scope,
407) {
408    apply_scope_to_subtree(interior_by_scope, knot_scopes, id, scope);
409
410    let prefix = format!("{name}.");
411    for (path, target) in &program.address_by_path {
412        let Some(rest) = path.strip_prefix(prefix.as_str()) else {
413            continue;
414        };
415        if rest.is_empty() || rest.contains('.') {
416            continue; // Not a direct one-segment child of `name`.
417        }
418        if target.byte_offset != 0 {
419            continue; // Not a container's own primary address.
420        }
421        // Confirm the target is itself a scope-owning container (a real
422        // stitch), not an author-labeled gather directly in the knot that
423        // happens to share the same two-segment path shape.
424        let owner = program.scope_ids[program.scope_table_idx(target.container_idx) as usize];
425        if owner != target.id {
426            continue;
427        }
428        apply_scope_to_subtree(interior_by_scope, knot_scopes, target.id, scope);
429    }
430}
431
432/// Shared game state that lives above individual flows.
433///
434/// Holds globals, visit/turn tracking, and RNG state. This is the natural
435/// serialization boundary for save/load (deferred).
436///
437/// Multiple [`FlowInstance`](crate::FlowInstance)s can share a single
438/// `World` (matching inklecate's semantics where flow writes are
439/// immediately visible to other flows), or each flow can hold its own
440/// cloned `World` if the consumer wants fork/branch/rollback semantics.
441/// The runtime's step functions take `&mut World` (or any
442/// `&mut impl ContextAccess`) without prescribing where it lives.
443#[derive(Debug, Clone)]
444pub struct World {
445    pub globals: Vec<Value>,
446    pub visit_counts: HashMap<DefinitionId, u32>,
447    pub turn_counts: HashMap<DefinitionId, u32>,
448    pub turn_index: u32,
449    pub rng_seed: i32,
450    pub previous_random: i32,
451    /// The resolved world/local scoping policy for this world.
452    ///
453    /// Boxed so `World` (cloned per-flow-spawn and stored inline in several
454    /// call sites and enums across the crate graph) doesn't balloon in size
455    /// for consumers that never touch policy — `ResolvedPolicy` carries a
456    /// per-slot `Vec` and a `HashMap` that dwarf `World`'s other fields.
457    ///
458    /// **Consulted by [`ContextView`] (F2.2 on)** to route every
459    /// [`ContextAccess`] op between `World` and `FlowLocal`. Every
460    /// construction path that predates policy (`World::from_globals`,
461    /// `FlowInstance::new_at*`, `Story::new`) resolves
462    /// [`WorldPolicy::default()`] (all-`World`), so those paths route every
463    /// op straight to `World` — unchanged from F1.3.
464    policy: Box<ResolvedPolicy>,
465}
466
467impl World {
468    /// Create a fresh `World` for `program`, resolving `policy` against the
469    /// program's symbol table.
470    ///
471    /// Globals are initialized from the program's declared defaults; visit
472    /// counts, turn counts, turn index, and RNG state start zeroed —
473    /// identical to `FlowInstance::new_at`'s inline construction. Fails if
474    /// `policy` names a variable or knot/stitch the program doesn't
475    /// declare ([`PolicyError::UnknownName`]).
476    ///
477    /// [`WorldPolicy::default()`] (all-`World`) always resolves — see
478    /// [`ResolvedPolicy::all_world`] — so passing it here can't produce a
479    /// `PolicyError`.
480    pub fn new(program: &Program, policy: &WorldPolicy) -> Result<Self, PolicyError> {
481        let resolved = ResolvedPolicy::resolve(program, policy)?;
482        Ok(Self::from_globals(program.global_defaults(), resolved))
483    }
484
485    /// Build a `World` from an explicit globals vector and an
486    /// already-resolved policy. Used by [`crate::FlowInstance::new_at`] (whose
487    /// signature predates policy and can't take a `Result`) to construct
488    /// the all-`World` default without re-deriving it from a `Program` each
489    /// time.
490    pub(crate) fn from_globals(globals: Vec<Value>, policy: ResolvedPolicy) -> Self {
491        Self {
492            globals,
493            visit_counts: HashMap::new(),
494            turn_counts: HashMap::new(),
495            turn_index: 0,
496            rng_seed: 0,
497            previous_random: 0,
498            policy: Box::new(policy),
499        }
500    }
501
502    /// Construct a `World` directly from its field values, with the
503    /// all-`World` policy. Only for test fixtures that need to hand-build a
504    /// `World` without a `Program` (e.g. `bevy-brink`'s commit-merge
505    /// tests) — production code should go through [`World::new`].
506    #[cfg(feature = "testing")]
507    #[must_use]
508    pub fn new_for_testing(
509        globals: Vec<Value>,
510        visit_counts: HashMap<DefinitionId, u32>,
511        turn_counts: HashMap<DefinitionId, u32>,
512        turn_index: u32,
513        rng_seed: i32,
514        previous_random: i32,
515    ) -> Self {
516        Self {
517            globals,
518            visit_counts,
519            turn_counts,
520            turn_index,
521            rng_seed,
522            previous_random,
523            policy: Box::new(ResolvedPolicy::all_world()),
524        }
525    }
526}
527
528/// A flow-local override of the shared RNG stream (`rng_seed` +
529/// `previous_random`), the two scalars [`WorldPolicy::rng`] scopes as a
530/// single unit.
531#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
532pub struct LocalRng {
533    pub seed: i32,
534    pub previous_random: i32,
535}
536
537/// An immutable snapshot of a [`FlowLocal`]'s override layer, frozen via
538/// [`FlowLocal::freeze`].
539///
540/// `FrozenLocal` chains: its own `base` is whatever the source `FlowLocal`'s
541/// base was at freeze time, so a chain of forks ([`FlowLocal::fork`]) can
542/// walk arbitrarily far back through frozen ancestors. Cloning a
543/// `FrozenLocal` reference is cheap — callers hold it behind an [`Arc`].
544#[derive(Debug, Clone, Default)]
545pub struct FrozenLocal {
546    /// Overridden values for globals `ResolvedPolicy` homes to `Local`,
547    /// keyed by slot index.
548    globals: BTreeMap<u32, Value>,
549    /// Overridden visit counts for knots/stitches homed to `Local`.
550    visit_counts: BTreeMap<DefinitionId, u32>,
551    /// Overridden turn counts for knots homed to `Local`.
552    turn_counts: BTreeMap<DefinitionId, u32>,
553    /// Overridden turn index, when `turn_index_scope() == Local`.
554    turn_index: Option<u32>,
555    /// Overridden RNG stream, when `rng_scope() == Local`.
556    rng: Option<LocalRng>,
557    /// The next link in the chain, if this snapshot's source `FlowLocal`
558    /// itself had a base at freeze time.
559    base: Option<Arc<FrozenLocal>>,
560}
561
562impl FrozenLocal {
563    /// Chain-lookup a global override: this snapshot's own overrides, else
564    /// recurse into `base`. Returns `None` on a miss all the way down the
565    /// chain — the caller falls through to `World`.
566    fn chain_get_global(&self, idx: u32) -> Option<&Value> {
567        self.globals
568            .get(&idx)
569            .or_else(|| self.base.as_deref().and_then(|b| b.chain_get_global(idx)))
570    }
571
572    /// Chain-lookup a visit-count override.
573    fn chain_get_visit_count(&self, id: DefinitionId) -> Option<u32> {
574        self.visit_counts.get(&id).copied().or_else(|| {
575            self.base
576                .as_deref()
577                .and_then(|b| b.chain_get_visit_count(id))
578        })
579    }
580
581    /// Chain-lookup a turn-count override.
582    fn chain_get_turn_count(&self, id: DefinitionId) -> Option<u32> {
583        self.turn_counts.get(&id).copied().or_else(|| {
584            self.base
585                .as_deref()
586                .and_then(|b| b.chain_get_turn_count(id))
587        })
588    }
589
590    /// Chain-lookup the overridden turn index.
591    fn chain_get_turn_index(&self) -> Option<u32> {
592        self.turn_index.or_else(|| {
593            self.base
594                .as_deref()
595                .and_then(FrozenLocal::chain_get_turn_index)
596        })
597    }
598
599    /// Chain-lookup the overridden RNG stream.
600    fn chain_get_rng(&self) -> Option<LocalRng> {
601        self.rng
602            .or_else(|| self.base.as_deref().and_then(FrozenLocal::chain_get_rng))
603    }
604}
605
606/// Execution mode of a [`FlowLocal`], baked in at construction/fork time and
607/// read by [`ContextView`] to decide how it routes every unit.
608///
609/// `Mode` is orthogonal to [`WorldPolicy`]/[`ResolvedPolicy`]: policy homes a
610/// *unit* (a global, a knot's visit count, …) to `World` or `Local`; `Mode`
611/// decides, for *this flow*, whether that homing is honored at all.
612#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
613pub enum Mode {
614    /// Route every unit by policy, exactly as [`ContextView`]'s F2.2/F3.1
615    /// docs describe: `World`-scoped units go straight to `World`,
616    /// `Local`-scoped units chain-read-through/write to `FlowLocal`. Every
617    /// construction path before F3.2 produces `Mode::Normal` — this is what
618    /// keeps the oracle corpus byte-identical.
619    #[default]
620    Normal,
621    /// Treat **every** unit as `Local`, regardless of policy: the shared
622    /// `World` becomes a read-only base for this flow. Reads still
623    /// chain-read-through to `World`'s current value on a total miss (so a
624    /// sandboxed flow sees live world state), but writes always land in
625    /// this flow's own top-layer overrides — `World` (and any `Normal`
626    /// ancestor) is never mutated. Combined with [`FlowLocal::fork`]'s
627    /// frozen base, this is the side-effect-proof primitive watch/eval
628    /// needs: run a flow against current state, observe its output, then
629    /// discard it (drop) with the shared world untouched.
630    Sandbox,
631}
632
633/// Per-flow override layer over the shared [`World`].
634///
635/// **F3.1: copy-on-write, frozen-base read-through chain.** Each field is a
636/// plain map/option holding this flow's own overrides for units
637/// [`ResolvedPolicy`] homes to [`Scope::Local`] (or, in [`Mode::Sandbox`],
638/// *every* unit — see [`Mode`]), plus an optional `base`: an immutable
639/// [`FrozenLocal`] snapshot (see [`FlowLocal::freeze`]) of another
640/// `FlowLocal`, captured at some earlier point. A read walks **own
641/// overrides → base (recursively) → [miss]**; [`ContextView`] treats a miss
642/// as "not in the local chain" and falls through to `World`, exactly as in
643/// F2.2. Writes always land in the flow's own top-layer overrides — never
644/// in `base`, which is immutable by construction.
645///
646/// A fresh `FlowLocal` (via `Default`/[`FlowLocal::new`]) has empty
647/// overrides, `base: None`, and `mode: Mode::Normal`, so it contributes no
648/// reads and every access falls through to `World` — this is what keeps the
649/// all-`World` policy (and every construction path that doesn't call
650/// [`fork`](Self::fork)) byte-identical to the F2.2 flat-storage behavior.
651/// [`FlowLocal::fork`] (F3.2) is what actually populates a child's `base` by
652/// freezing its parent, and what bakes in a non-`Normal` `mode`.
653///
654/// [`ContextView`] (below) is what actually consults these maps; see its
655/// docs for the read-through/copy-on-write-increment/mode semantics.
656#[derive(Debug, Clone, Default)]
657pub struct FlowLocal {
658    /// Overridden values for globals `ResolvedPolicy` homes to `Local`,
659    /// keyed by slot index.
660    globals: BTreeMap<u32, Value>,
661    /// Overridden visit counts for knots/stitches homed to `Local`.
662    visit_counts: BTreeMap<DefinitionId, u32>,
663    /// Overridden turn counts for knots homed to `Local`.
664    turn_counts: BTreeMap<DefinitionId, u32>,
665    /// Overridden turn index, when `turn_index_scope() == Local`.
666    turn_index: Option<u32>,
667    /// Overridden RNG stream, when `rng_scope() == Local`.
668    rng: Option<LocalRng>,
669    /// Frozen snapshot of an earlier `FlowLocal`'s overrides, read *after*
670    /// this layer's own overrides on a miss. Populated by [`FlowLocal::fork`];
671    /// `None` for every construction path that doesn't fork.
672    base: Option<Arc<FrozenLocal>>,
673    /// This flow's execution mode — see [`Mode`]. Baked in at construction
674    /// (`Mode::Normal`, the `Default`) or at [`FlowLocal::fork`] time.
675    mode: Mode,
676}
677
678impl FlowLocal {
679    /// Construct an empty flow-local layer — overrides nothing and has no
680    /// base, so every access routes through to `World`.
681    #[must_use]
682    pub fn new() -> Self {
683        Self::default()
684    }
685
686    /// Freeze this `FlowLocal`'s current state into an immutable
687    /// [`FrozenLocal`] snapshot, suitable for use as another `FlowLocal`'s
688    /// `base`.
689    ///
690    /// Captures the override maps (cloned — cheap, since only `Local`-scoped
691    /// units are ever present) and cheap-clones the current `base` `Arc` so
692    /// the new snapshot chains to the same ancestry this `FlowLocal` had.
693    ///
694    /// Called by [`FlowLocal::fork`] to snapshot a parent into a child's
695    /// `base`.
696    #[must_use]
697    fn freeze(&self) -> Arc<FrozenLocal> {
698        Arc::new(FrozenLocal {
699            globals: self.globals.clone(),
700            visit_counts: self.visit_counts.clone(),
701            turn_counts: self.turn_counts.clone(),
702            turn_index: self.turn_index,
703            rng: self.rng,
704            base: self.base.clone(),
705        })
706    }
707
708    /// Fork a child `FlowLocal` from this one.
709    ///
710    /// The child's `base` is a frozen, point-in-time snapshot of `self` (via
711    /// [`freeze`](Self::freeze)) — an `O(1)`-ish operation that clones this
712    /// flow's own (small) override maps and `Arc`-bumps the rest of the
713    /// ancestry chain, never a full `World` copy. The child's own override
714    /// layer starts empty, and it runs in `mode` for its lifetime (`Mode` is
715    /// baked in here, not mutable afterward).
716    ///
717    /// Because the base is frozen, later mutations to `self` (the parent)
718    /// are **not** visible to the child — the child sees the parent exactly
719    /// as it was at fork time. Symmetrically, nothing the child does is ever
720    /// visible to `self` or `World`: writes land only in the child's own top
721    /// layer (see [`Mode`] for how `Sandbox` additionally diverts
722    /// `World`-scoped writes there too). That makes **discard** trivial —
723    /// dropping the returned `FlowLocal` is the entire discard operation, no
724    /// unwinding required. Folding a child's writes back into `self` instead
725    /// of discarding them is the deferred `commit` seam — see [`CommitError`]
726    /// and [`commit`].
727    #[must_use]
728    pub fn fork(&self, mode: Mode) -> FlowLocal {
729        FlowLocal {
730            base: Some(self.freeze()),
731            mode,
732            ..FlowLocal::new()
733        }
734    }
735
736    /// Chain-lookup a global override: own overrides → `base` (recursively)
737    /// → `None` on a total miss, which [`ContextView`] treats as "fall
738    /// through to `World`".
739    fn chain_get_global(&self, idx: u32) -> Option<&Value> {
740        self.globals
741            .get(&idx)
742            .or_else(|| self.base.as_deref().and_then(|b| b.chain_get_global(idx)))
743    }
744
745    /// Chain-lookup a visit-count override.
746    fn chain_get_visit_count(&self, id: DefinitionId) -> Option<u32> {
747        self.visit_counts.get(&id).copied().or_else(|| {
748            self.base
749                .as_deref()
750                .and_then(|b| b.chain_get_visit_count(id))
751        })
752    }
753
754    /// Chain-lookup a turn-count override.
755    fn chain_get_turn_count(&self, id: DefinitionId) -> Option<u32> {
756        self.turn_counts.get(&id).copied().or_else(|| {
757            self.base
758                .as_deref()
759                .and_then(|b| b.chain_get_turn_count(id))
760        })
761    }
762
763    /// Chain-lookup the overridden turn index.
764    fn chain_get_turn_index(&self) -> Option<u32> {
765        self.turn_index.or_else(|| {
766            self.base
767                .as_deref()
768                .and_then(FrozenLocal::chain_get_turn_index)
769        })
770    }
771
772    /// Chain-lookup the overridden RNG stream.
773    fn chain_get_rng(&self) -> Option<LocalRng> {
774        self.rng
775            .or_else(|| self.base.as_deref().and_then(FrozenLocal::chain_get_rng))
776    }
777}
778
779/// Errors from [`commit`].
780///
781/// A single variant today: `commit` is a documented, deferred seam (see
782/// `docs/scoped-flow-state-spec.md`, "Write-back is determined by scope, not
783/// a separate knob") — this release ships fork + discard only.
784#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
785pub enum CommitError {
786    /// Folding a forked child's own-layer overrides back into its parent is
787    /// deferred past this release. Fork's only supported terminal operation
788    /// today is **discard** (drop the child); `commit` always returns this.
789    #[error(
790        "commit is not implemented in this release; fork's only supported terminal operation is discard (drop the child)"
791    )]
792    NotImplemented,
793}
794
795/// Fold a forked child's own-layer overrides back into its parent's,
796/// making the child's writes visible through `parent` (and, transitively,
797/// anything `parent` itself chains to or is later written through).
798///
799/// **This is a deferred seam, not implemented in this release.** It exists
800/// so the shape of the eventual write-back API is fixed and callers can
801/// write code against it now (always getting `CommitError::NotImplemented`
802/// back) rather than the API appearing later with no forward-compatible
803/// slot. Per the spec, only a **fork** ever commits — a root flow has no
804/// parent to fold into, and its `Local`-scoped writes already persist for
805/// its own lifetime; `World`-scoped writes already escape live, with no
806/// "make it back" step needed.
807///
808/// Intended semantics, when implemented: walk `child`'s own top-layer
809/// overrides (globals, visit counts, turn counts, turn index, RNG) and
810/// apply each onto `parent`'s own top layer, as if `child`'s writes had
811/// been made directly against `parent` — last-write-wins per unit, since a
812/// fork is single-writer for its whole lifetime (no concurrent-write
813/// conflict is possible, so no merge policy is needed). Commit never
814/// touches `World` directly: a folded `Local`-scoped write still only
815/// lands in `parent`'s overrides, reaching `World` only if `parent` is
816/// itself later written through a `World`-scoped op or committed further up
817/// the chain. `Sandbox`-mode writes are exactly what this would fold
818/// back — commit is what would turn a sandboxed probe into a real,
819/// persisted mutation, for a caller that chooses to call it instead of
820/// dropping the child.
821///
822/// # Errors
823///
824/// Always returns [`CommitError::NotImplemented`] in this release.
825pub fn commit(_child: FlowLocal, _parent: &mut FlowLocal) -> Result<(), CommitError> {
826    Err(CommitError::NotImplemented)
827}
828
829/// Routing view implementing [`ContextAccess`] over `(&mut World, &mut
830/// FlowLocal)`.
831///
832/// This is what the VM's drive path receives as its `impl ContextAccess`.
833/// Every op computes an **effective scope** for its unit — see
834/// [`ContextView::effective_scope`] — and then routes exactly as before:
835///
836/// - **`World`-scoped**: always routes straight to `World` — reads and
837///   writes are immediately visible to every flow sharing that `World`.
838/// - **`Local`-scoped, read**: **chain read-through** — walks the
839///   `FlowLocal`'s own overrides, then its frozen `base` (recursively, see
840///   [`FlowLocal::chain_get_global`] and friends), then falls back to
841///   `World`'s current value on a total miss (so a flow that has never
842///   written a Local unit, nor inherited one from a base, sees the shared
843///   default until its first local write).
844/// - **`Local`-scoped, write**: lands in the `FlowLocal`'s own top-layer
845///   overrides only; `World` (and any frozen `base`) is untouched.
846/// - **`Local`-scoped, increment** (`increment_visit`,
847///   `increment_turn_index`): copy-on-write from the *chain read-through*
848///   value — read the current value (own override, else base chain, else
849///   World fallback), add one, store the result as the new top-layer
850///   override. This is what makes a flow's first local increment start
851///   from the chain's (or World's) count rather than 0.
852///
853/// **The effective scope, not the raw policy scope, drives all of the
854/// above.** In [`Mode::Normal`] (every construction path before F3.2, and
855/// every non-forked flow today) the effective scope of a unit *is* its
856/// `ResolvedPolicy` scope — unchanged from F2.2/F3.1. In [`Mode::Sandbox`]
857/// the effective scope of **every** unit is `Local`, no matter what the
858/// policy says: a sandboxed flow's reads still chain-read-through to
859/// `World`'s live value on a miss (so it observes current shared state),
860/// but its writes — including to units the policy homes to `World` — land
861/// only in its own `FlowLocal` overrides. `World` is therefore a read-only
862/// base from a sandboxed flow's perspective: nothing it does can mutate the
863/// shared world.
864///
865/// Because the all-`World` policy (the only policy the oracle corpus
866/// exercises) takes the `World` branch on every op *and* no existing
867/// construction path ever sets `Mode::Sandbox`, this is byte-identical to
868/// the F1.3 all-`World` passthrough for every existing single-flow
869/// construction path.
870pub struct ContextView<'a> {
871    world: &'a mut World,
872    local: &'a mut FlowLocal,
873}
874
875impl<'a> ContextView<'a> {
876    /// Build a routing view over a `World` and `FlowLocal` pair for the
877    /// duration of one step.
878    pub fn new(world: &'a mut World, local: &'a mut FlowLocal) -> Self {
879        Self { world, local }
880    }
881
882    /// The scope a unit actually routes by: `policy_scope` in
883    /// [`Mode::Normal`], or unconditionally [`Scope::Local`] in
884    /// [`Mode::Sandbox`] — see the type docs above.
885    #[inline]
886    fn effective_scope(&self, policy_scope: Scope) -> Scope {
887        match self.local.mode {
888            Mode::Normal => policy_scope,
889            Mode::Sandbox => Scope::Local,
890        }
891    }
892}
893
894impl ContextAccess for World {
895    #[inline]
896    fn global(&self, idx: u32) -> &Value {
897        &self.globals[idx as usize]
898    }
899
900    #[inline]
901    fn set_global(&mut self, idx: u32, value: Value) {
902        self.globals[idx as usize] = value;
903    }
904
905    #[inline]
906    fn visit_count(&self, id: DefinitionId) -> u32 {
907        self.visit_counts.get(&id).copied().unwrap_or(0)
908    }
909
910    #[inline]
911    fn increment_visit(&mut self, id: DefinitionId) {
912        *self.visit_counts.entry(id).or_insert(0) += 1;
913    }
914
915    #[inline]
916    fn set_visit_count(&mut self, id: DefinitionId, count: u32) {
917        self.visit_counts.insert(id, count);
918    }
919
920    #[inline]
921    fn turn_count(&self, id: DefinitionId) -> Option<u32> {
922        self.turn_counts.get(&id).copied()
923    }
924
925    #[inline]
926    fn set_turn_count(&mut self, id: DefinitionId, turn: u32) {
927        self.turn_counts.insert(id, turn);
928    }
929
930    #[inline]
931    fn turn_index(&self) -> u32 {
932        self.turn_index
933    }
934
935    #[inline]
936    fn increment_turn_index(&mut self) {
937        self.turn_index += 1;
938    }
939
940    #[inline]
941    fn set_turn_index(&mut self, index: u32) {
942        self.turn_index = index;
943    }
944
945    #[inline]
946    fn rng_seed(&self) -> i32 {
947        self.rng_seed
948    }
949
950    #[inline]
951    fn set_rng_seed(&mut self, seed: i32) {
952        self.rng_seed = seed;
953    }
954
955    #[inline]
956    fn previous_random(&self) -> i32 {
957        self.previous_random
958    }
959
960    #[inline]
961    fn set_previous_random(&mut self, val: i32) {
962        self.previous_random = val;
963    }
964
965    #[inline]
966    fn next_random<R: StoryRng>(&self, seed: i32) -> i32 {
967        let mut rng = R::from_seed(seed);
968        rng.next_int()
969    }
970
971    fn random_sequence<R: StoryRng>(&self, seed: i32, count: usize) -> Vec<i32> {
972        let mut rng = R::from_seed(seed);
973        (0..count).map(|_| rng.next_int()).collect()
974    }
975}
976
977impl ContextAccess for ContextView<'_> {
978    #[inline]
979    fn global(&self, idx: u32) -> &Value {
980        match self.effective_scope(self.world.policy.scope_of_global(idx)) {
981            Scope::Local => self
982                .local
983                .chain_get_global(idx)
984                .unwrap_or_else(|| self.world.global(idx)),
985            Scope::World => self.world.global(idx),
986        }
987    }
988
989    #[inline]
990    fn set_global(&mut self, idx: u32, value: Value) {
991        match self.effective_scope(self.world.policy.scope_of_global(idx)) {
992            Scope::Local => {
993                self.local.globals.insert(idx, value);
994            }
995            Scope::World => self.world.set_global(idx, value),
996        }
997    }
998
999    #[inline]
1000    fn visit_count(&self, id: DefinitionId) -> u32 {
1001        match self.effective_scope(self.world.policy.scope_of_knot(id)) {
1002            Scope::Local => self
1003                .local
1004                .chain_get_visit_count(id)
1005                .unwrap_or_else(|| self.world.visit_count(id)),
1006            Scope::World => self.world.visit_count(id),
1007        }
1008    }
1009
1010    #[inline]
1011    fn increment_visit(&mut self, id: DefinitionId) {
1012        match self.effective_scope(self.world.policy.scope_of_knot(id)) {
1013            Scope::Local => {
1014                let base = self.visit_count(id);
1015                self.local.visit_counts.insert(id, base + 1);
1016            }
1017            Scope::World => self.world.increment_visit(id),
1018        }
1019    }
1020
1021    #[inline]
1022    fn set_visit_count(&mut self, id: DefinitionId, count: u32) {
1023        match self.effective_scope(self.world.policy.scope_of_knot(id)) {
1024            Scope::Local => {
1025                self.local.visit_counts.insert(id, count);
1026            }
1027            Scope::World => self.world.set_visit_count(id, count),
1028        }
1029    }
1030
1031    #[inline]
1032    fn turn_count(&self, id: DefinitionId) -> Option<u32> {
1033        match self.effective_scope(self.world.policy.scope_of_knot(id)) {
1034            Scope::Local => self
1035                .local
1036                .chain_get_turn_count(id)
1037                .or_else(|| self.world.turn_count(id)),
1038            Scope::World => self.world.turn_count(id),
1039        }
1040    }
1041
1042    #[inline]
1043    fn set_turn_count(&mut self, id: DefinitionId, turn: u32) {
1044        match self.effective_scope(self.world.policy.scope_of_knot(id)) {
1045            Scope::Local => {
1046                self.local.turn_counts.insert(id, turn);
1047            }
1048            Scope::World => self.world.set_turn_count(id, turn),
1049        }
1050    }
1051
1052    #[inline]
1053    fn turn_index(&self) -> u32 {
1054        match self.effective_scope(self.world.policy.turn_index_scope()) {
1055            Scope::Local => self
1056                .local
1057                .chain_get_turn_index()
1058                .unwrap_or_else(|| self.world.turn_index()),
1059            Scope::World => self.world.turn_index(),
1060        }
1061    }
1062
1063    #[inline]
1064    fn increment_turn_index(&mut self) {
1065        match self.effective_scope(self.world.policy.turn_index_scope()) {
1066            Scope::Local => {
1067                let base = self.turn_index();
1068                self.local.turn_index = Some(base + 1);
1069            }
1070            Scope::World => self.world.increment_turn_index(),
1071        }
1072    }
1073
1074    #[inline]
1075    fn set_turn_index(&mut self, index: u32) {
1076        match self.effective_scope(self.world.policy.turn_index_scope()) {
1077            Scope::Local => {
1078                self.local.turn_index = Some(index);
1079            }
1080            Scope::World => self.world.set_turn_index(index),
1081        }
1082    }
1083
1084    #[inline]
1085    fn rng_seed(&self) -> i32 {
1086        match self.effective_scope(self.world.policy.rng_scope()) {
1087            Scope::Local => self
1088                .local
1089                .chain_get_rng()
1090                .map_or_else(|| self.world.rng_seed(), |rng| rng.seed),
1091            Scope::World => self.world.rng_seed(),
1092        }
1093    }
1094
1095    #[inline]
1096    fn set_rng_seed(&mut self, seed: i32) {
1097        match self.effective_scope(self.world.policy.rng_scope()) {
1098            Scope::Local => {
1099                // CoW from the chain read-through value: seed a fresh local
1100                // override from the base chain's RNG if present, else World.
1101                // (base is always None in F3.1, so this reduces to World.)
1102                let fallback = self.local.chain_get_rng().unwrap_or(LocalRng {
1103                    seed: self.world.rng_seed(),
1104                    previous_random: self.world.previous_random(),
1105                });
1106                let rng = self.local.rng.get_or_insert(fallback);
1107                rng.seed = seed;
1108            }
1109            Scope::World => self.world.set_rng_seed(seed),
1110        }
1111    }
1112
1113    #[inline]
1114    fn previous_random(&self) -> i32 {
1115        match self.effective_scope(self.world.policy.rng_scope()) {
1116            Scope::Local => self
1117                .local
1118                .chain_get_rng()
1119                .map_or_else(|| self.world.previous_random(), |rng| rng.previous_random),
1120            Scope::World => self.world.previous_random(),
1121        }
1122    }
1123
1124    #[inline]
1125    fn set_previous_random(&mut self, val: i32) {
1126        match self.effective_scope(self.world.policy.rng_scope()) {
1127            Scope::Local => {
1128                // CoW from the chain read-through value (see `set_rng_seed`).
1129                let fallback = self.local.chain_get_rng().unwrap_or(LocalRng {
1130                    seed: self.world.rng_seed(),
1131                    previous_random: self.world.previous_random(),
1132                });
1133                let rng = self.local.rng.get_or_insert(fallback);
1134                rng.previous_random = val;
1135            }
1136            Scope::World => self.world.set_previous_random(val),
1137        }
1138    }
1139
1140    #[inline]
1141    fn next_random<R: StoryRng>(&self, seed: i32) -> i32 {
1142        // Pure function of the explicit `seed` argument — not routed
1143        // state, so this delegates to `World`'s implementation unchanged.
1144        // The routed `rng_seed()` above is what call sites read to obtain
1145        // `seed` in the first place.
1146        self.world.next_random::<R>(seed)
1147    }
1148
1149    fn random_sequence<R: StoryRng>(&self, seed: i32, count: usize) -> Vec<i32> {
1150        self.world.random_sequence::<R>(seed, count)
1151    }
1152}
1153
1154#[cfg(test)]
1155mod policy_tests {
1156    use super::*;
1157    use crate::link;
1158
1159    /// Compile a small ink story with the brink compiler and link it, for
1160    /// resolving policies against a real `Program` symbol table.
1161    fn compile(src: &str) -> Program {
1162        let out = brink_compiler::compile("t.ink", |p| {
1163            if p == "t.ink" {
1164                Ok(src.to_string())
1165            } else {
1166                Err(std::io::Error::new(
1167                    std::io::ErrorKind::NotFound,
1168                    "no such include",
1169                ))
1170            }
1171        })
1172        .expect("compile");
1173        let (program, _line_tables) = link(&out.data).expect("link");
1174        program
1175    }
1176
1177    fn sample_program() -> Program {
1178        compile(
1179            "VAR gold = 0\n\
1180             VAR mood = 0\n\
1181             -> shrine\n\
1182             === shrine ===\n\
1183             At the shrine.\n\
1184             -> END\n\
1185             === cellar ===\n\
1186             In the cellar.\n\
1187             -> END\n",
1188        )
1189    }
1190
1191    /// The default `WorldPolicy` (all-`World`) must resolve via the fast
1192    /// path — no name lookups — and every scope must read back as `World`.
1193    #[test]
1194    fn all_world_default_resolves_via_fast_path() {
1195        let program = sample_program();
1196        let policy = WorldPolicy::default();
1197
1198        let resolved = ResolvedPolicy::resolve(&program, &policy).expect("resolves");
1199
1200        // Fast path: no per-slot table populated.
1201        assert!(resolved.global_scopes.is_empty());
1202        assert!(resolved.knot_scopes.is_empty());
1203
1204        let gold_slot = program.global_index("gold").expect("gold declared");
1205        let mood_slot = program.global_index("mood").expect("mood declared");
1206        let shrine_id = program.find_path_target("shrine").expect("shrine exists");
1207        let cellar_id = program.find_path_target("cellar").expect("cellar exists");
1208
1209        assert_eq!(resolved.scope_of_global(gold_slot), Scope::World);
1210        assert_eq!(resolved.scope_of_global(mood_slot), Scope::World);
1211        assert_eq!(resolved.scope_of_knot(shrine_id), Scope::World);
1212        assert_eq!(resolved.scope_of_knot(cellar_id), Scope::World);
1213        assert_eq!(resolved.turn_index_scope(), Scope::World);
1214        assert_eq!(resolved.rng_scope(), Scope::World);
1215    }
1216
1217    /// A policy with `default: Local` plus explicit variable and knot
1218    /// overrides resolves each override to its named scope and leaves
1219    /// everything else at the default.
1220    #[test]
1221    fn resolves_valid_variable_and_knot_overrides() {
1222        let program = sample_program();
1223        let mut overrides = BTreeMap::new();
1224        overrides.insert("gold".to_owned(), Scope::World);
1225        overrides.insert("shrine".to_owned(), Scope::World);
1226        let policy = WorldPolicy {
1227            default: Scope::Local,
1228            overrides,
1229            turn_index: Scope::Local,
1230            rng: Scope::Local,
1231        };
1232
1233        let resolved = ResolvedPolicy::resolve(&program, &policy).expect("resolves");
1234
1235        let gold_slot = program.global_index("gold").expect("gold declared");
1236        let mood_slot = program.global_index("mood").expect("mood declared");
1237        let shrine_id = program.find_path_target("shrine").expect("shrine exists");
1238        let cellar_id = program.find_path_target("cellar").expect("cellar exists");
1239
1240        // Named overrides win.
1241        assert_eq!(resolved.scope_of_global(gold_slot), Scope::World);
1242        assert_eq!(resolved.scope_of_knot(shrine_id), Scope::World);
1243
1244        // Unlisted variable/knot fall back to `default`.
1245        assert_eq!(resolved.scope_of_global(mood_slot), Scope::Local);
1246        assert_eq!(resolved.scope_of_knot(cellar_id), Scope::Local);
1247
1248        // Scalars resolve independently of `overrides`.
1249        assert_eq!(resolved.turn_index_scope(), Scope::Local);
1250        assert_eq!(resolved.rng_scope(), Scope::Local);
1251    }
1252
1253    /// A name in `overrides` that matches neither a declared variable nor a
1254    /// resolvable knot/stitch path is a `PolicyError`, not a silent
1255    /// fallback to `default`.
1256    #[test]
1257    fn unknown_override_name_is_an_error() {
1258        let program = sample_program();
1259        let mut overrides = BTreeMap::new();
1260        overrides.insert("not_a_real_name".to_owned(), Scope::World);
1261        let policy = WorldPolicy {
1262            default: Scope::World,
1263            overrides,
1264            turn_index: Scope::World,
1265            rng: Scope::World,
1266        };
1267
1268        let err = ResolvedPolicy::resolve(&program, &policy).expect_err("must fail");
1269        assert_eq!(err, PolicyError::UnknownName("not_a_real_name".to_owned()));
1270    }
1271
1272    /// `World::new` resolves the policy and constructs a `World` whose
1273    /// globals match the program's declared defaults.
1274    #[test]
1275    fn world_new_resolves_policy_and_initializes_globals() {
1276        let program = sample_program();
1277        let world = World::new(&program, &WorldPolicy::default()).expect("world builds");
1278        assert_eq!(world.globals, program.global_defaults());
1279    }
1280
1281    /// `World::new` propagates the resolver's error for an unknown override
1282    /// name rather than panicking or silently ignoring it.
1283    #[test]
1284    fn world_new_propagates_unknown_name_error() {
1285        let program = sample_program();
1286        let mut overrides = BTreeMap::new();
1287        overrides.insert("nonexistent".to_owned(), Scope::Local);
1288        let policy = WorldPolicy {
1289            overrides,
1290            ..WorldPolicy::default()
1291        };
1292
1293        let err = World::new(&program, &policy).expect_err("must fail");
1294        assert_eq!(err, PolicyError::UnknownName("nonexistent".to_owned()));
1295    }
1296}
1297
1298#[cfg(test)]
1299mod routing_tests {
1300    use super::*;
1301    use crate::link;
1302
1303    /// Compile a small ink story with the brink compiler and link it, for
1304    /// resolving policies against a real `Program` symbol table.
1305    fn compile(src: &str) -> Program {
1306        let out = brink_compiler::compile("t.ink", |p| {
1307            if p == "t.ink" {
1308                Ok(src.to_string())
1309            } else {
1310                Err(std::io::Error::new(
1311                    std::io::ErrorKind::NotFound,
1312                    "no such include",
1313                ))
1314            }
1315        })
1316        .expect("compile");
1317        let (program, _line_tables) = link(&out.data).expect("link");
1318        program
1319    }
1320
1321    fn sample_program() -> Program {
1322        compile(
1323            "VAR gold = 0\n\
1324             VAR mood = 0\n\
1325             -> shrine\n\
1326             === shrine ===\n\
1327             At the shrine.\n\
1328             -> END\n\
1329             === cellar ===\n\
1330             In the cellar.\n\
1331             -> END\n",
1332        )
1333    }
1334
1335    /// `mood` is Local, `gold` stays World (the default). `shrine`/`cellar`
1336    /// are left at the World default too, so visit counts there stay
1337    /// shared — exercised separately below.
1338    fn mixed_policy() -> WorldPolicy {
1339        let mut overrides = BTreeMap::new();
1340        overrides.insert("mood".to_owned(), Scope::Local);
1341        WorldPolicy {
1342            default: Scope::World,
1343            overrides,
1344            turn_index: Scope::World,
1345            rng: Scope::World,
1346        }
1347    }
1348
1349    /// Writing a `Local`-scoped global in one flow must not affect another
1350    /// flow's `ContextView` over the same `World`, nor the `World` itself.
1351    /// Writing a `World`-scoped global in one flow must be immediately
1352    /// visible through another flow's `ContextView`.
1353    #[test]
1354    fn local_write_isolated_world_write_shared() {
1355        let program = sample_program();
1356        let policy = mixed_policy();
1357        let mut world = World::new(&program, &policy).expect("world builds");
1358
1359        let gold_slot = program.global_index("gold").expect("gold declared");
1360        let mood_slot = program.global_index("mood").expect("mood declared");
1361
1362        let mut local_a = FlowLocal::new();
1363        let mut local_b = FlowLocal::new();
1364
1365        // Flow A writes its Local `mood`.
1366        {
1367            let mut view_a = ContextView::new(&mut world, &mut local_a);
1368            view_a.set_global(mood_slot, Value::Int(42));
1369            assert_eq!(view_a.global(mood_slot), &Value::Int(42));
1370        }
1371
1372        // Flow B's view over the same World must not see A's local write —
1373        // it read-throughs to World's untouched default.
1374        {
1375            let view_b = ContextView::new(&mut world, &mut local_b);
1376            assert_eq!(view_b.global(mood_slot), &Value::Int(0));
1377            // World itself is untouched too.
1378            assert_eq!(world.global(mood_slot), &Value::Int(0));
1379        }
1380
1381        // Flow A writes its World-scoped `gold` — this must be immediately
1382        // visible via Flow B's view (and via World directly).
1383        {
1384            let mut view_a = ContextView::new(&mut world, &mut local_a);
1385            view_a.set_global(gold_slot, Value::Int(7));
1386        }
1387        {
1388            let view_b = ContextView::new(&mut world, &mut local_b);
1389            assert_eq!(view_b.global(gold_slot), &Value::Int(7));
1390        }
1391        assert_eq!(world.global(gold_slot), &Value::Int(7));
1392    }
1393
1394    /// Local visit counts increment independently per flow, while a
1395    /// World-scoped knot's visits are shared across flows.
1396    #[test]
1397    fn local_visits_independent_world_visits_shared() {
1398        let program = sample_program();
1399
1400        // shrine: Local: cellar stays at the World default.
1401        let mut overrides = BTreeMap::new();
1402        overrides.insert("shrine".to_owned(), Scope::Local);
1403        let policy = WorldPolicy {
1404            default: Scope::World,
1405            overrides,
1406            turn_index: Scope::World,
1407            rng: Scope::World,
1408        };
1409        let mut world = World::new(&program, &policy).expect("world builds");
1410
1411        let shrine_id = program.find_path_target("shrine").expect("shrine exists");
1412        let cellar_id = program.find_path_target("cellar").expect("cellar exists");
1413
1414        let mut local_a = FlowLocal::new();
1415        let mut local_b = FlowLocal::new();
1416
1417        // Flow A visits `shrine` (Local) twice.
1418        {
1419            let mut view_a = ContextView::new(&mut world, &mut local_a);
1420            view_a.increment_visit(shrine_id);
1421            view_a.increment_visit(shrine_id);
1422            assert_eq!(view_a.visit_count(shrine_id), 2);
1423        }
1424        // Flow B's local shrine count is independent — still 0.
1425        {
1426            let view_b = ContextView::new(&mut world, &mut local_b);
1427            assert_eq!(view_b.visit_count(shrine_id), 0);
1428        }
1429        // World's own bookkeeping for a Local-scoped knot is never touched.
1430        assert_eq!(world.visit_count(shrine_id), 0);
1431
1432        // `cellar` (World-scoped): Flow A's increment is visible to Flow B.
1433        {
1434            let mut view_a = ContextView::new(&mut world, &mut local_a);
1435            view_a.increment_visit(cellar_id);
1436        }
1437        {
1438            let view_b = ContextView::new(&mut world, &mut local_b);
1439            assert_eq!(view_b.visit_count(cellar_id), 1);
1440        }
1441        assert_eq!(world.visit_count(cellar_id), 1);
1442    }
1443
1444    /// A `Local`-scoped global reads through to World's current value until
1445    /// the flow performs its first local write.
1446    #[test]
1447    fn local_read_through_returns_world_default_before_first_write() {
1448        let program = sample_program();
1449        let policy = mixed_policy();
1450        let mut world = World::new(&program, &policy).expect("world builds");
1451        let mood_slot = program.global_index("mood").expect("mood declared");
1452
1453        // World's `mood` starts at the program default (0). A later World
1454        // write (e.g. host bootstrapping) should read through too, since A
1455        // hasn't written its own local override yet.
1456        world.set_global(mood_slot, Value::Int(99));
1457
1458        let mut local_a = FlowLocal::new();
1459        let view_a = ContextView::new(&mut world, &mut local_a);
1460        assert_eq!(view_a.global(mood_slot), &Value::Int(99));
1461    }
1462
1463    /// Local visit-count increment is copy-on-write from the read-through
1464    /// value: if World already has a nonzero count when Local is scoped
1465    /// in, the flow's first local increment starts from that base, not 0.
1466    #[test]
1467    fn local_increment_is_cow_from_read_through_base() {
1468        let program = sample_program();
1469        let mut overrides = BTreeMap::new();
1470        overrides.insert("shrine".to_owned(), Scope::Local);
1471        let policy = WorldPolicy {
1472            default: Scope::World,
1473            overrides,
1474            turn_index: Scope::World,
1475            rng: Scope::World,
1476        };
1477        let mut world = World::new(&program, &policy).expect("world builds");
1478        let shrine_id = program.find_path_target("shrine").expect("shrine exists");
1479
1480        // Seed World's bookkeeping directly (simulating pre-existing shared
1481        // state before this knot was scoped Local for this flow).
1482        world.increment_visit(shrine_id);
1483        world.increment_visit(shrine_id);
1484        assert_eq!(world.visit_count(shrine_id), 2);
1485
1486        let mut local_a = FlowLocal::new();
1487        let mut view_a = ContextView::new(&mut world, &mut local_a);
1488        // First local increment must start from World's base (2), not 0.
1489        view_a.increment_visit(shrine_id);
1490        assert_eq!(view_a.visit_count(shrine_id), 3);
1491
1492        // World's own count is untouched by the Local increment.
1493        assert_eq!(world.visit_count(shrine_id), 2);
1494    }
1495
1496    /// F3.1 chain read-through: a `FlowLocal` with a frozen `base` reads a
1497    /// value from the base when its own top layer has no override, and its
1498    /// own top-layer override shadows the base. Values that appear in
1499    /// neither layer still fall through to `World`.
1500    ///
1501    /// Built by hand (no fork exists yet — that's F3.2): freeze a parent
1502    /// `FlowLocal` that has some overrides, then attach that snapshot as a
1503    /// child's `base`.
1504    #[test]
1505    fn chain_read_through_reads_base_and_top_shadows() {
1506        let program = sample_program();
1507
1508        // Home both globals, both knots, turn index, and RNG to Local so the
1509        // chain (not World) is what's exercised on every read.
1510        let mut overrides = BTreeMap::new();
1511        overrides.insert("gold".to_owned(), Scope::Local);
1512        overrides.insert("mood".to_owned(), Scope::Local);
1513        overrides.insert("shrine".to_owned(), Scope::Local);
1514        overrides.insert("cellar".to_owned(), Scope::Local);
1515        let policy = WorldPolicy {
1516            default: Scope::World,
1517            overrides,
1518            turn_index: Scope::Local,
1519            rng: Scope::Local,
1520        };
1521        let mut world = World::new(&program, &policy).expect("world builds");
1522
1523        let gold_slot = program.global_index("gold").expect("gold declared");
1524        let mood_slot = program.global_index("mood").expect("mood declared");
1525        let shrine_id = program.find_path_target("shrine").expect("shrine exists");
1526        let cellar_id = program.find_path_target("cellar").expect("cellar exists");
1527
1528        // World defaults are distinct from anything we put in the chain, so a
1529        // read hitting World rather than the chain would be visible.
1530        world.set_global(gold_slot, Value::Int(1));
1531        world.set_global(mood_slot, Value::Int(1));
1532
1533        // Build a parent FlowLocal with overrides, then freeze it.
1534        let mut parent = FlowLocal::new();
1535        {
1536            let mut pv = ContextView::new(&mut world, &mut parent);
1537            pv.set_global(gold_slot, Value::Int(100));
1538            pv.set_global(mood_slot, Value::Int(200));
1539            pv.increment_visit(shrine_id); // parent shrine visit = 1
1540            pv.set_turn_count(cellar_id, 5);
1541            pv.increment_turn_index(); // parent turn index = 1
1542            pv.set_rng_seed(777);
1543        }
1544        let base = parent.freeze();
1545
1546        // Child inherits the frozen parent as its base, with its own empty
1547        // top layer.
1548        let mut child = FlowLocal {
1549            base: Some(base),
1550            ..FlowLocal::new()
1551        };
1552
1553        // Reads with an empty top layer see the base's values (not World's).
1554        {
1555            let view = ContextView::new(&mut world, &mut child);
1556            assert_eq!(view.global(gold_slot), &Value::Int(100));
1557            assert_eq!(view.global(mood_slot), &Value::Int(200));
1558            assert_eq!(view.visit_count(shrine_id), 1);
1559            assert_eq!(view.turn_count(cellar_id), Some(5));
1560            assert_eq!(view.turn_index(), 1);
1561            assert_eq!(view.rng_seed(), 777);
1562        }
1563
1564        // A top-layer override shadows the base for that one unit; other
1565        // units keep reading through to the base.
1566        {
1567            let mut view = ContextView::new(&mut world, &mut child);
1568            view.set_global(gold_slot, Value::Int(999));
1569            assert_eq!(view.global(gold_slot), &Value::Int(999)); // shadowed
1570            assert_eq!(view.global(mood_slot), &Value::Int(200)); // still base
1571        }
1572
1573        // A knot with no override anywhere in the chain falls through to
1574        // World (whose count for the Local-scoped `cellar` is untouched: 0).
1575        {
1576            let view = ContextView::new(&mut world, &mut child);
1577            assert_eq!(view.visit_count(cellar_id), 0);
1578        }
1579    }
1580
1581    /// F3.2 fork isolation (`Mode::Normal` child): forking a parent
1582    /// `FlowLocal` gives the child a frozen view of the parent's overrides
1583    /// at fork time. A write the child makes to a `Local`-scoped unit lands
1584    /// only in the child's own top layer — the parent's `FlowLocal` and
1585    /// `World` are both unaffected.
1586    #[test]
1587    fn fork_isolation_normal_child_write_does_not_leak_to_parent_or_world() {
1588        let program = sample_program();
1589        let policy = mixed_policy(); // mood: Local, gold: World (default)
1590        let mut world = World::new(&program, &policy).expect("world builds");
1591        let mood_slot = program.global_index("mood").expect("mood declared");
1592
1593        let mut parent = FlowLocal::new();
1594        {
1595            let mut view = ContextView::new(&mut world, &mut parent);
1596            view.set_global(mood_slot, Value::Int(42));
1597        }
1598
1599        // Fork a Normal child from the parent.
1600        let mut child = parent.fork(Mode::Normal);
1601
1602        // The child reads the parent's frozen state via `base` — it never
1603        // wrote `mood` itself.
1604        {
1605            let view = ContextView::new(&mut world, &mut child);
1606            assert_eq!(view.global(mood_slot), &Value::Int(42));
1607        }
1608
1609        // The child writes its own override for `mood`.
1610        {
1611            let mut view = ContextView::new(&mut world, &mut child);
1612            view.set_global(mood_slot, Value::Int(100));
1613            assert_eq!(view.global(mood_slot), &Value::Int(100));
1614        }
1615
1616        // The parent's own `FlowLocal` still reads its original write — the
1617        // child's write never reached it.
1618        {
1619            let view = ContextView::new(&mut world, &mut parent);
1620            assert_eq!(view.global(mood_slot), &Value::Int(42));
1621        }
1622
1623        // `World` was never touched — `mood` is Local-scoped, so it was
1624        // never written there in the first place.
1625        assert_eq!(world.global(mood_slot), &Value::Int(0));
1626    }
1627
1628    /// F3.2 frozen snapshot: a fork's `base` is a point-in-time snapshot.
1629    /// Mutating the parent *after* the fork must not be visible through the
1630    /// child, which still reads the parent's state as of the fork.
1631    #[test]
1632    fn fork_base_is_a_frozen_snapshot_later_parent_writes_invisible_to_child() {
1633        let program = sample_program();
1634        let policy = mixed_policy(); // mood: Local
1635        let mut world = World::new(&program, &policy).expect("world builds");
1636        let mood_slot = program.global_index("mood").expect("mood declared");
1637
1638        let mut parent = FlowLocal::new();
1639        {
1640            let mut view = ContextView::new(&mut world, &mut parent);
1641            view.set_global(mood_slot, Value::Int(1));
1642        }
1643
1644        let mut child = parent.fork(Mode::Normal);
1645
1646        // Mutate the parent *after* the fork.
1647        {
1648            let mut view = ContextView::new(&mut world, &mut parent);
1649            view.set_global(mood_slot, Value::Int(2));
1650        }
1651
1652        // The child's frozen base still reflects the pre-fork value.
1653        let view = ContextView::new(&mut world, &mut child);
1654        assert_eq!(view.global(mood_slot), &Value::Int(1));
1655    }
1656
1657    /// F3.2 sandbox side-effect-proof: in `Mode::Sandbox`, a `World`-scoped
1658    /// unit is still readable through the live `World` value, but any write
1659    /// — even to a unit the policy homes to `World` — lands only in the
1660    /// sandboxed flow's own overrides. `World` itself is never mutated, and
1661    /// dropping the sandboxed `FlowLocal` leaves no trace.
1662    #[test]
1663    fn sandbox_mode_writes_never_reach_world_reads_see_live_world() {
1664        let program = sample_program();
1665        let policy = mixed_policy(); // gold: World (default), mood: Local
1666        let mut world = World::new(&program, &policy).expect("world builds");
1667        let gold_slot = program.global_index("gold").expect("gold declared");
1668        let shrine_id = program.find_path_target("shrine").expect("shrine exists");
1669
1670        // Simulate pre-existing shared state the sandboxed flow should see.
1671        world.set_global(gold_slot, Value::Int(7));
1672        world.increment_visit(shrine_id);
1673        world.increment_visit(shrine_id);
1674        world.increment_visit(shrine_id);
1675        assert_eq!(world.visit_count(shrine_id), 3);
1676
1677        // Fork a sandboxed child from a "live" flow's (empty) FlowLocal.
1678        let live = FlowLocal::new();
1679        {
1680            let mut sandboxed = live.fork(Mode::Sandbox);
1681
1682            // Reads see World's current, live value even though `gold` and
1683            // `shrine` are World-scoped by policy.
1684            {
1685                let view = ContextView::new(&mut world, &mut sandboxed);
1686                assert_eq!(view.global(gold_slot), &Value::Int(7));
1687                assert_eq!(view.visit_count(shrine_id), 3);
1688            }
1689
1690            // Writing the World-scoped `gold` in the sandbox diverts to the
1691            // sandbox's own overrides — it does not touch `World`.
1692            {
1693                let mut view = ContextView::new(&mut world, &mut sandboxed);
1694                view.set_global(gold_slot, Value::Int(555));
1695                assert_eq!(view.global(gold_slot), &Value::Int(555)); // visible locally
1696            }
1697            assert_eq!(world.global(gold_slot), &Value::Int(7)); // World unchanged
1698
1699            // Incrementing a World-scoped visit count in the sandbox is
1700            // copy-on-write from the live World count, but the increment
1701            // itself stays local — World's count is untouched.
1702            {
1703                let mut view = ContextView::new(&mut world, &mut sandboxed);
1704                view.increment_visit(shrine_id);
1705                assert_eq!(view.visit_count(shrine_id), 4); // sandbox sees 4
1706            }
1707            assert_eq!(world.visit_count(shrine_id), 3); // World still 3
1708
1709            // Dropping `sandboxed` here (end of scope) is discard — nothing
1710            // escaped to World, so there is nothing to unwind.
1711        }
1712
1713        // World is still clean after the sandboxed child is gone.
1714        assert_eq!(world.global(gold_slot), &Value::Int(7));
1715        assert_eq!(world.visit_count(shrine_id), 3);
1716    }
1717}
1718
1719#[cfg(test)]
1720mod save_load_tests {
1721    use super::*;
1722    use crate::link;
1723    use crate::rng::FastRng;
1724    use crate::story::{FallbackHandler, FlowInstance};
1725    use crate::{load_state, save_state};
1726
1727    /// Compile a small ink story with the brink compiler and link it,
1728    /// keeping the line tables `FlowInstance::drive_to_terminal` needs.
1729    fn compile_for_flow(src: &str) -> (Program, Vec<Vec<brink_format::LineEntry>>) {
1730        let out = brink_compiler::compile("t.ink", |p| {
1731            if p == "t.ink" {
1732                Ok(src.to_string())
1733            } else {
1734                Err(std::io::Error::new(
1735                    std::io::ErrorKind::NotFound,
1736                    "no such include",
1737                ))
1738            }
1739        })
1740        .expect("compile");
1741        link(&out.data).expect("link")
1742    }
1743
1744    /// A scoped save/load roundtrip (F6.1b): `gold` (global) and `shrine`
1745    /// (knot) are policy-scoped `Local`; `silver` (global) stays `World`
1746    /// (the default). Driving the flow populates both layers; saving
1747    /// through the routing view captures effective values regardless of
1748    /// scope. Loading into a **fresh** `(World, FlowLocal)` pair through a
1749    /// fresh view must land each unit back in the layer its policy
1750    /// names — `Local` units in the new `FlowLocal`'s override maps (the new
1751    /// `World`'s own copy stays untouched), `World` units directly in the
1752    /// new `World` (the new `FlowLocal` contributes nothing for them).
1753    #[test]
1754    fn scoped_save_load_lands_each_unit_in_its_policy_layer() {
1755        let (program, tables) = compile_for_flow(
1756            "VAR gold = 0\n\
1757             VAR silver = 0\n\
1758             ~ silver = 7\n\
1759             -> shrine\n\
1760             === shrine ===\n\
1761             ~ gold = 5\n\
1762             At the shrine.\n\
1763             -> DONE\n\
1764             === reader ===\n\
1765             {READ_COUNT(-> shrine)}\n\
1766             -> DONE\n",
1767            // `reader` is never entered — it exists only so the compiler's
1768            // counting-flags pass sees a visit-count read of `shrine` and
1769            // sets `CountingFlags::VISITS` on it (a knot whose visit count
1770            // is never read anywhere in the program has counting disabled
1771            // entirely — an existing compiler optimization).
1772        );
1773
1774        let mut overrides = BTreeMap::new();
1775        overrides.insert("gold".to_owned(), Scope::Local);
1776        overrides.insert("shrine".to_owned(), Scope::Local);
1777        let policy = WorldPolicy {
1778            default: Scope::World,
1779            overrides,
1780            turn_index: Scope::World,
1781            rng: Scope::World,
1782        };
1783
1784        let gold_slot = program.global_index("gold").expect("gold declared");
1785        let silver_slot = program.global_index("silver").expect("silver declared");
1786        let shrine_id = program.find_path_target("shrine").expect("shrine exists");
1787
1788        // Drive the flow against a World built from our policy — the
1789        // `FlowInstance::new_at_root`-returned World is discarded; only the
1790        // callstack/thread state it seeds matters here.
1791        let mut world = World::new(&program, &policy).expect("world builds");
1792        let mut local = FlowLocal::new();
1793        let save = {
1794            let (mut flow, _unused_default_world) = FlowInstance::new_at_root(&program);
1795            let mut view = ContextView::new(&mut world, &mut local);
1796            flow.drive_to_terminal::<FastRng>(&program, &tables, &mut view, &FallbackHandler, None)
1797                .expect("drive succeeds");
1798            save_state(&program, &view)
1799        };
1800
1801        assert_eq!(save.globals.get("gold"), Some(&Value::Int(5)));
1802        assert_eq!(save.globals.get("silver"), Some(&Value::Int(7)));
1803        assert_eq!(
1804            save.visits
1805                .iter()
1806                .find(|e| e.id == shrine_id)
1807                .map(|e| e.count),
1808            Some(1),
1809            "shrine should have a captured visit entry"
1810        );
1811
1812        // Load into a fresh (World, FlowLocal) pair, built from the same
1813        // policy but with none of the driven state.
1814        let mut world2 = World::new(&program, &policy).expect("world builds");
1815        let mut local2 = FlowLocal::new();
1816        let report = {
1817            let mut view2 = ContextView::new(&mut world2, &mut local2);
1818            load_state(&program, &mut view2, &save)
1819        };
1820        assert!(report.unknown_globals.is_empty(), "clean load: {report:?}");
1821
1822        // `gold` is Local-scoped: the load must land it in `local2`'s
1823        // override map, leaving `world2`'s own copy at its untouched
1824        // default. The routing view's effective read still sees 5.
1825        assert_eq!(
1826            world2.global(gold_slot),
1827            &Value::Int(0),
1828            "gold is Local-scoped; World's own copy must stay untouched"
1829        );
1830        {
1831            let view2 = ContextView::new(&mut world2, &mut local2);
1832            assert_eq!(view2.global(gold_slot), &Value::Int(5));
1833        }
1834
1835        // `silver` is World-scoped: the load must land it directly in
1836        // `world2`, readable without any FlowLocal involvement.
1837        assert_eq!(
1838            world2.global(silver_slot),
1839            &Value::Int(7),
1840            "silver is World-scoped; must land directly in World"
1841        );
1842
1843        // `shrine`'s visit count is Local-scoped: same split as `gold`.
1844        assert_eq!(
1845            world2.visit_count(shrine_id),
1846            0,
1847            "shrine is Local-scoped; World's own visit count must stay untouched"
1848        );
1849        {
1850            let view2 = ContextView::new(&mut world2, &mut local2);
1851            assert_eq!(view2.visit_count(shrine_id), 1);
1852        }
1853    }
1854}
1855
1856#[cfg(test)]
1857mod subtree_scope_tests {
1858    use super::*;
1859    use crate::link;
1860    use crate::rng::FastRng;
1861    use crate::story::{FallbackHandler, FlowInstance, Line};
1862
1863    /// Compile a small ink story with the brink compiler and link it,
1864    /// keeping the line tables `FlowInstance::drive_to_terminal` needs.
1865    fn compile_for_flow(src: &str) -> (Program, Vec<Vec<brink_format::LineEntry>>) {
1866        let out = brink_compiler::compile("t.ink", |p| {
1867            if p == "t.ink" {
1868                Ok(src.to_string())
1869            } else {
1870                Err(std::io::Error::new(
1871                    std::io::ErrorKind::NotFound,
1872                    "no such include",
1873                ))
1874            }
1875        })
1876        .expect("compile");
1877        link(&out.data).expect("link")
1878    }
1879
1880    /// A knot `guard_talk` with its own top-level stopping sequence
1881    /// (`{ Halt! | Back again? }`) plus a stitch `guard_talk.inner` with its
1882    /// own stopping sequence, and an unrelated `other_knot` — the minimal
1883    /// shape needed to exercise interior-container containment, the knot→
1884    /// stitch cascade, and most-specific-wins precedence.
1885    fn story_with_stitch_and_sequences() -> (Program, Vec<Vec<brink_format::LineEntry>>) {
1886        compile_for_flow(
1887            "VAR gold = 0\n\
1888             -> guard_talk\n\
1889             === guard_talk ===\n\
1890             { stopping: Halt! | Back again? }\n\
1891             -> inner\n\
1892             = inner\n\
1893             { stopping: A | B | C }\n\
1894             -> DONE\n\
1895             === other_knot ===\n\
1896             Other.\n\
1897             -> DONE\n",
1898        )
1899    }
1900
1901    /// Find the `DefinitionId` of the (single) interior container directly
1902    /// owned by `scope_owner` that carries `CountingFlags::VISITS` — i.e.
1903    /// the anonymous sequence container `handle_sequence` (`vm.rs`) keys its
1904    /// counter off. Panics if there isn't exactly one, since every test
1905    /// story here is built with exactly one stopping sequence per scope.
1906    fn find_owned_sequence_id(program: &Program, scope_owner: DefinitionId) -> DefinitionId {
1907        let mut found = None;
1908        for (idx, container) in program.containers.iter().enumerate() {
1909            #[expect(clippy::cast_possible_truncation, reason = "test fixture")]
1910            let idx = idx as u32;
1911            let owner = program.scope_ids[program.scope_table_idx(idx) as usize];
1912            if owner == scope_owner
1913                && container
1914                    .counting_flags
1915                    .contains(brink_format::CountingFlags::VISITS)
1916            {
1917                assert!(
1918                    found.is_none(),
1919                    "expected exactly one VISITS-counted interior container owned by {scope_owner:?}"
1920                );
1921                found = Some(container.id);
1922            }
1923        }
1924        found.expect("expected a VISITS-counted interior container")
1925    }
1926
1927    /// A knot marked `Local` must cover not just its own `DefinitionId` but
1928    /// the interior sequence container nested directly under it — this is
1929    /// the exact bug the F6 AMENDMENT (ruling 3) describes:
1930    /// `handle_sequence` keys a stopping/cycle counter off the sequence's
1931    /// *own* container id, not the enclosing knot's, so without subtree
1932    /// expansion a `Local`-marked knot would silently leave that counter
1933    /// `World`-scoped.
1934    #[test]
1935    fn marked_local_knot_covers_its_interior_sequence_container() {
1936        let (program, _tables) = story_with_stitch_and_sequences();
1937        let guard_talk_id = program
1938            .find_path_target("guard_talk")
1939            .expect("guard_talk exists");
1940        let other_knot_id = program
1941            .find_path_target("other_knot")
1942            .expect("other_knot exists");
1943        let sequence_id = find_owned_sequence_id(&program, guard_talk_id);
1944
1945        let mut overrides = BTreeMap::new();
1946        overrides.insert("guard_talk".to_owned(), Scope::Local);
1947        let policy = WorldPolicy {
1948            default: Scope::World,
1949            overrides,
1950            turn_index: Scope::World,
1951            rng: Scope::World,
1952        };
1953        let resolved = ResolvedPolicy::resolve(&program, &policy).expect("resolves");
1954
1955        assert_eq!(resolved.scope_of_knot(guard_talk_id), Scope::Local);
1956        assert_eq!(
1957            resolved.scope_of_knot(sequence_id),
1958            Scope::Local,
1959            "the interior sequence container must inherit guard_talk's Local scope"
1960        );
1961        // An unrelated knot must stay at the World default — the expansion
1962        // must not leak scope onto unrelated containers.
1963        assert_eq!(resolved.scope_of_knot(other_knot_id), Scope::World);
1964    }
1965
1966    /// Stitch-level override + most-specific-wins precedence: knot
1967    /// `guard_talk` is `Local`, but its stitch `guard_talk.inner` is
1968    /// explicitly `World`. Every container under `inner` (the stitch
1969    /// itself, and its own interior sequence) must resolve `World`; the
1970    /// rest of `guard_talk`'s subtree (the knot's own id and its own
1971    /// interior sequence) must resolve `Local`.
1972    #[test]
1973    fn stitch_override_wins_over_enclosing_knot_for_its_own_subtree() {
1974        let (program, _tables) = story_with_stitch_and_sequences();
1975        let guard_talk_id = program
1976            .find_path_target("guard_talk")
1977            .expect("guard_talk exists");
1978        let inner_id = program
1979            .find_path_target("guard_talk.inner")
1980            .expect("guard_talk.inner exists");
1981        let guard_talk_sequence_id = find_owned_sequence_id(&program, guard_talk_id);
1982        let inner_sequence_id = find_owned_sequence_id(&program, inner_id);
1983
1984        let mut overrides = BTreeMap::new();
1985        overrides.insert("guard_talk".to_owned(), Scope::Local);
1986        overrides.insert("guard_talk.inner".to_owned(), Scope::World);
1987        let policy = WorldPolicy {
1988            default: Scope::World,
1989            overrides,
1990            turn_index: Scope::World,
1991            rng: Scope::World,
1992        };
1993        let resolved = ResolvedPolicy::resolve(&program, &policy).expect("resolves");
1994
1995        assert_eq!(resolved.scope_of_knot(guard_talk_id), Scope::Local);
1996        assert_eq!(resolved.scope_of_knot(guard_talk_sequence_id), Scope::Local);
1997        assert_eq!(
1998            resolved.scope_of_knot(inner_id),
1999            Scope::World,
2000            "the stitch's own explicit override must win over its enclosing knot's"
2001        );
2002        assert_eq!(
2003            resolved.scope_of_knot(inner_sequence_id),
2004            Scope::World,
2005            "the stitch's interior sequence must follow the stitch's own override, \
2006             not the enclosing knot's"
2007        );
2008
2009        // And the reverse precedence: knot World (default), stitch Local —
2010        // confirms precedence isn't just "whichever happens to be Local".
2011        let mut overrides2 = BTreeMap::new();
2012        overrides2.insert("guard_talk".to_owned(), Scope::World);
2013        overrides2.insert("guard_talk.inner".to_owned(), Scope::Local);
2014        let policy2 = WorldPolicy {
2015            default: Scope::World,
2016            overrides: overrides2,
2017            turn_index: Scope::World,
2018            rng: Scope::World,
2019        };
2020        let resolved2 = ResolvedPolicy::resolve(&program, &policy2).expect("resolves");
2021        assert_eq!(resolved2.scope_of_knot(guard_talk_id), Scope::World);
2022        assert_eq!(
2023            resolved2.scope_of_knot(guard_talk_sequence_id),
2024            Scope::World
2025        );
2026        assert_eq!(resolved2.scope_of_knot(inner_id), Scope::Local);
2027        assert_eq!(resolved2.scope_of_knot(inner_sequence_id), Scope::Local);
2028    }
2029
2030    /// The all-`World` default policy must still resolve via
2031    /// `ResolvedPolicy::all_world`'s fast path — no per-slot/per-knot tables
2032    /// populated — even against a program with stitches and sequences that
2033    /// would otherwise drive subtree expansion. This is the oracle-anchored
2034    /// path every existing single-flow construction path takes; it must
2035    /// stay byte-identical.
2036    #[test]
2037    fn all_world_default_still_takes_fast_path() {
2038        let (program, _tables) = story_with_stitch_and_sequences();
2039        let resolved =
2040            ResolvedPolicy::resolve(&program, &WorldPolicy::default()).expect("resolves");
2041
2042        // Fast path: no per-slot/per-knot table populated, matching
2043        // `all_world()` exactly.
2044        assert!(resolved.global_scopes.is_empty());
2045        assert!(resolved.knot_scopes.is_empty());
2046
2047        let guard_talk_id = program
2048            .find_path_target("guard_talk")
2049            .expect("guard_talk exists");
2050        assert_eq!(resolved.scope_of_knot(guard_talk_id), Scope::World);
2051    }
2052
2053    /// An override name that resolves to neither a global nor a knot/
2054    /// stitch path is still a `PolicyError::UnknownName` — subtree
2055    /// expansion must not swallow or change this error path.
2056    #[test]
2057    fn unknown_override_name_still_errors() {
2058        let (program, _tables) = story_with_stitch_and_sequences();
2059        let mut overrides = BTreeMap::new();
2060        overrides.insert("guard_talk.nonexistent_stitch".to_owned(), Scope::Local);
2061        let policy = WorldPolicy {
2062            default: Scope::World,
2063            overrides,
2064            turn_index: Scope::World,
2065            rng: Scope::World,
2066        };
2067        let err = ResolvedPolicy::resolve(&program, &policy).expect_err("must fail");
2068        assert_eq!(
2069            err,
2070            PolicyError::UnknownName("guard_talk.nonexistent_stitch".to_owned())
2071        );
2072    }
2073
2074    /// End-to-end (F6.1c's motivating "per-entity memory" case): two
2075    /// `FlowInstance`s, each with its own `FlowLocal`, drive the *same*
2076    /// `guard_talk` knot (a stopping sequence, `{ Halt! | Back again? }`)
2077    /// over one shared `World` whose policy marks `guard_talk` `Local`.
2078    /// Without subtree expansion, the sequence's own interior container id
2079    /// isn't in `knot_scopes`, falls through to the `World` default, and
2080    /// the two flows' visits collide on one shared counter — the second
2081    /// flow would see "Back again?" on its very first encounter. With the
2082    /// fix, each flow's first encounter is independently the first-visit
2083    /// text.
2084    #[test]
2085    fn two_flows_over_shared_world_each_see_first_visit_text() {
2086        let (program, tables) = compile_for_flow(
2087            "VAR gold = 0\n\
2088             -> guard_talk\n\
2089             === guard_talk ===\n\
2090             { stopping: Halt! | Back again? }\n\
2091             -> DONE\n",
2092        );
2093
2094        let mut overrides = BTreeMap::new();
2095        overrides.insert("guard_talk".to_owned(), Scope::Local);
2096        let policy = WorldPolicy {
2097            default: Scope::World,
2098            overrides,
2099            turn_index: Scope::World,
2100            rng: Scope::World,
2101        };
2102        let mut world = World::new(&program, &policy).expect("world builds");
2103
2104        let drive = |flow: &mut FlowInstance, view: &mut ContextView<'_>| -> String {
2105            let lines = flow
2106                .drive_to_terminal::<FastRng>(&program, &tables, view, &FallbackHandler, None)
2107                .expect("drive succeeds");
2108            assert!(
2109                matches!(lines.last(), Some(Line::Done { .. })),
2110                "expected Done, got {lines:?}"
2111            );
2112            lines.iter().map(Line::text).collect::<String>()
2113        };
2114
2115        // Flow A: first (and only, for this assertion) encounter.
2116        let (mut flow_a, _discarded_world_a) = FlowInstance::new_at_root(&program);
2117        let mut local_a = FlowLocal::new();
2118        let first_visit_a = {
2119            let mut view_a = ContextView::new(&mut world, &mut local_a);
2120            drive(&mut flow_a, &mut view_a)
2121        };
2122
2123        // Flow B: independent FlowLocal, same shared World. Its first
2124        // encounter must read exactly like Flow A's — not "already
2125        // visited" — proving the interior sequence container's visit count
2126        // is Local per-flow, not accidentally shared through World.
2127        let (mut flow_b, _discarded_world_b) = FlowInstance::new_at_root(&program);
2128        let mut local_b = FlowLocal::new();
2129        let first_visit_b = {
2130            let mut view_b = ContextView::new(&mut world, &mut local_b);
2131            drive(&mut flow_b, &mut view_b)
2132        };
2133
2134        assert_eq!(
2135            first_visit_a, first_visit_b,
2136            "both flows' first encounter with guard_talk must produce identical \
2137             (first-visit) text — each flow's visit count is independently local"
2138        );
2139
2140        // Flow A, re-entered a second time (still its own FlowLocal): now
2141        // it must progress to the *next* branch of the stopping sequence,
2142        // proving Local scoping still lets a single flow's own state
2143        // advance normally.
2144        let second_visit_a = {
2145            let mut view_a = ContextView::new(&mut world, &mut local_a);
2146            flow_a
2147                .choose_path_string(&program, &mut view_a, "guard_talk")
2148                .expect("re-enter guard_talk");
2149            drive(&mut flow_a, &mut view_a)
2150        };
2151        assert_ne!(
2152            first_visit_a, second_visit_a,
2153            "flow A's second encounter must progress past the first-visit branch"
2154        );
2155
2156        // Flow B's own local state must still be untouched by flow A's
2157        // second visit — driving B a second time reproduces A's *first*
2158        // progression, not A's second.
2159        let second_visit_b = {
2160            let mut view_b = ContextView::new(&mut world, &mut local_b);
2161            flow_b
2162                .choose_path_string(&program, &mut view_b, "guard_talk")
2163                .expect("re-enter guard_talk");
2164            drive(&mut flow_b, &mut view_b)
2165        };
2166        assert_eq!(
2167            second_visit_a, second_visit_b,
2168            "flow B's second encounter must match flow A's second encounter — \
2169             both progressed independently from the same (shared, untouched) \
2170             World default"
2171        );
2172
2173        // World's own bookkeeping for the Local-scoped knot must never have
2174        // been touched by either flow.
2175        let sequence_id = find_owned_sequence_id(&program, {
2176            program
2177                .find_path_target("guard_talk")
2178                .expect("guard_talk exists")
2179        });
2180        assert_eq!(
2181            world.visit_count(sequence_id),
2182            0,
2183            "World's own copy of the Local-scoped sequence's visit count must stay untouched"
2184        );
2185    }
2186}