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}