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:
-
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.
-
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 apub 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. -
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 aneeds_rebuilddirty 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()callson_card_entered()/on_card_left()on every cache,engine/src/game/state.rs:2122–2135. - tap —
tap_permanent()callson_tap(),engine/src/game/state.rs:656–660. - untap —
untap_permanent()callson_untap(),engine/src/game/state.rs:886. - undo / rewind — caches
clear()ed inUndoLog::rewind_to_choice_point(),src/engine/src/undo/log.rs; plus lazymark_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:3055–3058andengine/src/game/state.rs:1262. - lazy rebuild on read — query path checks
needs_rebuildand callsrebuild_from_battlefield(),engine/src/game/state.rs:1249+.
- card enters/leaves the battlefield —
- 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 theGameStatewrite chokepoints (move_card,tap_permanent,untap_permanent). Themana_cachesfield itself ispub, 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, bypassingon_tap:engine/src/game/state.rs:4868(execute_delayed_effect,DelayedEffect::Return“return tapped”): aftermove_cardrecords the card as untapped, the code doescard.tapped = true;directly — neitheron_tap()norincrement_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: callself.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.tappedis 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 bycached_player+cached_version.engine/src/game/mana_engine.rs:187–214. - 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 atstate.rs:662/891), and on undo (src/engine/src/undo/log.rs). The engine comparescached_version == game.mana_state_versionon 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: COMPLIES — as long as cache 1’s
Card.tappedleak is closed. The version counter is bumped at exactly the chokepoints; the one directcard.tapped = trueatstate.rs:4868also 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:64–250; stored as thecachefield atengine/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 viarefresh_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_lowercaseplus precomputed targeting flags (targets_creature,requires_target, …) on anActivatedAbility.engine/src/core/effects/activated_ability.rs:8–26; 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 → bucketmap.engine/src/game/mana_index.rs:159–186. Its module doc describes a full lazy dirty-bit invalidation strategy. - Status:
pub mod mana_indexand apub usere-export exist (engine/src/game/mod.rs:36,:82), but nothing in the engine ever instantiates or stores aManaProducerIndex— the only::new()calls are in its own unit tests. It is parallel, never-wired-in machinery that overlaps entirely withManaSourceCache(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
ManaSourceCacheonto 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
| Cache | What | Verdict |
|---|---|---|
ManaSourceCache (1) | per-player mana sources | MOSTLY COMPLIES — one LEAKY caller (state.rs:4868) |
ManaEngine version memo (2) | mana resolution scratch | COMPLIES once (1) is fixed (shares the leak) |
CardCache (3) | card-definition flags | COMPLIES |
AbilityCache (4) | ability targeting flags | COMPLIES (immutable) |
ManaProducerIndex (5) | duplicate mana index | DEAD CODE / DRY violation — delete or consolidate |
LogWrapCache (6) | TUI render wrap | OUT 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.