The decision record¶
Every call to decide returns one object, a DecisionRecord. Its JSON form
is what your service stores, what a reviewer reads when a case is held, and
what an auditor queries months later. This page explains every field, shows a
real record, and says what to store with it and how to read its reason and
health fields.
It is for the engineer who designs the table that holds decisions and for
anyone who reads records to explain a decision. It assumes you have read
Anatomy of a decision. The record on this page is the thin-chain
case from uv run grounded-decision examples fraud_review: a suspicious
flag on an $840 purchase whose card is linked to only 4 accounts, held for
review.
The short version¶
Most of the time you need six fields:
{
"verdict": "abstain",
"action": null,
"proposed_action": "block",
"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)",
"module": "fraud_review",
"extras": {"state": "review"}
}
Read it as: the fraud_review policy wanted to block the card; it did not
(action is null) because two checks failed, named in reason; the card is
now in the review state, which you must save for its next event. The rest of
the record (the event, the six check reports and the health numbers) says
exactly what was measured.
Fields¶
DecisionRecord.to_dict() returns these keys, in this order:
| Field | Type | Meaning |
|---|---|---|
verdict |
"act", "abstain" or "degrade" |
The answer. See Act, abstain, degrade. |
action |
string or null | What to carry out. Set only on act (the proposal) and degrade (the fallback). Null on every abstain. |
proposed_action |
string or null | What the policy wanted, recorded whatever the verdict. Null when the policy had nothing to propose. |
reason |
string | One line. See Reading reason. |
module |
string | The name of the module (policy) that decided. |
event |
object | {"name", "payload"} exactly as you sent it. The payload is copied for the audit trail and never read by the checks. |
channels |
list of 6 objects | One report per check, always six, always in the order certificate, game, reconstruction, threshold, conformal, support. |
health |
object | The key numbers from the six reports, flattened for dashboards. See Reading health. |
envelope |
string | The hardness label of the module (p_exact for every example). decide does not use it; see Questions the library refuses. |
extras |
object | state: the entity's state after this event. Ignore keys you do not recognise. |
In Python, record.acted is a shortcut for verdict is Verdict.ACT. It is not
in the JSON.
Each check's report¶
Each entry in channels has the same shape:
| Field | Meaning |
|---|---|
name |
Which check: certificate (policy shape), game (progress), reconstruction (neighbour agreement), threshold (knife-edge), conformal (unusual cases), support (support). |
passed |
true or false. Any false makes the verdict abstain. |
trust |
lean, paper or operational: how much proven mathematics stands behind this yes/no. See Trust levels and honest limits. |
summary |
One line. The summaries of failed checks are joined to make reason. |
metrics |
The numbers the check used (table below). |
catalog |
Pointers to results in the companion math catalog: family (result number), title, lean (whether a machine-checked statement exists) and note. Informational; nothing reads them at decide time. |
| Check | metrics keys |
Passes when |
|---|---|---|
certificate |
star_height, dfa_states, max_dfa_states |
star_height ≤ max_star_height (3) and dfa_states ≤ max_dfa_states (256) |
game |
parity_win, nonnegative_mean |
both true |
reconstruction |
degree, lambda_hat, d_lambda_sq, margin, kesten_stigum (always 1.0) |
d_lambda_sq > 1 + margin |
threshold |
n, p_hat, p_threshold, epsilon (0.1), theorem_width, operational_width, in_theorem_window, in_operational_window, theorem_vacuous |
in_operational_window is false |
conformal |
p_value, alpha, n_cal, label_score, require_calibration |
p_value > alpha; with no calibration scores it passes unless require_calibration is true |
support |
min_support, require_support, measured, and support when measured |
support ≥ min_support; when not measured it passes unless require_support is true |
Two things in this table surprise people:
- The certificate report always passes inside a record. A policy that
breaks
max_star_heightormax_dfa_statesis refused when the module is built, so no record is ever made from it. The report is kept so every record shows the limits the policy was checked against (for examplestar-height 0 ≤ 3; 4 states ≤ 256). - The
trusttag is almost constant in practice.certificateis alwayslean, even if it fails.gameandreconstructionareleanwhen they pass and drop tooperationalandpaperwhen they fail.thresholdisoperationalat every realistic graph size, because the proven window is wider than 1 (theorem_vacuousis true).conformalandsupportare alwaysoperational. Trust levels and honest limits explains why.
A real record¶
This is the complete record for the thin-chain case, printed with
json.dumps(record.to_dict(), indent=2, ensure_ascii=False). It is about
3.5 KB as single-line JSON; 1.3 KB of that is the catalog pointers, which
are the same on every record. This evidence does not measure support, so
the support report says support not measured and health.support is
null.
Full JSON of the thin-chain abstain (163 lines)
{
"verdict": "abstain",
"action": null,
"proposed_action": "block",
"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)",
"module": "fraud_review",
"event": {
"name": "suspicious",
"payload": {
"amount_usd": 840,
"merchant": "electronics"
}
},
"channels": [
{
"name": "certificate",
"passed": true,
"trust": "lean",
"summary": "star-height 0 ≤ 3; 4 states ≤ 256",
"metrics": {
"star_height": 0,
"dfa_states": 4,
"max_dfa_states": 256
},
"catalog": [
{
"family": "134",
"title": "Generalized star height at most three",
"lean": true,
"note": "Every regular control language is rejected if star-height exceeds 3."
},
{
"family": "104",
"title": "Mean-payoff and parity games",
"lean": true,
"note": "Parity via Zielonka; 1-player mean-payoff via Karp. Truffet tropical elimination is banned."
},
{
"family": "104",
"title": "Truffet elimination counterexample",
"lean": true,
"note": "The Lean Comparator records a finite counterexample; the solver never uses that step."
}
]
},
{
"name": "game",
"passed": true,
"trust": "lean",
"summary": "parity_win=True nonnegative_mean=True",
"metrics": {
"parity_win": true,
"nonnegative_mean": true
},
"catalog": [
{
"family": "104",
"title": "Mean-payoff and parity games",
"lean": true,
"note": "Parity via Zielonka; 1-player mean-payoff via Karp. Truffet tropical elimination is banned."
},
{
"family": "104",
"title": "Truffet elimination counterexample",
"lean": true,
"note": "The Lean Comparator records a finite counterexample; the solver never uses that step."
}
]
},
{
"name": "reconstruction",
"passed": false,
"trust": "paper",
"summary": "d λ² = 0.1667 vs 1+0.050 (at or below KS; abstain)",
"metrics": {
"degree": 1.5,
"lambda_hat": 0.33333333333333326,
"d_lambda_sq": 0.1666666666666666,
"margin": 0.05,
"kesten_stigum": 1.0
},
"catalog": [
{
"family": "229",
"title": "Three-state reconstruction above Kesten–Stigum",
"lean": true,
"note": "Go only when d λ² > 1 + margin. Non-reconstruction at equality is policy, not Lean."
}
]
},
{
"name": "threshold",
"passed": false,
"trust": "operational",
"summary": "evidence density inside operational window (theorem width=4.39e+05, vacuous=True)",
"metrics": {
"n": 4,
"p_hat": 0.5,
"p_threshold": 0.08,
"epsilon": 0.1,
"theorem_width": 439069.4614197543,
"operational_width": 1.6749170738973782,
"in_theorem_window": false,
"in_operational_window": true,
"theorem_vacuous": true
},
"catalog": [
{
"family": "186",
"title": "Friedgut–Kalai sharp threshold width",
"lean": true,
"note": "Theorem width is 2^19 log(1/(2ε))/(log n)². Vacuous at operational n; operational prefactor is separate."
}
]
},
{
"name": "conformal",
"passed": true,
"trust": "operational",
"summary": "conformal p=1.000 vs α=0.100 (cover)",
"metrics": {
"p_value": 1.0,
"alpha": 0.1,
"n_cal": 40,
"label_score": 0.1,
"require_calibration": false
},
"catalog": []
},
{
"name": "support",
"passed": true,
"trust": "operational",
"summary": "support not measured",
"metrics": {
"min_support": 0.5,
"require_support": false,
"measured": false
},
"catalog": []
}
],
"health": {
"d_lambda_sq": 0.1666666666666666,
"reconstruction_margin": 0.05,
"p_hat": 0.5,
"p_threshold": 0.08,
"threshold_width_theorem": 439069.4614197543,
"threshold_width_operational": 1.6749170738973782,
"in_theorem_window": false,
"in_operational_window": true,
"theorem_window_vacuous": true,
"conformal_p": 1.0,
"star_height": 0,
"game_solved": true,
"mixer_status": "idle",
"support": null
},
"envelope": "p_exact",
"extras": {
"state": "review"
}
}
Reproduce it with:
import json
from grounded_decision.examples import fraud_review as fr
from grounded_decision.types import Event
module = fr.build()
logins, outcomes = fr.thin_rumour_logins()
evidence = fr.device_graph_evidence(logins, outcomes, candidate_model_score=0.10)
record = module.decide(
Event("suspicious", {"amount_usd": 840, "merchant": "electronics"}), evidence
)
print(json.dumps(record.to_dict(), indent=2, ensure_ascii=False))
uv run grounded-decision examples fraud_review --json prints all three
records of the example in compact form.
Reading reason¶
reason is one of five kinds of string. Which one tells you which branch of
decide ran (One decision, step by step has the code).
reason |
Verdict | Meaning |
|---|---|---|
all channels passed |
act |
Every check passed; the proposal was carried out. |
statechart degrade |
degrade |
Every check passed; the state is marked as a fallback. |
event '…' is not handled in state '…'; nothing proposed |
abstain |
Every check passed, but the event has no arrow out of the current state (an unknown name, or a known one out of order). |
no action marked on current state |
abstain |
Every check passed; the policy moved to a state that proposes nothing. |
| anything else | abstain |
The summaries of the failed checks, joined by "; ". |
The failed-check summaries start with a recognisable phrase:
| Summary starts with | Failed check | Example |
|---|---|---|
d λ² = |
neighbour agreement | d λ² = 0.1667 vs 1+0.050 (at or below KS; abstain): evidence strength 0.1667, needed above 1.05 |
evidence density inside operational window |
knife-edge | density too close to the cut-off for this graph size |
conformal p= |
unusual cases | conformal p=0.032 vs α=0.100 (reject) |
no calibration scores |
unusual cases | empty calibration set with require_conformal_calibration=True |
support |
support | support 0.19 vs minimum 0.50 (does not support the proposed action), or support not measured (require_support) |
parity_win= or ... vertices exceed ... |
progress | the policy failed its build-time check; every decision will abstain |
Search on the channels[].passed bits rather than on these strings when you
can. The summary wording is for people and may change between versions (see
Stability and versioning).
Reading health¶
health copies the numbers you will want to chart over time out of the six
reports, so a dashboard does not have to search the channels list. For the
thin-chain record:
| Field | Value | Meaning | Copied from |
|---|---|---|---|
d_lambda_sq |
0.1667 | Evidence strength (\(d\lambda^2\)): links per case × agreement². Must exceed 1 + margin. | reconstruction |
reconstruction_margin |
0.05 | The margin above 1 | reconstruction |
p_hat |
0.5 | Link density of the related cases | evidence |
p_threshold |
0.08 | Your density cut-off | evidence |
threshold_width_operational |
1.675 | Full width of the knife-edge window used for the decision; the check holds when p_hat is within half of it (capped at 0.5) of p_threshold |
threshold |
threshold_width_theorem |
439069 | Width the proven theorem gives; above 1 means it says nothing at this size | threshold |
in_operational_window |
true | The density is on the knife-edge, so the check failed | threshold |
in_theorem_window |
false | Always false when the theorem width is vacuous | threshold |
theorem_window_vacuous |
true | The theorem width is above 1 | threshold |
conformal_p |
1.0 | Unusualness p-value; low means unlike past correct cases | conformal |
star_height |
0 | How deeply the policy nests repetition; 0 for policies built as charts | module |
game_solved |
true | The progress check's parity result (parity_win) |
module |
mixer_status |
idle |
Always idle on records from decide |
— |
support |
null | Share of related cases carrying the outcome the proposed action implies; null when not measured | evidence |
Three habits make health useful:
- Chart
d_lambda_sqandconformal_pper module over time. A slow slide towards 1.05 or 0.10 shows your evidence weakening before abstains spike. - Compare
p_hatwithp_threshold, not with 0. The knife-edge check is about distance from your cut-off. - Use
channels[].passedto explain a verdict, andhealthto see trends.healthhas no pass/fail bits for neighbour agreement or unusualness.
What to store¶
The record is designed to be stored as it is. Store its JSON, plus four things the record does not contain:
- The evidence you passed in (
EvidenceSnapshot.to_dict()). The record keeps the scalars the checks read, but not the calibrationscores,n_statesor yourobservations. Without the snapshot you cannot replay a decision exactly. - The policy version. The record names the module but not which version of its policy decided. Store a fingerprint of the policy and its settings. See State and policy versions.
- Who, when and which request: entity id, timestamp, request id, tenant. The library has none of these.
- The entity's new state (
extras.state), in a separate one-row-per-entity table, so the next event can restore it.
The reference service stores decisions in an append-only table. Its
schema (from src/grounded_decision/examples/reference_service.py) is a
good starting point:
CREATE TABLE IF NOT EXISTS decisions (
decision_id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL,
entity_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
policy_name TEXT NOT NULL,
policy_version TEXT NOT NULL,
event_name TEXT NOT NULL,
state_before TEXT,
state_after TEXT NOT NULL,
verdict TEXT NOT NULL,
action TEXT,
proposed_action TEXT,
routing TEXT NOT NULL,
enforced INTEGER NOT NULL,
failed_channels TEXT NOT NULL, -- JSON list of channel names
record_json TEXT NOT NULL,
evidence_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS decisions_by_entity ON decisions (entity_id, decision_id);
It pulls the fields people query (verdict, proposed_action,
event_name, the failed check names, the state before and after) into
columns, and keeps the full record and evidence as JSON beside them. Typical
questions it answers quickly: "which blocks did we hold last week?"
(verdict = 'abstain' AND proposed_action = 'block'), "how often did the
knife-edge check fail?" (failed_channels LIKE '%threshold%').
Do not pickle a live Module to store state. Store extras.state and
restore it into a module built from the same policy version.
Reading a stored record back¶
DecisionRecord.from_dict(data) rebuilds the object from its JSON, so code
that combines records (for example compose_parent) can
work across services. Re-running decide on the old event with new evidence
is a new decision, not a replay: for an audit, read the stored record.
When the format changes¶
Records carry no schema version field. Under the rules in Stability and
versioning, adding a field is a compatible change
(readers must ignore keys they do not know), while adding or reordering a
check, or renaming a verdict or check name, is a breaking one. Store the
library version (grounded_decision.__version__) beside each record if you
need to know which format produced it.
What an auditor checks¶
When a record needs explaining, read it in this order:
verdictandproposed_action.abstainwith a proposal is a held action;abstainwithout one is a policy waiting for its next event.reasonand the sixpassedbits. They must agree: anyfalsemeansreasonlists that check's summary. Alltruewithabstainmeans the policy had nothing to carry out.healthagainstchannels[].metrics.health.d_lambda_sqequals the reconstruction report'sd_lambda_sq;health.conformal_pequals the conformal report'sp_value. If they differ, the record was altered.extras.state. The state after this event. In the thin-chain case it isreview: the policy moved even though the block was held.- The stored evidence and policy version, to reproduce the decision.
An act record shows that the evidence passed the checks, not that the
action was right; see what a verdict does not
establish. Whatever you put in
event.payload is copied into every record, so keep secrets out of it.
Where to go next¶
- State and policy versions: saving and restoring
extras.state, and versioning the policy. - Act, abstain, degrade: routing each verdict.
- Types reference: the dataclasses behind the JSON.
- Build a decision service: the reference service that stores these records.