Step cache
Step identity, cache storage and attempt budgets. For engineers reasoning about step reuse, inspecting the repository cache and tuning budgets.
The cache is a repository artifact: one .py file per step, addressed
deterministically; the cache is always read, writes are best-effort.
Addressing
Identity triple
| Component | Source | Effect on identity |
|---|---|---|
| cache_key | the main object constructor argument | a different key — a different step |
| step type | action vs assertion | the same sentence as action and as assertion — two steps |
| normalized sentence | NFC, trim, whitespace collapse, casefold | «Click Sign in» equals «click sign in »; a Russian sentence and its English translation are different steps |
A missing cache entry for the computed address is a cache miss — the step is generated, not an error.
Normalize and address
from prettyplay.cache import StepIdentity, normalize_step_text
normalized = normalize_step_text(" Click Sign In ")
identity = StepIdentity(
cache_key="login-flow",
step_type="action",
normalized_text=normalized,
)
# identity.filename — the deterministic digest of the triple
Layout
The cache root defaults to <cwd>/.prettyplay/cache/ — the working directory
of the run — and is set by cache_root. The optional cache_path subdirectory
argument is part of the address: steps never leak across subdirectories;
without a subdirectory, equal cache keys are reused across tests. One .py
file per step.
Storage
Read and write
from prettyplay.cache import CachedStep, StepCache
from prettyplay.config import Config
from prettyplay.reporting import StepReporter
cache = StepCache(config=Config(), path="checkout", reporter=StepReporter(hooks=[]))
step = cache.load(identity) # None on a cache miss
if step is None:
# a miss means: generate the step, then store it
cache.save(CachedStep(identity=identity, code=step_code, created_at="2026-09-07"))
File format
Each file carries the metadata fields (step sentence, cache key, step type,
creation date), then the # --- step code --- sentinel line, then the
generated step code — top-level imports when present, then the fixed-form
def step function (see Driver facade), restored
verbatim on load. Files carry no library version: an
upgrade never invalidates them mechanically. The code inside targets the
standard Playwright sync API, so it keeps replaying across upgrades that
keep that API — the one recorded break is the switch to the genuine page:
caches written against the retired page facade call methods that no longer
exist (an AttributeError at replay, a hard failure in strict mode), so
purge the cache directory once when upgrading across that change.
Write behavior
savenever fails the run: a read-only cache or a busy Windows target skips the write loudly- writes are atomic: a unique temporary file in the target directory, then an atomic replace; the last writer wins, a partial file never becomes visible
loadalways works, in every environment
The compliance gate before caching
Every successfully executed candidate — generation, healing, funded
regeneration and the steering write-back alike — passes the two-dimension
compliance check (check_step_compliance) before the cache save: the code
against the project's user instructions and against the step sentence
itself (step adequacy, judged from the step type and the verbatim per-step
attempt history) — an unchecked candidate is never stored. A
high finding in either dimension fails the attempt and the retry carries
the violation text with the grown history; a
malformed verdict or a provider failure is a loud hard failure — the gate
never degrades into a silent pass.
Replayed cached code is never re-gated: the step address takes no part of the
instructions, so changing generation_prompt does not invalidate cached
steps — purge the cache manually when the instructions change. The
generation_approve switch (or an empty generation_prompt) restores the old
behavior with zero extra LLM calls. See
Configuration.
Attempt budgets
Generation and healing attempts are budgeted per step per test.
Consume attempts
from prettyplay.cache import RunBudgets
budgets = RunBudgets(generation_limit=3, healing_limit=2)
if budgets.try_generation(identity):
... # attempt allowed; False — budget exhausted, the caller reports incurability
Semantics
- Budgets are per step per test: every test owns its registry and starts with full limits — a step reused across tests gets a fresh budget in each test (N tests running one step in a process spend N × attempts in total)
- Defaults: 3 generation attempts, 2 healing attempts — configurable via
generation_attempts/healing_attemptsin the project settings - An exhausted budget is the incurable failure, never an infinite loop
- Step groups add a per-group recovery-cycle cap — capped by
healing_attempts, keyed by the group prompt — and grant each row step a fresh full healing pool per cycle; the ordinary per-step pools of non-group steps are never consumed by a group recovery (see Groups) - Budgets exist only in the memory of the running process — nothing is persisted
Team workflow and CI
Generate locally where the LLM is reachable → commit the cache directory → CI
runs the whole suite from the cache with no LLM keys at all — strict = true
turns that posture into a guarantee: a cache miss fails the run instead of
silently generating. See Configuration.