Anatomy of a decision¶
A risk model flags a card payment as suspicious. Should the system block the card right now, or send the case to a person? This page follows that one decision through the library from start to finish: what goes in, what is checked, what comes out, and what your service does with the result.
It is written for someone deciding whether to adopt the library and for the
engineer who will wire it in. It assumes you know Python and your own service,
and nothing about the mathematics behind the checks. Every number on the page
comes from uv run grounded-decision examples fraud_review. Each section ends
with a link to the page that goes deeper.
The whole path in one picture¶
flowchart LR
E["Event<br/>suspicious"] --> S["Policy state<br/>intake → review"]
S --> P["Proposed action<br/>block"]
EV["Evidence<br/>numbers about the<br/>related cases"] --> C
P --> C{"Five checks<br/>all must pass"}
C -- "all pass" --> A["Verdict: act<br/>action = block"]
C -- "any fails" --> H["Verdict: abstain<br/>block is held"]
A --> R["DecisionRecord<br/>(saved as JSON)"]
H --> R
R --> RT["Routing in your service<br/>execute / human review / wait"]
In words:
- An event arrives. Your service names what happened, using a name the
policy knows:
suspiciousorclean. The library does not compute this name. Your risk model, rule or upstream system does. - The policy moves. The policy is a short chart of states and arrows
(see Write a policy). Each entity, here a
card, has a current state. The event moves it along one arrow:
intaketoreview. - The policy proposes an action. The state it lands in names what the
policy would do next if nothing else were checked:
block. This proposed action is always recorded, whatever happens next. - Evidence is attached. Your service also sends a few numbers that
describe the cases related to this one: here, the accounts that logged in
from the same phones as the flagged account, and what their past fraud
reviews found. This bundle is the evidence (
EvidenceSnapshot). - Five checks run. Each answers one yes/no question about the policy or the evidence. If any answer is no, the proposal is held.
- A verdict is returned.
act(carry out the proposal),abstain(do not; hold it), ordegrade(carry out a safer fallback the policy names). - A record is produced. The
DecisionRecordholds the verdict, the proposed action, the outcome of every check, the numbers each check used, and the entity's new state. You save it as JSON. - Your service routes it.
actexecutes. Anabstainwith a proposal goes to a person. Anabstainwith nothing proposed means the policy is waiting for its next event.
One worked example: two identical flags, two different answers¶
The fraud_review example sends the same event, suspicious on an $840
electronics purchase, twice. Only the evidence differs.
- Dense ring. The flagged account sits among 16 accounts that cycle through 8 phones. 13 of them were confirmed as fraud in past reviews, and linked accounts mostly share the same outcome.
- Thin chain. The flagged account is one of 4 accounts linked through a chain of 3 shared laptops, such as an internet cafe. Linked accounts barely predict each other's outcomes.
Here is what the library computed for each. The evidence numbers are filled
in from raw (account, device) login rows by snapshot_from_graph (see
Build evidence from your data).
| Quantity | Dense ring | Thin chain | What it means |
|---|---|---|---|
Accounts in the neighbourhood (n) |
16 | 4 | How many related cases there are |
| Shared-device links | 40 | 3 | Lines between cases that share a device |
Average links per account (degree, \(d\)) |
5.0 | 1.5 | How connected each case is |
Neighbour agreement (lambda_hat, λ) |
0.55 | 0.333 | How much more often linked accounts share an outcome than chance; 0 means no better than chance |
| Evidence strength (\(d\lambda^2\)) | 1.5125 | 0.1667 | Must be above 1.05 for the related cases to count as informative |
Link density (p_hat) |
0.333 | 0.5 | Fraction of possible links that exist |
Density cut-off (p_threshold) |
0.08 | 0.08 | The density at which rings and innocent clusters are hardest to tell apart |
| Knife-edge half-width | 0.209 | 0.5 | How far from the cut-off the density must be to trust it; wider for small graphs |
| Unusualness p-value | 1.0 | 1.0 | Must be above 0.10; low means unlike recent confirmed cases |
And the decisions:
| Dense ring | Thin chain | |
|---|---|---|
| State after the event | review |
review |
| Proposed action | block |
block |
| Failed checks | none | neighbour agreement, knife-edge |
| Verdict | act |
abstain |
| Action carried out | block |
none |
| Routing | execute the block | human review, with the reason |
The thin chain fails two checks. Its evidence strength, 0.1667, is far below
1.05: four accounts that barely agree tell you almost nothing about a fifth.
Its density, 0.5, is only 0.42 from the cut-off of 0.08, and a graph of four
accounts is so small that the library will not trust any density within 0.5
of the cut-off. A naive rule that blocks every suspicious event freezes this
customer's card. The grounded module holds the block and sends it to a
reviewer together with the 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)
The unusualness check passes in both cases here
The example hard-codes the model's score for this payment at 0.10, below every one of the 40 calibration scores (0.40 to 1.18), so its p-value is 1.0 in both runs. That check does real work only when the candidate score is computed from your model. See Have we seen cases like this?.
The six checks, in one line each¶
Every decision runs all six, in this order. Each has its own page under The six checks.
Check (record field name) |
Question it answers | Input | Computed |
|---|---|---|---|
Policy shape (certificate) |
Is the policy simple enough to accept? | The policy text | Once, at build time |
Progress (game) |
Can the policy keep running from its start state? | The policy chart | Once, at build time |
Neighbour agreement (reconstruction) |
Do related cases agree strongly enough to be informative? | degree, lambda_hat |
Every decision |
Knife-edge (threshold) |
Is the link density far enough from the cut-off to trust? | n, p_hat, p_threshold |
Every decision |
Unusual cases (conformal) |
Does this case look like recent cases where the answer was right? | label_score, scores |
Every decision |
Support (support) |
Do the related cases point the same way as the proposed action? | support |
Every decision |
The two policy checks rarely fail at decide time. A policy that is too complex or ambiguous is refused when you build the module, before any decision. The progress check fails policies that can loop forever without proposing anything; its page explains exactly what it does and does not catch. In practice, the four evidence checks decide most holds.
What the verdict does not tell you¶
Direction is checked only if you measure it
Which action to propose comes from the event your service sends; choose
it with your model or rule. Only the support check compares the
direction of the evidence with the proposed action, and only when the
evidence carries a support share. In fraud_review it does: send
clean with the same dense fraud ring and the module holds the
pass, because only 3 of the 16 related accounts were cleared
(support 0.19 vs minimum 0.50 (does not support the proposed action)).
Without a support share, the same clean event acts pass: the
related cases are informative and the density is stable, so every
other check passes.
An act verdict therefore means "the policy proposed this action, and the
evidence around the case was strong, stable, familiar and (if measured)
pointing the same way." It does not mean the action is correct or that a
theorem guarantees the outcome. See
Act, abstain, degrade for what each verdict does and does not
establish, and Trust levels and honest limits for how much
mathematics stands behind each check.
Built once, decided per request¶
Some of the work happens once, when your service starts. The rest happens on every event. This split is why a decision is cheap: on the machine used for Limits and performance, the fraud module builds in about 9 µs and decides in about 7 µs.
| When | What happens | Can fail how |
|---|---|---|
| Build, once per policy version | Parse the policy text, check how deeply it nests, compile it to a chart of states, solve the progress check | Raises an error for an illegal or oversized policy; a progress check that cannot be solved is recorded as failed |
| Per request | Restore the entity's saved state, take one step on the event, run the four evidence checks, read the cached policy checks, build the record | Never raises for weak evidence or unknown events; it abstains and says why |
| After the request | Save the record and the entity's new state | Your database |
A Module holds the current entity's state while it decides, so one instance
must not serve two requests at the same time. Restore the state under a lock,
or give each worker its own module. State and policy versions
covers this, and the pitfall that editing a policy renumbers its states.
Where to go next¶
- One decision, step by step:
Module.decideline by line, every branch and the record each one produces. - The six checks: each check's question, rule, worked numbers and limits.
- Act, abstain, degrade: what to do with each verdict, and the two kinds of abstain.
- The decision record: every field, a real JSON record, what to store.
- State and policy versions: saving state between requests, threads, and policy edits.
- Build a decision service: the runnable reference service built around this example.
- Fraud review: the full example case.