One decision, step by step¶
When a record says abstain and you expected act, you need to know exactly
which line of code produced it. This page walks through Module.decide in
src/grounded_decision/runtime/module.py in the order it runs, names every
branch, and shows the record each branch produces from a real call.
Read it when you are debugging a surprising verdict or changing decide
itself. It assumes you have read Anatomy of a decision. The checks
are summarised here and explained on their own pages under
The six checks. Words such as "state" and "proposed
action" are defined in the glossary.
All records below come from the fraud_review policy (its chart is drawn in
the next section) and, for degrade, from sensor_fusion. Reproduce them
with uv run grounded-decision examples fraud_review and
uv run grounded-decision examples sensor_fusion, or with the snippets on
this page.
The chart these examples use¶
fraud_review.build() calls two_path_module in examples/common.py. It
produces this chart:
stateDiagram-v2
[*] --> intake
intake --> auto: clean
intake --> review: suspicious
auto --> hold: hold
review --> hold: hold
note right of auto: verdict act, action pass
note right of review: verdict act, action block
note right of hold: verdict abstain, no action
intake has no verdict and no action. auto and review are marked act
and name an action. hold is marked abstain and names none. Any event not
drawn as an arrow from the current state is an unknown event for that state.
The flow of decide¶
flowchart TD
S1["1. Step the chart on event.name"] --> Q1{"Arrow from the<br/>current state?"}
Q1 -- no --> U["Stay put<br/>proposed = None"]
Q1 -- yes --> M["Move to the target<br/>proposed = action on the arrow<br/>or on the target state"]
U --> S2
M --> S2["2. Run all six checks"]
S2 --> Q2{"Any check failed?"}
Q2 -- yes --> B1["Branch 1: abstain<br/>reason = failed checks' summaries"]
Q2 -- no --> Q3{"proposed is set and state<br/>verdict is act or none?"}
Q3 -- yes --> B2["Branch 2: act<br/>reason = all channels passed"]
Q3 -- no --> Q4{"State verdict<br/>is degrade?"}
Q4 -- yes --> B3["Branch 3: degrade<br/>action = the state's action"]
Q4 -- no --> B4["Branch 4: abstain<br/>reason = event not handled, or no action marked"]
Exactly one branch runs. All four return a DecisionRecord. decide never
raises because of weak evidence or an unfamiliar event.
Step 1: move the chart¶
trans = self.chart.step(self._state, event.name)
proposed: str | None = None
if trans is None:
# Unknown event: stay, propose nothing.
state = self._state
else:
self._state = trans.target
state = self._state
proposed = trans.action or self.chart.action_of(state)
chart.step looks for an arrow out of the current state labelled with
event.name. If several match, the one with the highest priority wins. Only
the event's name is used; its payload is copied into the record for
auditors and otherwise ignored.
Three outcomes are possible:
- No matching arrow (event not handled). The state does not change and nothing is proposed. This covers names the policy never uses and names it uses only from other states (an approval before any request). This is not an error. Most such events end in branch 4.
- An arrow with its own action.
proposedis the arrow's action. Only hand-built charts put actions on arrows. - An arrow without an action.
proposedis the action marked on the state the arrow lands in, orNoneif that state has none. Policies compiled from text always use this form: actions sit on states.
The state moves even when the decision is held
The move happens before any check runs, and a failed check does not undo
it. In the thin-chain example the card moves to review and the block is
held. The record's extras["state"] is review. Checks decide whether
the proposal is carried out, not whether the policy advances.
Step 2: run the six checks¶
cert = self._certificate_channel()
game = self._game_channel()
recon = reconstruction_report(evidence.degree, evidence.lambda_hat, limits=self.limits)
thresh = threshold_report(
evidence.n, evidence.p_hat, evidence.p_threshold, limits=self.limits
)
conf = conformal_report(evidence.label_score, evidence.scores, limits=self.limits)
support = support_report(evidence.support, self.limits)
channels = (cert, game, recon, thresh, conf, support)
All six run on every call, even when nothing was proposed, so every record
carries a complete set. The order is fixed. In the record each check appears
as a ChannelReport whose name is the field value in the first column.
name |
Check | Reads | Passes when | Computed |
|---|---|---|---|---|
certificate |
Policy shape | the policy's nesting depth and number of states | depth ≤ max_star_height (3) and states ≤ max_dfa_states (256) |
at build time |
game |
Progress | the solved policy game | from the start, the policy can keep making progress with non-negative average reward | at build time |
reconstruction |
Neighbour agreement | degree, lambda_hat |
\(d\lambda^2 > 1 + \texttt{reconstruction\_margin}\) (1.05) | per call |
threshold |
Knife-edge | n, p_hat, p_threshold |
p_hat lies outside the window around p_threshold |
per call |
conformal |
Unusual cases | label_score, scores |
p-value > conformal_alpha (0.10) |
per call |
support |
Support | support |
share ≥ min_support (0.5), or not measured and require_support off |
per call |
The first two read results cached when the module was built; nothing is
re-solved per call. A policy built from a chart rather than from text has no
expression to measure, so its nesting depth is recorded as 0 and the policy
shape check passes trivially. That is the case for fraud_review.
The module then copies the key numbers into a Health object (evidence
strength, density and window widths, conformal p-value, nesting depth,
whether the progress game was won, support share) so a dashboard can plot
them without unpacking the six reports. See The decision record.
Step 3: pick the branch¶
state_verdict is the verdict marked on the state the chart is now in: act,
abstain, degrade, or none.
Branch 1: a check failed, so abstain and keep the proposal¶
if failed:
reason = "; ".join(c.summary for c in failed)
return DecisionRecord(verdict=Verdict.ABSTAIN, action=None, proposed_action=proposed, ...)
Any failed check wins over everything else, including a degrade state. The
reason joins the one-line summaries of the failed checks only. Passing
checks still appear in channels with passed: true; their absence from
reason does not mean they did not run.
Thin chain, suspicious:
verdict=abstain action=None proposed_action=block state=review
failed: reconstruction, threshold
reason: d λ² = 0.1667 vs 1+0.050 (at or below KS; abstain); evidence density inside operational window (theorem width=4.39e+05, vacuous=True)
If the progress check failed when the module was built, every call takes this branch: the chart still moves and records its state, but nothing is ever carried out.
Branch 2: everything passed and the state allows it, so act¶
if proposed and (state_verdict in {None, Verdict.ACT}):
return DecisionRecord(verdict=Verdict.ACT, action=proposed, proposed_action=proposed,
reason="all channels passed", ...)
Dense ring, suspicious:
A state with no verdict also allows acting, provided something was proposed (a hand-built chart can put the action on the arrow into such a state).
Branch 3: everything passed and the state says degrade¶
if state_verdict is Verdict.DEGRADE:
return DecisionRecord(verdict=Verdict.DEGRADE, action=self.chart.action_of(state),
proposed_action=proposed, reason="statechart degrade", ...)
The policy itself marks some states as "carry out a safer fallback". In
sensor_fusion, an image reconstructed without a uniqueness assumption lands
in qualitative_only, which degrades to publishing a qualitative picture
instead of a quantitative map:
event=unconstrained_model, good electrode contact
verdict=degrade action=publish_qualitative_image proposed_action=publish_qualitative_image
state=qualitative_only reason: statechart degrade
action is the state's action, which may differ from proposed if a
hand-built arrow carried a different one. Degrade happens only when every
check passes. With half the electrodes off the skin, the same event abstains
(branch 1) on failed neighbour agreement and unusualness checks. Degrade is
not a way around weak evidence. Act, abstain, degrade explains
when to use it.
Branch 4: everything passed but there is nothing to carry out¶
return DecisionRecord(verdict=Verdict.ABSTAIN, action=None, proposed_action=proposed,
reason=(f"event {event.name!r} is not handled in state {state!r}; "
"nothing proposed" if trans is None
else "no action marked on current state"), ...)
This is the fall-through. Every check passed, but either nothing was
proposed, or the state is marked abstain. Most of the time it means the
policy is waiting for its next event, not that anything is wrong. The
reason says which of the two situations you are in. Real records from
fraud_review with the dense-ring evidence:
| Situation | State before → after | reason |
|---|---|---|
chargeback (unknown) at intake |
intake → intake |
event 'chargeback' is not handled in state 'intake'; nothing proposed |
hold from review |
review → hold |
no action marked on current state |
chargeback (unknown) at review |
review → review |
event 'chargeback' is not handled in state 'review'; nothing proposed |
a second suspicious at review |
review → review |
event 'suspicious' is not handled in state 'review'; nothing proposed |
None of the four proposes an action. "Not handled in state …" means the
event has no arrow out of the current state: check the event name and the
previous state. "No action marked" means the policy moved to a state that
proposes nothing, and is waiting by design. The reference service also
rejects event names that appear nowhere in the policy before calling
decide (valid_event_names in examples/reference_service.py).
The last row matters for real services. The fraud chart has no suspicious
arrow out of review, so a second flag on the same card proposes nothing
until the saved state is reset. The reference service resets a card to
intake when a reviewer resolves its case. Design your policy so every state
an entity can rest in has arrows for the events it will receive; see
State and policy versions.
Two edge cases worth knowing¶
- Unknown event in a degrade state. It repeats the degrade. In
sensor_fusion, sendingframe_donewhile inqualitative_onlyreturnsdegradewithaction=publish_qualitative_imageandproposed_action=None, because branch 3 looks only at the state's marking. If your service executes everydegrade, it will execute the fallback again. - A failed check with nothing proposed.
holdfromreviewon thin evidence returnsabstain,proposed_action=None, and the failed checks inreason(branch 1). Nothing was blocked, so there is nothing for a person to approve. Act, abstain, degrade explains how to route it.
Every branch at a glance¶
| Branch | Condition | verdict |
action |
proposed_action |
reason |
|---|---|---|---|---|---|
| 1 | any check failed | abstain |
none | what the policy wanted, or none | failed checks' summaries, joined by "; " |
| 2 | all passed, something proposed, state is act or unmarked |
act |
the proposal | the proposal | all channels passed |
| 3 | all passed, state is degrade |
degrade |
the state's action | what the arrow proposed, or none | statechart degrade |
| 4 | all passed, otherwise | abstain |
none | usually none | event … is not handled in state …; nothing proposed or no action marked on current state |
Every record also carries extras["state"], the state after the step. Save it
for the next request.
Reading a surprising record¶
| You see | Most likely cause | What to do |
|---|---|---|
abstain, reason mentions d λ² |
Related cases too few or too weakly agreeing (branch 1) | Check health.d_lambda_sq; see Neighbour agreement |
abstain, reason mentions operational window |
Density too close to the cut-off for this graph size | Check health.p_hat, p_threshold, n; see Knife-edge |
abstain, reason mentions conformal p |
Candidate score unlike the calibration scores | See Unusual cases |
abstain, reason mentions parity_win=False or nonnegative_mean=False |
The progress check failed at build time; every call will abstain | Fix the policy's priorities or payoffs; see Progress |
abstain, event … is not handled in state … |
The event has no arrow out of the previous state | Check the event name and the entity's saved state |
abstain, no action marked on current state |
The policy is in a waiting state by design | Nothing to do; send the next event |
degrade you did not expect |
Entity is resting in a degrade state and received an unknown event | Check the previous state |
abstain, reason mentions support |
The related cases mostly carry the opposite outcome to the proposed action | Check the event your service sent and the action-to-label mapping; see Support |
act on evidence that points the other way |
Support was not measured (support not measured) |
Pass the implied label when you build the evidence; see Support |
The how-to guide Troubleshoot: why did it abstain? expands this table with fixes.
What does not run inside decide¶
- Parsing, compiling and solving the policy game. These happen once, when the module is built. See Architecture.
- The hardness router and the compare helpers. Your code calls them beside
decidewhen it needs them. See Questions the library refuses. - Any clock.
Limits.decide_ms_budgetis a documented target, not a timer. See Limits and performance. - Any change to
eventorevidence. Both are frozen.
Reproduce the records¶
from grounded_decision.examples import fraud_review as fr
from grounded_decision.types import Event
module = fr.build()
ring_logins, ring_outcomes = fr.dense_fraud_ring_logins()
thin_logins, thin_outcomes = fr.thin_rumour_logins()
ring = fr.device_graph_evidence(ring_logins, ring_outcomes, candidate_model_score=0.10)
thin = fr.device_graph_evidence(thin_logins, thin_outcomes, candidate_model_score=0.10)
print(module.decide(Event("suspicious"), ring).verdict) # act (branch 2)
module.reset()
print(module.decide(Event("suspicious"), thin).verdict) # abstain (branch 1)
module.reset()
print(module.decide(Event("chargeback"), ring).reason) # branch 4
The tests that pin these branches are in tests/test_runtime.py
(test_well_corroborated_evidence_acts,
test_thinly_corroborated_evidence_abstains_and_records_proposal,
test_module_state_and_restore) and tests/test_reference_service.py
(test_state_persists_across_requests). If you change a reason string,
update this page, The decision record, and any queries that
match on it.
Next¶
- Act, abstain, degrade: what your service does with each branch's record.
- The decision record: every field of the returned object.
- Grammar compile and Games and solvers: what happens when the module is built.