Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Caching Policy

This chapter states the engine-wide policy for derived caches — any value the engine stores so it can answer a question cheaply instead of recomputing it from scratch — and surveys every cache that currently exists. It is the canonical reference that individual caching sites cite in their code comments (see How code cites this doc at the end).

A cache is an optimization, and an optimization is only legitimate if it is indistinguishable from the slow path. The entire risk of caching is that the cache and the truth drift apart. This document exists to make that drift structurally impossible, not merely unlikely.

The policy

Caching is an internal property of an object. A cached summary — a “no-keyword-grants” boolean, a per-player mana-availability index, a precomputed type-flag bitset — is far cheaper to check than the full scan it replaces. But cheapness buys correctness only under three rules, which together make up the policy:

  1. Every mutation invalidates (or updates) the cache. There is no write to the backing state that leaves the cache stale. A write either refreshes the cached value in place (incremental update) or marks it dirty so the next read rebuilds it (lazy invalidation). Both are fine; silently leaving it stale is the bug.

  2. All writes go through public API methods; there is no direct external state manipulation that bypasses them. If outside code can reach in and mutate the backing field directly — card.tapped = true, push onto a pub Vec, etc. — then invalidation can be skipped, and rule 1 is only advisory. The backing state must be private (or only mutated through methods that maintain the invariant), so that “go through the API” is the only physically available path, not a convention people remember.

  3. The cache and its invalidation live behind that object’s API. The rest of the engine interacts with the object only through methods that maintain the invariant. Callers never see the cache, never manage its freshness, and never need to know it exists. A query method is free to lazily rebuild before answering; a write method is obligated to invalidate. The cache is a private implementation detail of “answer this question quickly,” not a shared mutable field that every call site must remember to poke.

The invariant

Cache is valid ⟺ every mutation has gone through an invalidating API method.

Read both directions. Left-to-right: if the cache is valid, then no mutation bypassed invalidation — so a bypassing write is, by definition, a correctness bug. Right-to-left: if every mutation goes through an invalidating method, the cache is valid — so the encapsulation (rule 2) is what earns the right to trust the cache. A cache whose backing state has a public bypass does not satisfy the invariant even if every current call site happens to invalidate, because the next call site added is one field = value away from silent staleness.

This is the same discipline the project applies to GameState rewind safety (see Deterministic Simulation and the project CLAUDE.md → “Prefer functional/immutable style”): a #[serde(skip)] derived cache is acceptable precisely because it is rebuilt deterministically from serialized truth after load. The caching policy is the generalization: a cache is acceptable when it is provably a pure function of state that every mutation path maintains.

Survey of existing caches

The engine currently has five derived caches plus one version-counter memoization. Each entry below records: what it caches, where it is invalidated, whether its writes are encapsulated, and its compliance verdict against the invariant above. File:line citations are as of 2026-06-15_#3523(ad87fb5af).

