Skip to content

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. proposed is the arrow's action. Only hand-built charts put actions on arrows.
  • An arrow without an action. proposed is the action marked on the state the arrow lands in, or None if 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

failed = [c for c in channels if not c.passed]
state_verdict = self.chart.verdict_of(state)

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:

verdict=act  action=block  proposed_action=block  state=review
reason: all channels passed

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, sending frame_done while in qualitative_only returns degrade with action=publish_qualitative_image and proposed_action=None, because branch 3 looks only at the state's marking. If your service executes every degrade, it will execute the fallback again.
  • A failed check with nothing proposed. hold from review on thin evidence returns abstain, proposed_action=None, and the failed checks in reason (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 decide when it needs them. See Questions the library refuses.
  • Any clock. Limits.decide_ms_budget is a documented target, not a timer. See Limits and performance.
  • Any change to event or evidence. 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