Projections and snapshots¶
An algorithm holds no state of its own. When an event arrives, it asks for the run's projection: state computed by folding the run's events.
flowchart LR
S[(Event log)] --> |"snapshot @ seq 128"| P{{Projection.load}}
S --> |"tail: events 129..140"| P
P --> ST[State object]
ST --> A[Algorithm decides]
Writing a projection¶
A projection is an initial state, a fold function, and (to enable snapshots) a name and state type.
from pydantic import BaseModel, Field
from aaa.events import Event, Projection
class Translations(BaseModel):
wanted: list[str] = Field(default_factory=list)
done: dict[str, str] = Field(default_factory=dict)
def apply(state: Translations, e: Event) -> Translations:
if e.type == "aaa.run.started":
state.wanted = e.data["input"]["languages"]
elif e.type == "aaa.llm.completed":
state.done[e.data["tag"]] = e.data["response"]["message"]["content"]
return state # mutate-and-return is fine: the state object is private
TRANSLATIONS = Projection(
Translations,
apply,
name="acme.translations", # unique snapshot key
version=1, # bump when the fold or state shape changes
state_type=Translations, # how to (de)serialize the state
every=64, # snapshot after this many events were folded
)
In an algorithm: state = await ctx.project(TRANSLATIONS).
Rules for a fold:
- Deterministic. Same events in, same state out. No clocks, randomness or I/O.
- Total. Ignore event types you do not care about.
- Compact. The state is serialized to JSON on every snapshot; do not accumulate unbounded data in it. Store ids, counters and the last request id, not transcripts.
How snapshots work¶
Projection.load does:
- Load the latest snapshot for
(stream, name)if one exists, itsversionmatches, and itsseqis not ahead of the log. - Read only the events after the snapshot's
seqand fold them. - If at least
everyevents were folded, save a new snapshot at the latestseq.
Snapshots are derived data, so every failure degrades to a replay instead of an error:
| Situation | Result |
|---|---|
| No snapshot | Replay from event 0. |
| Corrupt JSON | Ignored (logged), replay. |
version mismatch (you changed the fold) |
Ignored, replay. |
seq ahead of the log (stale/foreign) |
Ignored, replay. |
| Two workers save simultaneously | Saves are monotonic by seq; an older snapshot never overwrites a newer one. |
All four stores (memory, SQLite, Postgres, Redis) implement snapshots.
What it buys you¶
A 40-turn tool loop, folding with and without snapshots (every=8):
| State folds | |
|---|---|
| No snapshots | 6,724 |
| Snapshots | 407 |
The gap grows with run length: without snapshots every handled event refolds the whole history (quadratic); with them it folds only the tail. This, together with delta requests, keeps long runs linear.
Built-in projections¶
| Projection | State | Used by |
|---|---|---|
aaa.algos.loops.LOOP |
request id, turn count, pending tool calls and results | ToolLoop, ReAct |
aaa.algos.refine.REFINE |
last request id, candidate, iteration count | GenerateTestRepair, GeneratorCritic |
aaa.algos.plan.PLAN |
steps, statuses, results, replan count | PlanAndExecute |
aaa.algos.control.WF, FSM_STATE |
node results; FSM path | WorkflowGraph, FSM |
aaa.algos.ensemble.SAMPLES |
raw samples and failure count | SelfConsistency |
aaa.runtime.engine.RUNMETA |
spend, completion, open children | the engine itself |
Because the engine's own view of a run is a projection too, any process can adopt any run: that is what makes clusters possible.