Counterfactual forks¶
A counterfactual is a what-if branch off a recorded run: at step N, mutate the
state (or facts, or rule pack, or a node output), then re-execute from there.
The original run is untouched — Stargraph enforces the Temporal "cannot change the
past" invariant: every cf-derived checkpoint lives under a fresh run_id and a
domain-separated graph_hash.
The mutation builder¶
CounterfactualMutation (in stargraph.replay.counterfactual) is the typed
Pydantic builder for the five FR-27 fields a caller may override at the cf
fork point. extra="forbid" rejects unknown keys at construction time so
typos are loud:
from stargraph.replay.counterfactual import CounterfactualMutation
mutation = CounterfactualMutation(
state_overrides={"risk_score": 0.95}, # patch state at fork step
facts_assert=[{"head": "high-risk"}], # add CLIPS facts
facts_retract=[{"head": "low-risk"}], # remove CLIPS facts
rule_pack_version="2.4.0", # pin a different rule pack
node_output_overrides={"classifier": {"label": "deny"}},
)
All fields default to None; the empty mutation is a valid no-op probe — useful
for asking "does this run look the same under cf-replay semantics with no
semantic change?" The derived hash still differs by virtue of the
domain-separation tag.
Forking a run¶
GraphRun.counterfactual() mints the cf child:
from stargraph.graph import GraphRun
cf_run = await GraphRun.counterfactual(
checkpointer,
run_id="run-abc", # the original run
step=5, # fork point
mutate=mutation,
)
# cf_run.run_id is fresh ("cf-<uuid4>"); cf_run.parent_run_id == "run-abc".
summary = await cf_run.wait() # drive cf branch to terminal
Five things happen inside counterfactual() (per design §3.8.4):
- Load the original checkpoint at
step(loud-fail on miss viaCheckpointError(reason="missing-step")). - Compute the cf-derived
graph_hashviaderived_graph_hash(...):sha256(b"stargraph-cf-v1\x00" + original_hash + b"\x00" + jcs(mutation)). The 12-byte tag prefix lives only in the pre-image — the on-the-wire artifact is the 64-char hex digest. - Mint a fresh
run_id(f"cf-{uuid.uuid4()}") so cf checkpoints never shadow original rows. - Apply
mutation.state_overridesto the fork-step state in memory only — the original checkpoint row is byte-identical at its(run_id, step)coordinate. - Return a fresh
GraphRunbound to the newrun_id, pointing at the cf-derivedgraph_hashviaparent_run_id.
Resume refuses cf-prefix¶
GraphRun.resume() deliberately refuses checkpoints whose graph_hash starts
with stargraph-cf-v1 — a cf-derived row is not eligible for resume against the
parent run_id (AC-3.4):
from stargraph.errors import CheckpointError
try:
await GraphRun.resume(checkpointer, run_id=cf_run.run_id, graph=graph)
except CheckpointError as exc:
assert exc.reason == "cf-prefix-hash-refused"
If you want to continue a cf branch, call resume() against the cf run_id
itself (the cf branch's own checkpoints carry the cf-prefix hash and live
under the cf run_id, so the parent-run refusal is what's blocked, not
self-resume).
Direct hash computation¶
Most callers use GraphRun.counterfactual() and never touch the hash
directly, but derived_graph_hash is exposed for test fixtures and audit
tools that need to predict the cf identity before forking:
from stargraph.replay.counterfactual import derived_graph_hash
cf_hash = derived_graph_hash(graph.graph_hash, mutation)
# cf_hash is the same digest GraphRun.counterfactual() will pin for this fork.
Two mutations with identical fields produce identical cf-hashes — the
rfc8785.dumps(...) JCS canonicalization makes the hash insensitive to
dict-key insertion order.