1. ManaSourceCache — per-player mana sources (the mana engine cache)

  • What: per-player lists of mana-producing permanents bucketed by color (white_sources, …, complex_sources) plus precomputed untapped counts per color, and a needs_rebuild dirty flag. engine/src/game/mana_source_cache.rs:20.
  • Stored: GameState.mana_caches: Vec<(PlayerId, ManaSourceCache)>, engine/src/game/state.rs:38, marked #[serde(skip)] — derived, rebuilt from the battlefield after deserialization.
  • Invalidated / updated at:
    • card enters/leaves the battlefield — move_card() calls on_card_entered() / on_card_left() on every cache, engine/src/game/state.rs:21222135.
    • tap — tap_permanent() calls on_tap(), engine/src/game/state.rs:656660.
    • untap — untap_permanent() calls on_untap(), engine/src/game/state.rs:886.
    • undo / rewind — caches clear()ed in UndoLog::rewind_to_choice_point(), src/engine/src/undo/log.rs; plus lazy mark_dirty() on clone, engine/src/game/state.rs:5420.
    • deserialization / resume — ensure_mana_caches_for_all_players() rebuilds the empty post-load caches, engine/src/main.rs:30553058 and engine/src/game/state.rs:1262.
    • lazy rebuild on read — query path checks needs_rebuild and calls rebuild_from_battlefield(), engine/src/game/state.rs:1249+.
  • Encapsulation: the cache’s own mutators (on_card_entered, on_card_left, on_tap, on_untap, mark_dirty, clear, rebuild_from_battlefield) are the only way to change the cached lists, and they are driven from the GameState write chokepoints (move_card, tap_permanent, untap_permanent). The mana_caches field itself is pub, which is a latent hazard (see verdict).
  • Verdict: MOSTLY COMPLIES, with one LEAKY caller. The cache + invalidation design is correct and the normal write paths maintain it. But the backing tap state (Card.tapped) is not fully private, and one site mutates it directly, bypassing on_tap: engine/src/game/state.rs:4868 (execute_delayed_effect, DelayedEffect::Return “return tapped”): after move_card records the card as untapped, the code does card.tapped = true; directly — neither on_tap() nor increment_mana_version() runs, so the mana cache continues to count that permanent as an available untapped source. This is precisely a rule-2 violation: a write that did not go through the invalidating API. Fix: call self.tap_permanent(card_id) instead of the raw assignment (tracked as a follow-up; see the keyword-grant proposal note about the same chokepoint discipline).

Is the mana engine cache’s invalidation airtight? Almost. The cache machinery is airtight; the encapsulation around Card.tapped is not. The one direct-write site above can make the cache over-report available mana. The structural fix is the policy itself: make the only way to change tap state a method that invalidates, so the bypass cannot be written.

2. ManaEngine version-counter memoization

  • What: memoized mana-resolution scratch (simple_sources, complex_sources, simple_capacity, mana_sources, greedy_resolver) for the last queried player, guarded by cached_player + cached_version. engine/src/game/mana_engine.rs:187214.
  • Invalidation: an epoch counter, GameState.mana_state_version (engine/src/game/state.rs:103). It is bumped on every battlefield change (move_card, engine/src/game/state.rs:2140), on tap/untap (increment_mana_version(), state.rs:627, called at state.rs:662/891), and on undo (src/engine/src/undo/log.rs). The engine compares cached_version == game.mana_state_version on each query (engine/src/game/mana_engine.rs:613) and rebuilds on mismatch.
  • Encapsulation: callers use update() / can_pay(); they never touch the cache or the version. increment_mana_version() is the single API for invalidation and is called from the same chokepoints as cache 1.
  • Verdict: COMPLIESas long as cache 1’s Card.tapped leak is closed. The version counter is bumped at exactly the chokepoints; the one direct card.tapped = true at state.rs:4868 also skips the version bump, so the same single fix repairs both caches. The epoch-counter pattern is a clean example of rule 3: invalidation is “increment one integer,” entirely behind the write API.

3. CardCache — precomputed card-definition flags

  • What: precomputed boolean/character-istic flags derived from a card’s definition — type checks (is_land, is_creature, …), mana-production upper bound, spell-targeting restrictions, ETB-choice flags, land subtypes, enters_tapped, etc. engine/src/core/card.rs:64250; stored as the cache field at engine/src/loader/card.rs:767.
  • Invalidated / recomputed at: card construction (engine/src/loader/card.rs:405), after all abilities parse (loader/card.rs:1224), and on a type-line change via refresh_type_cache() / update_from_types() (engine/src/core/card.rs:1618), which is invoked when animate/typeline effects change a card’s types (engine/src/game/state.rs:750).
  • Encapsulation: effectively immutable after load; the only post-load mutation path is refresh_type_cache(), the API boundary for type changes.
  • Verdict: COMPLIES. Type-changing effects must route through refresh_type_cache(); any future effect that mutates a card’s types without it would be a rule-2 violation to watch for.

4. AbilityCache — precomputed ability-targeting flags

  • What: description_lowercase plus precomputed targeting flags (targets_creature, requires_target, …) on an ActivatedAbility. engine/src/core/effects/activated_ability.rs:826; field at line 134.
  • Invalidated at: never — populated in every ActivatedAbility::new_* constructor (activated_ability.rs:145, 165, 191, …) and immutable thereafter; ability descriptions are never edited post-creation.
  • Verdict: COMPLIES (trivially). An immutable cache cannot go stale; there is nothing to invalidate. This is the cheapest way to satisfy the invariant — make the backing data immutable.

