Skip to main content

brink_runtime/
save.rs

1//! `save_state` / `load_state`: produce and reconcile the durable,
2//! name-keyed [`SaveState`] game-state save.
3//!
4//! [`SaveState`] (defined in `brink-format`) is distinct from the in-memory
5//! [`StorySnapshot`](crate::StorySnapshot), which captures full execution
6//! position and is locked to one exact program build. `SaveState` captures
7//! only *game state* — globals, visit/turn counts, turn index, RNG — keyed by
8//! stable identities (variable name; scope `DefinitionId`), so a save survives
9//! a story recompile/patch as long as the relevant names/paths are unchanged.
10//! Execution position is deliberately not captured; the host re-enters a
11//! conversation at a known knot. See `docs/external-binding-foundation.md`.
12//!
13//! **F6.1b:** the logic lives as free functions over `&Program` +
14//! `&impl ContextAccess`, not as `Story` methods, so any holder of a flow's
15//! context — `Story`'s own `default_context`, or a `bevy-brink` `ContextView`
16//! over a shared `World` plus an entity's `FlowLocal` — can save/load without
17//! going through `Story` at all. [`Story::save_state`]/[`Story::load_state`]
18//! now delegate to these, unchanged in observable behavior.
19//!
20//! **Enumeration.** `ContextAccess` has no iteration surface (a `ContextView`
21//! can't hand back "every visited id" — it only answers point queries routed
22//! by scope), so the candidate id set for visits/turns comes from the
23//! `Program`'s own container definitions rather than map iteration. Every
24//! container the VM ever visit/turn-counts carries `CountingFlags::VISITS`:
25//! `vm.rs`'s `EnterContainer`/goto paths only ever call
26//! `increment_visit`/`set_turn_count` when that flag is set on the target
27//! container. (The converter *does* set `CountingFlags::TURNS` independently,
28//! mirroring inklecate's container flags — but since every VM counting site
29//! gates on `VISITS` alone, a TURNS-only container can never accrue a runtime
30//! entry.) So containers with `VISITS` set are exactly the superset of ids
31//! that could have a visit *or* turn entry. Iterating `Program::containers` (a `Vec`,
32//! not a hash map) keeps enumeration order deterministic independent of
33//! `Program`'s internal id tables.
34//!
35//! For each candidate id: `ContextAccess::visit_count` returns `0` for an id
36//! the context has never visited (`World::increment_visit` only ever inserts
37//! on the first increment, `or_insert(0) += 1`), so a `0` here means "never
38//! visited" and is skipped — that reproduces the old code's
39//! present-entries-only output, which iterated `World`'s
40//! `visit_counts: HashMap` directly and so only ever saw ids that had
41//! actually been inserted. `turn_count` returns `Option<u32>`, so absence is
42//! directly distinguishable from an explicit `0` without a sentinel value.
43//! Output is sorted by id explicitly (`Vec::sort_by_key`) rather than relying
44//! on `Program::containers`' container-index order — byte-identical save
45//! output is a hard requirement independent of container layout.
46
47use alloc::borrow::ToOwned;
48use alloc::collections::BTreeMap;
49use alloc::format;
50use alloc::string::String;
51use alloc::sync::Arc;
52use alloc::vec::Vec;
53
54use brink_format::{
55    ClosureEnvEntry, ClosureValue, CountingFlags, DefinitionId, ListValue, LoadReport, OrderedMap,
56    SAVE_FORMAT_VERSION, SaveState, Value, VisitEntry,
57};
58
59use crate::StoryRng;
60use crate::debug::NameResolver;
61use crate::program::Program;
62use crate::state::ContextAccess;
63use crate::story::Story;
64
65/// Capture a flow's game state as a durable, name-keyed [`SaveState`]. Does
66/// not capture execution position — see the module docs.
67///
68/// `ctx` can be any [`ContextAccess`] implementor: `World` directly, a
69/// `ContextView` routing over `(World, FlowLocal)` (in which case every value
70/// captured is the **effective** value for that flow — a `Local` override
71/// where present, else `World`'s value on a read-through miss), or an
72/// `ObservedContext` wrapping either.
73#[must_use]
74pub fn save_state<C: ContextAccess + ?Sized>(program: &Program, ctx: &C) -> SaveState {
75    let resolver = NameResolver::new(program);
76
77    let globals: BTreeMap<String, Value> = (0..program.global_count())
78        .filter_map(|idx| {
79            program.global_slot_name(idx as usize).map(|name| {
80                let value = ctx.global(idx).clone();
81                // Same mechanism as `Opcode::GetGlobal`'s read (a bare
82                // `Arc::clone` on a collection-typed `Value`) — reported
83                // through the same counter so a save/load cycle's
84                // Arc-clone count is visible to `bench-counters`
85                // (issue #821 Workstream C), not silently invisible just
86                // because it's a host-side `ContextAccess` read rather
87                // than a VM opcode.
88                crate::vm::note_value_share(&value);
89                (name.to_owned(), value)
90            })
91        })
92        .collect();
93
94    // M-3 (docs/modules-spec.md §5): each global's compiled `DefinitionId`
95    // at save time, so a later miss-path lookup (a renamed VAR/CONST/LIST —
96    // declared-module identity is `(module, name)`-hashed, so the bare name
97    // alone doesn't recover it) can consult the alias table directly. See
98    // `load_state`'s doc comment.
99    let global_ids: BTreeMap<String, DefinitionId> = (0..program.global_count())
100        .filter_map(|idx| {
101            let name = program.global_slot_name(idx as usize)?;
102            let id = program.global_id(idx as usize)?;
103            Some((name.to_owned(), id))
104        })
105        .collect();
106
107    let mut visits = Vec::new();
108    let mut turns = Vec::new();
109    for container in &program.containers {
110        if !container.counting_flags.contains(CountingFlags::VISITS) {
111            continue;
112        }
113        let id = container.id;
114
115        let count = ctx.visit_count(id);
116        if count > 0 {
117            visits.push(VisitEntry {
118                id,
119                path: resolver.def_path(id).map(str::to_owned),
120                count,
121            });
122        }
123
124        if let Some(turn) = ctx.turn_count(id) {
125            turns.push(VisitEntry {
126                id,
127                path: resolver.def_path(id).map(str::to_owned),
128                count: turn,
129            });
130        }
131    }
132    visits.sort_by_key(|e| e.id.to_raw());
133    turns.sort_by_key(|e| e.id.to_raw());
134
135    SaveState {
136        version: SAVE_FORMAT_VERSION,
137        globals,
138        global_ids,
139        visits,
140        turns,
141        turn_index: ctx.turn_index(),
142        rng_seed: ctx.rng_seed(),
143        previous_random: ctx.previous_random(),
144        // FS-1 is format-only (`docs/flow-suspension-spec.md` §9): the
145        // runtime spill/restore that would populate a live suspended flow
146        // here is FS-3 scope. Always `None` until then.
147        suspended: None,
148    }
149}
150
151/// Reconcile a [`SaveState`] into a flow's context, returning a
152/// [`LoadReport`] of anything that couldn't be applied. Globals are matched
153/// by name; visit/turn counts by id. Tolerant of story patches: unknown
154/// globals are reported, scopes the program no longer has retain their saved
155/// counts harmlessly in the live context. Note one deliberate change from the
156/// pre-F6.1b `Story` methods: such stale entries are **not re-emitted by a
157/// subsequent [`save_state`]** (which enumerates the *current* program's
158/// containers, not the live maps) — ghost counts from older program versions
159/// no longer round-trip through saves indefinitely.
160///
161/// **M-3 rehydration miss-path lookup** (`docs/modules-spec.md` §5): a
162/// visit/turn-count id, or a divert-target/fn-token/closure-target id
163/// embedded inside a saved global's value, that the current program doesn't
164/// recognize is looked up in the compiled `#@was` alias table before being
165/// treated as genuinely gone — a knot/stitch/module rename that recorded
166/// `#@was` rebinds saved state deterministically instead of orphaning it
167/// under the stale id. Still unresolved after that (only checked, and only
168/// reported, for a program that carries alias-table entries at all — an
169/// ordinary content edit with no `#@was` stays exactly as silent as before
170/// M-3) surfaces a teaching message in [`LoadReport::unresolved_renames`].
171///
172/// A saved global whose **own name** no longer matches any current global
173/// slot gets the same treatment before being dropped: `save.global_ids`
174/// carries the name's save-time `DefinitionId` (declared-module identity is
175/// `(module, name)`-hashed, so the bare name string alone can't reconstruct
176/// it — this is what makes a VAR/CONST/LIST rename inside a *declared*
177/// module different from a bare knot rename), which is looked up in the
178/// alias table exactly like an address/global-pointer id. A resolved rename
179/// rebinds silently to the renamed slot; still unresolved (no id recorded —
180/// an older save predating this field — or no matching alias) falls back to
181/// [`LoadReport::unknown_globals`], same as before M-3.
182///
183/// Writes go through [`ContextAccess`], so on a `ContextView` they route by
184/// scope exactly like any other write: a `World`-scoped unit lands in the
185/// shared `World`, a `Local`-scoped unit in the flow's own `FlowLocal`
186/// override layer.
187pub fn load_state<C: ContextAccess + ?Sized>(
188    program: &Program,
189    ctx: &mut C,
190    save: &SaveState,
191) -> LoadReport {
192    let mut report = LoadReport::default();
193    let renames_matter = program.has_aliases();
194
195    for (name, value) in &save.globals {
196        match program.global_index(name) {
197            Some(idx) => {
198                let value = if renames_matter {
199                    rebind_value(program, value, &mut report)
200                } else {
201                    // The common path (no `#@was` aliases active): a
202                    // bare `Value::clone()`, same mechanism as
203                    // `Opcode::GetGlobal`'s read — noted so a full
204                    // save/load round trip's Arc-clone count is visible
205                    // to `bench-counters` (issue #821 Workstream C), not
206                    // just the save half. The `renames_matter` branch
207                    // above calls `rebind_value`, which recursively
208                    // rebuilds compound values rather than cloning the
209                    // top-level `Arc` — not the same mechanism, so not
210                    // noted here (would overstate the count).
211                    let cloned = value.clone();
212                    crate::vm::note_value_share(&cloned);
213                    cloned
214                };
215                ctx.set_global(idx, value);
216            }
217            None => {
218                if let Some(idx) = rebind_global_name(program, renames_matter, save, name) {
219                    let value = rebind_value(program, value, &mut report);
220                    ctx.set_global(idx, value);
221                } else {
222                    if renames_matter && save.global_ids.contains_key(name) {
223                        report
224                            .unresolved_renames
225                            .push(teach_was_message("global variable", name));
226                    }
227                    report.unknown_globals.push(name.clone());
228                }
229            }
230        }
231    }
232
233    ctx.set_turn_index(save.turn_index);
234    ctx.set_rng_seed(save.rng_seed);
235    ctx.set_previous_random(save.previous_random);
236    for e in &save.visits {
237        ctx.set_visit_count(rebind_address_key(program, e, &mut report), e.count);
238    }
239    for e in &save.turns {
240        ctx.set_turn_count(rebind_address_key(program, e, &mut report), e.count);
241    }
242
243    report
244}
245
246// ─── M-3 rehydration miss-path lookup (docs/modules-spec.md §5) ───────────
247
248/// Resolve a visit/turn-count entry's saved id against the current program,
249/// falling back to the alias table on a direct miss. On a still-unresolved
250/// miss: a **named** scope (`entry.path` is `Some`) gets the M-3 teaching
251/// message, but only for a program with any alias-table entries at all (an
252/// ordinary content edit with no `#@was` directive stays silent, same as
253/// before M-3). An **anonymous** scope (`entry.path` is `None` — a gather,
254/// choice point, or sequence with no author label) has no path to teach a
255/// fix against and — unlike a named miss — can never be recovered through
256/// the alias table regardless of whether the program uses `#@was` elsewhere
257/// (an alias entry is only ever written against a name), so it is counted
258/// in [`LoadReport::anonymous_states_dropped`] unconditionally (issue
259/// #1674, gap 4 of the identity cluster) rather than gated on
260/// `Program::has_aliases` — the count is the *only* legible signal an
261/// anonymous miss gets, so gating it the way the named case is gated would
262/// make it silent for the overwhelming majority of projects.
263fn rebind_address_key(
264    program: &Program,
265    entry: &VisitEntry,
266    report: &mut LoadReport,
267) -> DefinitionId {
268    let (id, unresolved) = rebind_address(program, entry.id);
269    if unresolved {
270        match &entry.path {
271            Some(path) if program.has_aliases() => {
272                report
273                    .unresolved_renames
274                    .push(teach_was_message("visit count", path));
275            }
276            Some(_) => {}
277            None => report.anonymous_states_dropped += 1,
278        }
279    }
280    id
281}
282
283/// Resolve a saved global's own name against the current program's alias
284/// table, when its bare name no longer matches any live global slot
285/// (`load_state`'s doc comment). Looks up the name's save-time
286/// `DefinitionId` in `save.global_ids`, resolves it through the alias
287/// table, and — if the resolved id names a live global slot — returns that
288/// slot's index. `None` when there's nothing to attempt (`renames_matter`
289/// is `false`, or the save predates `global_ids`) or the lookup doesn't
290/// land on a live slot.
291fn rebind_global_name(
292    program: &Program,
293    renames_matter: bool,
294    save: &SaveState,
295    name: &str,
296) -> Option<u32> {
297    if !renames_matter {
298        return None;
299    }
300    let old_id = *save.global_ids.get(name)?;
301    let new_id = program.resolve_alias(old_id)?;
302    program.resolve_global(new_id)
303}
304
305/// Resolve a single address-space id (container/scope/label) against the
306/// current program, falling back to the alias table on a direct miss.
307/// Returns the id to use and whether it's still unresolved after that (no
308/// alias, or an alias whose own target doesn't resolve either) — the
309/// compiler never emits a multi-hop alias chain (`old -> old2 -> new`), so
310/// one alias lookup is always enough; a still-unresolved alias target means
311/// the alias itself is stale (e.g. a further edit deleted the renamed
312/// definition), which is the same "genuinely gone" outcome as no alias.
313fn rebind_address(program: &Program, id: DefinitionId) -> (DefinitionId, bool) {
314    if program.knows_address(id) {
315        return (id, false);
316    }
317    match program.resolve_alias(id) {
318        Some(new_id) => (new_id, !program.knows_address(new_id)),
319        None => (id, true),
320    }
321}
322
323/// Resolve a global-variable-pointer id (`Value::VariablePointer`) the same
324/// way [`rebind_address`] resolves a container/address id, against the
325/// global-slot namespace instead.
326fn rebind_global(program: &Program, id: DefinitionId) -> (DefinitionId, bool) {
327    if program.knows_global(id) {
328        return (id, false);
329    }
330    match program.resolve_alias(id) {
331        Some(new_id) => (new_id, !program.knows_global(new_id)),
332        None => (id, true),
333    }
334}
335
336/// Resolve a list-item id (one of a `Value::List`'s active items) the same
337/// way [`rebind_address`] resolves a container/address id, against the
338/// list-item namespace instead.
339fn rebind_list_item(program: &Program, id: DefinitionId) -> (DefinitionId, bool) {
340    if program.knows_list_item(id) {
341        return (id, false);
342    }
343    match program.resolve_alias(id) {
344        Some(new_id) => (new_id, !program.knows_list_item(new_id)),
345        None => (id, true),
346    }
347}
348
349/// Resolve a list-definition id (one of a `Value::List`'s `origins`) the
350/// same way [`rebind_address`] resolves a container/address id, against the
351/// list-definition namespace instead.
352fn rebind_list_def(program: &Program, id: DefinitionId) -> (DefinitionId, bool) {
353    if program.knows_list_def(id) {
354        return (id, false);
355    }
356    match program.resolve_alias(id) {
357        Some(new_id) => (new_id, !program.knows_list_def(new_id)),
358        None => (id, true),
359    }
360}
361
362/// The M-3 teaching fault message (`docs/modules-spec.md` §5): "saved
363/// {subject} `{path}` resolves to nothing; if `{suggestion}` was renamed,
364/// add `#@was({suggestion})`." The suggestion is the path's outermost
365/// segment (module-qualified paths look like `module.knot`; the module is
366/// usually the rename culprit for a multi-definition miss) falling back to
367/// the whole path for an unqualified name (a bare knot rename).
368fn teach_was_message(subject: &str, path: &str) -> String {
369    let suggestion = path.split('.').next().unwrap_or(path);
370    format!(
371        "saved {subject} `{path}` resolves to nothing; if `{suggestion}` was renamed, add `#@was({suggestion})`"
372    )
373}
374
375/// The M-3 teaching fault message for an id with no saved author path (a
376/// divert target / fn token / closure target embedded in a global's value —
377/// the wire format carries only the numeric id, never a path string).
378fn teach_was_message_for_id(subject: &str, id: DefinitionId) -> String {
379    format!(
380        "saved {subject} {id} resolves to nothing; if its knot, stitch, or function was renamed, add `#@was(old_name)` to it"
381    )
382}
383
384/// Rebind an address-space id (divert target / fn token / closure target)
385/// found inside a saved `Value`, reporting a teaching message when it's
386/// still unresolved after the alias-table lookup. Only called when the
387/// program has alias-table entries (`load_state`'s `renames_matter` gate) —
388/// the report only fires for a program that actually uses `#@was`.
389fn rebind_value_address_id(
390    program: &Program,
391    subject: &str,
392    id: DefinitionId,
393    report: &mut LoadReport,
394) -> DefinitionId {
395    let (new_id, unresolved) = rebind_address(program, id);
396    if unresolved {
397        report
398            .unresolved_renames
399            .push(teach_was_message_for_id(subject, id));
400    }
401    new_id
402}
403
404/// Rebind a global-pointer id (`Value::VariablePointer`) found inside a
405/// saved `Value`, same discipline as [`rebind_value_address_id`].
406fn rebind_value_global_id(
407    program: &Program,
408    id: DefinitionId,
409    report: &mut LoadReport,
410) -> DefinitionId {
411    let (new_id, unresolved) = rebind_global(program, id);
412    if unresolved {
413        report
414            .unresolved_renames
415            .push(teach_was_message_for_id("variable pointer", id));
416    }
417    new_id
418}
419
420/// Rebind a list-item id found inside a saved `Value::List`'s active items,
421/// same discipline as [`rebind_value_address_id`].
422fn rebind_value_list_item_id(
423    program: &Program,
424    id: DefinitionId,
425    report: &mut LoadReport,
426) -> DefinitionId {
427    let (new_id, unresolved) = rebind_list_item(program, id);
428    if unresolved {
429        report
430            .unresolved_renames
431            .push(teach_was_message_for_id("list item", id));
432    }
433    new_id
434}
435
436/// Rebind a list-definition id found inside a saved `Value::List`'s
437/// `origins`, same discipline as [`rebind_value_address_id`].
438fn rebind_value_list_def_id(
439    program: &Program,
440    id: DefinitionId,
441    report: &mut LoadReport,
442) -> DefinitionId {
443    let (new_id, unresolved) = rebind_list_def(program, id);
444    if unresolved {
445        report
446            .unresolved_renames
447            .push(teach_was_message_for_id("list definition", id));
448    }
449    new_id
450}
451
452/// Recursively rebind M-3 alias-table ids embedded anywhere inside a saved
453/// `Value` — divert targets, fn tokens, closure targets and their `ref` env
454/// entries, list items/origins inside a `Value::List`, and any of those
455/// nested inside an array/map/record. A value with no rename-affected id
456/// anywhere in it round-trips unchanged (modulo the `Arc` rebuild
457/// collections/records always pay here — load is a one-shot reconciliation,
458/// not a hot path, so the simpler always-recurse shape wins over threading a
459/// "did anything change" flag through).
460fn rebind_value(program: &Program, value: &Value, report: &mut LoadReport) -> Value {
461    match value {
462        Value::DivertTarget(id) => Value::DivertTarget(rebind_value_address_id(
463            program,
464            "divert target",
465            *id,
466            report,
467        )),
468        Value::FnRef(id) => Value::FnRef(rebind_value_address_id(program, "fn token", *id, report)),
469        Value::VariablePointer(id) => {
470            Value::VariablePointer(rebind_value_global_id(program, *id, report))
471        }
472        Value::List(list) => Value::List(Arc::new(ListValue {
473            items: list
474                .items
475                .iter()
476                .map(|id| rebind_value_list_item_id(program, *id, report))
477                .collect(),
478            origins: list
479                .origins
480                .iter()
481                .map(|id| rebind_value_list_def_id(program, *id, report))
482                .collect(),
483        })),
484        Value::Closure(c) => {
485            let target = rebind_value_address_id(program, "fn token", c.target, report);
486            let env = c
487                .env
488                .iter()
489                .map(|e| ClosureEnvEntry {
490                    name: e.name,
491                    is_ref: e.is_ref,
492                    payload: rebind_value(program, &e.payload, report),
493                })
494                .collect();
495            Value::Closure(Arc::new(ClosureValue { target, env }))
496        }
497        Value::Array(items) => Value::array(
498            items
499                .iter()
500                .map(|v| rebind_value(program, v, report))
501                .collect::<Vec<_>>(),
502        ),
503        Value::Map(m) => {
504            let rebound: OrderedMap = m
505                .iter()
506                .map(|(k, v)| (k.clone(), rebind_value(program, v, report)))
507                .collect();
508            Value::map(rebound)
509        }
510        Value::Record { shape, fields } => Value::Record {
511            shape: *shape,
512            fields: Arc::new(
513                fields
514                    .iter()
515                    .map(|v| rebind_value(program, v, report))
516                    .collect(),
517            ),
518        },
519        // T1e (docs/t1e-spec.md §3): "rehydration validates the root cell
520        // like VariablePointer today, and the `#@was` alias table applies
521        // to the root's identity on the miss path" — the *same*
522        // `rebind_value_global_id` a `VariablePointer` root uses, since a
523        // projection's cell reference is that identical payload shape
524        // (`docs/format-v4-rfc.md` §1: "cell reference = the existing
525        // VAL_VAR_POINTER payload shape, reused not reinvented"). Segment
526        // values recurse too — a `Key` segment can itself carry an id
527        // needing rebinding (e.g. a divert-target map key is not legal,
528        // but a nested closure/array segment value theoretically could be).
529        Value::Projection(p) => {
530            let cell = rebind_value_global_id(program, p.cell, report);
531            let segments = p
532                .segments
533                .iter()
534                .map(|seg| match seg {
535                    brink_format::ProjSegment::Index(n) => brink_format::ProjSegment::Index(*n),
536                    brink_format::ProjSegment::Key(v) => {
537                        brink_format::ProjSegment::Key(rebind_value(program, v, report))
538                    }
539                })
540                .collect();
541            Value::projection(cell, segments)
542        }
543        other => other.clone(),
544    }
545}
546
547impl<R: StoryRng> Story<R> {
548    /// Capture the default flow's game state as a durable, name-keyed
549    /// [`SaveState`]. Does not capture execution position. Thin delegating
550    /// wrapper over the free [`save_state`] function — see the module docs.
551    #[must_use]
552    pub fn save_state(&self) -> SaveState {
553        save_state(self.program(), &self.default_context)
554    }
555
556    /// Reconcile a [`SaveState`] into the default flow's context. Thin
557    /// delegating wrapper over the free [`load_state`] function — see the
558    /// module docs.
559    pub fn load_state(&mut self, save: &SaveState) -> LoadReport {
560        let program = self.program_arc();
561        load_state(&program, &mut self.default_context, save)
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use std::sync::Arc;
568
569    use super::*;
570    use crate::link;
571    use crate::rng::FastRng;
572
573    /// Compile a small ink story with the brink compiler and link it.
574    fn compile_for_flow(src: &str) -> (Program, Vec<Vec<brink_format::LineEntry>>) {
575        let out = brink_compiler::compile("t.ink", |p| {
576            if p == "t.ink" {
577                Ok(src.to_string())
578            } else {
579                Err(std::io::Error::new(
580                    std::io::ErrorKind::NotFound,
581                    "no such include",
582                ))
583            }
584        })
585        .expect("compile");
586        link(&out.data).expect("link")
587    }
588
589    /// `DefinitionId`s are content-hash-based (`brink_format::id`), not an
590    /// incrementing counter tied to declaration order — so visiting knots in
591    /// declaration order already scrambles hash order, and `Program`'s
592    /// container `Vec` (declaration order) doesn't coincidentally match id
593    /// order either. `save_state`'s explicit `sort_by_key` is what
594    /// guarantees `SaveState::visits`/`turns` come out id-sorted regardless
595    /// of visit order or container layout — this locks that invariant down.
596    #[test]
597    fn visits_are_sorted_by_id_regardless_of_visit_order() {
598        let (program, tables) = compile_for_flow(
599            "-> alpha\n\
600             === alpha ===\n\
601             Alpha.\n\
602             -> DONE\n\
603             === beta ===\n\
604             Beta.\n\
605             -> DONE\n\
606             === gamma ===\n\
607             Gamma.\n\
608             -> DONE\n\
609             === reader ===\n\
610             {READ_COUNT(-> alpha)} {READ_COUNT(-> beta)} {READ_COUNT(-> gamma)}\n\
611             -> DONE\n",
612            // `reader` is never entered at runtime — it exists only so the
613            // compiler's counting-flags pass (`apply_counting_flags` in
614            // brink-ir) sees a `READ_COUNT` reference to each knot and sets
615            // `CountingFlags::VISITS` on it. Without a visit-count *read*
616            // somewhere in the program, the compiler leaves counting
617            // disabled for a knot (an optimization) and the VM never calls
618            // `increment_visit`/`set_turn_count` for it at all.
619        );
620        let program = Arc::new(program);
621        let mut story = crate::Story::<FastRng>::new(Arc::clone(&program), tables);
622
623        // Visit alpha (root divert), then gamma, then beta — an order that
624        // matches neither declaration order nor (necessarily) id order.
625        story.continue_maximally().expect("continue");
626        story.choose_path_string("gamma").expect("jump");
627        story.continue_maximally().expect("continue");
628        story.choose_path_string("beta").expect("jump");
629        story.continue_maximally().expect("continue");
630
631        let save = story.save_state();
632        assert_eq!(
633            save.visits.len(),
634            3,
635            "alpha/beta/gamma should each have a visit entry: {:?}",
636            save.visits
637        );
638
639        let ids: Vec<u64> = save.visits.iter().map(|e| e.id.to_raw()).collect();
640        let mut sorted = ids.clone();
641        sorted.sort_unstable();
642        assert_eq!(ids, sorted, "SaveState::visits must be sorted by id");
643    }
644
645    /// Issue #1674: a saved visit/turn-count entry for an **anonymous**
646    /// scope (`path: None` — the shape an unlabeled once-only choice or a
647    /// sequence's own visit entry carries) that the current program no
648    /// longer recognizes is counted in
649    /// [`brink_format::LoadReport::anonymous_states_dropped`] — legible
650    /// through the existing tolerant `LoadReport` rather than silently
651    /// retained under an id nothing can ever reach again. The phantom id
652    /// here stands in for what a real content edit does: shift an
653    /// unlabeled once-only choice's positional id out from under an
654    /// earlier save (see `anonymous_stateful` in `brink-analyzer` for the
655    /// companion compile-time lint).
656    #[test]
657    fn anonymous_unresolved_visit_entry_is_counted_in_load_report() {
658        let (program, tables) = compile_for_flow(
659            "-> alpha\n\
660             === alpha ===\n\
661             Alpha.\n\
662             -> DONE\n",
663        );
664        let program = Arc::new(program);
665        let mut story = crate::Story::<FastRng>::new(Arc::clone(&program), tables);
666        story.continue_maximally().expect("continue");
667
668        let mut save = story.save_state();
669        assert!(
670            save.visits.iter().all(|e| e.path.is_some()),
671            "sanity: the real save has no anonymous entries to confuse this \
672             test: {:?}",
673            save.visits
674        );
675        let phantom_id =
676            brink_format::DefinitionId::new(brink_format::DefinitionTag::Address, u64::MAX);
677        save.visits.push(brink_format::VisitEntry {
678            id: phantom_id,
679            path: None,
680            count: 3,
681        });
682
683        let report = story.load_state(&save);
684        assert_eq!(report.anonymous_states_dropped, 1, "{report:?}");
685        assert!(!report.is_clean(), "{report:?}");
686        assert!(
687            report.unresolved_renames.is_empty(),
688            "an anonymous miss has no path to teach a #@was fix against, \
689             so it must never land in unresolved_renames: {report:?}"
690        );
691    }
692
693    /// A saved **named** scope's unresolved entry (`path: Some(_)`) must
694    /// never be counted as an anonymous drop, even when the program has no
695    /// `#@was` alias table at all (the ordinary "content edit deleted a
696    /// knot" case, which stays silent by design) — the two report channels
697    /// are for genuinely different situations and must not bleed into each
698    /// other.
699    #[test]
700    fn named_unresolved_visit_entry_is_not_counted_as_anonymous() {
701        let (program, tables) = compile_for_flow(
702            "-> alpha\n\
703             === alpha ===\n\
704             Alpha.\n\
705             -> DONE\n",
706        );
707        let program = Arc::new(program);
708        let mut story = crate::Story::<FastRng>::new(Arc::clone(&program), tables);
709
710        let phantom_id =
711            brink_format::DefinitionId::new(brink_format::DefinitionTag::Address, u64::MAX);
712        let mut save = story.save_state();
713        save.visits.push(brink_format::VisitEntry {
714            id: phantom_id,
715            path: Some("forest.gone_knot".to_owned()),
716            count: 3,
717        });
718
719        let report = story.load_state(&save);
720        assert_eq!(
721            report.anonymous_states_dropped, 0,
722            "a named miss is not an anonymous drop: {report:?}"
723        );
724        assert!(
725            report.unresolved_renames.is_empty(),
726            "no #@was alias table on this program, so the named miss stays \
727             silent exactly like before M-3: {report:?}"
728        );
729    }
730
731    /// The end-to-end proof `anonymous_unresolved_visit_entry_is_counted_
732    /// in_load_report` above stops short of: that test's `path: None` entry
733    /// is a synthetic phantom id, so it would still pass even if
734    /// `save_state` never actually emitted a `path: None` entry for a real
735    /// anonymous container (i.e. if the whole feature were dead). This test
736    /// compiles two REAL stories and lets a real content edit do the
737    /// shifting.
738    ///
739    /// Story A has one unlabeled once-only choice (`c0` of knot `alpha`,
740    /// `stamp::stamp_stmt`'s positional counter) — take it, so its target
741    /// container's `path: None` visit/turn entries are real, not
742    /// constructed. Story B inserts a *labeled* choice above it: labeling
743    /// "extra" makes its own id come from the label, not the `c{N}`
744    /// counter, so — unlike an unlabeled insertion, which would just make
745    /// "extra" inherit `pick`'s old `c0` id and silently retarget the load
746    /// to the wrong choice (the "reappear" hazard E157 warns about, not a
747    /// drop) — nothing in program B ends up using the string `"alpha.c0"`
748    /// at all: `extra` is label-derived, `pick` shifted to `c1`. Loading
749    /// story A's save into story B therefore hits a genuine miss.
750    ///
751    /// Two entries, not one: a once-only choice's target container carries
752    /// both `CountingFlags::VISITS` and `COUNT_START_ONLY`, so `save_state`
753    /// emits both a visit *and* a turn entry for it — per this field's own
754    /// doc, a scope whose visit *and* turn count both go unresolved counts
755    /// as two independent losses, not one.
756    #[test]
757    fn a_real_content_edit_shifting_an_anonymous_choice_is_counted_in_load_report() {
758        let (program_a, tables_a) = compile_for_flow(
759            "-> alpha\n\
760             === alpha ===\n\
761             * [pick]\n\
762             \tPicked.\n\
763             \t-> DONE\n",
764        );
765        let program_a = Arc::new(program_a);
766        let mut story_a = crate::Story::<FastRng>::new(Arc::clone(&program_a), tables_a);
767        story_a.continue_maximally().expect("continue");
768        story_a.choose(0).expect("choose `pick`");
769        story_a.continue_maximally().expect("continue");
770
771        let save = story_a.save_state();
772        assert_eq!(
773            save.visits.len(),
774            1,
775            "sanity: exactly `pick`'s own anonymous visit entry: {:?}",
776            save.visits
777        );
778        assert!(
779            save.visits[0].path.is_none(),
780            "sanity: a real unlabeled once-only choice's container really \
781             does save with `path: None`: {:?}",
782            save.visits[0]
783        );
784
785        let (program_b, tables_b) = compile_for_flow(
786            "-> alpha\n\
787             === alpha ===\n\
788             * (extra) [extra]\n\
789             \tExtra.\n\
790             \t-> DONE\n\
791             * [pick]\n\
792             \tPicked.\n\
793             \t-> DONE\n",
794        );
795        let program_b = Arc::new(program_b);
796        let mut story_b = crate::Story::<FastRng>::new(Arc::clone(&program_b), tables_b);
797
798        let report = story_b.load_state(&save);
799        assert_eq!(
800            report.anonymous_states_dropped, 2,
801            "the shifted choice's visit AND turn entry both go unresolved: {report:?}"
802        );
803        assert!(!report.is_clean(), "{report:?}");
804        assert!(
805            report.unresolved_renames.is_empty(),
806            "an anonymous miss has no path to teach a #@was fix against: {report:?}"
807        );
808    }
809}