5. ManaProducerIndex — alternative mana index (DEAD CODE)

  • What: a second, independent mana index: seven color buckets, each with its own dirty bit and cached untapped count, plus a CardId → bucket map. engine/src/game/mana_index.rs:159186. Its module doc describes a full lazy dirty-bit invalidation strategy.
  • Status: pub mod mana_index and a pub use re-export exist (engine/src/game/mod.rs:36, :82), but nothing in the engine ever instantiates or stores a ManaProducerIndex — the only ::new() calls are in its own unit tests. It is parallel, never-wired-in machinery that overlaps entirely with ManaSourceCache (cache 1).
  • Verdict: NOT APPLICABLE / DRY VIOLATION. It is not part of the live cache surface, so it has no compliance obligation — but it is duplicate infrastructure (two cache designs for the same job) and a maintenance hazard: a reader can’t tell which is authoritative. Recommendation: delete it, or consolidate ManaSourceCache onto it, so there is one mana index. Tracked as a cleanup follow-up.

6. LogWrapCache — TUI render cache (out of scope)

  • What: precomputed word-wrap layouts for game-log lines, to avoid re-wrapping every frame. engine/src/game/fancy_tui_renderer.rs:274, field at :868.
  • Verdict: OUT OF SCOPE. This is a pure rendering/presentation cache; it does not back game state and a stale entry cannot cause a desync or a rules error (only a cosmetic re-wrap). It is rebuilt on log change in the render cycle. Noted for completeness; the policy targets game-state caches.

Compliance summary

CacheWhatVerdict
ManaSourceCache (1)per-player mana sourcesMOSTLY COMPLIES — one LEAKY caller (state.rs:4868)
ManaEngine version memo (2)mana resolution scratchCOMPLIES once (1) is fixed (shares the leak)
CardCache (3)card-definition flagsCOMPLIES
AbilityCache (4)ability targeting flagsCOMPLIES (immutable)
ManaProducerIndex (5)duplicate mana indexDEAD CODE / DRY violation — delete or consolidate
LogWrapCache (6)TUI render wrapOUT OF SCOPE (presentation only)

The single concrete defect to fix is the direct card.tapped = true at engine/src/game/state.rs:4868; replacing it with self.tap_permanent(...) closes the leak for both mana caches at once.

How code cites this doc

Every caching site must make its compliance auditable from the code itself. The convention:

  • At the cache’s declaration (the struct field or stored cache), add a comment pointing here and naming the invalidation surface:

    #![allow(unused)]
    fn main() {
    /// Derived cache. Caching policy: docs/guide/src/part2/caching_policy.md.
    /// Invariant: cache is valid IFF every mutation goes through an invalidating
    /// API method. Complies: invalidated in `on_card_entered`/`on_card_left`
    /// (move_card), `on_tap`/`on_untap` (tap_permanent/untap_permanent),
    /// rebuilt in `rebuild_from_battlefield`.
    #[serde(skip)]
    pub mana_caches: Vec<(PlayerId, ManaSourceCache)>,
    }
  • At each write chokepoint that maintains the cache (e.g. move_card, tap_permanent), a one-line comment asserting it upholds the invariant, so a future editor sees the obligation before adding a new mutation:

    #![allow(unused)]
    fn main() {
    // Caching policy (part2/caching_policy.md): this is a cache write chokepoint.
    // Any new battlefield mutation here MUST update mana_caches + bump
    // mana_state_version, or it breaks the invariant.
    }
  • The assertion is “complies: invalidated in <fns> — name the actual functions, so the claim is checkable: a reader can open those functions and confirm they invalidate. A bare “// cached for perf” comment is not acceptable; it gives a future editor no way to know what they must maintain.

These comments are not added by this document’s change — adding them is deferred implementation work (Phase B). This section defines the convention so that Phase B, and every new cache, follows it.