Your first decision¶
In this tutorial you write a two-line policy for a loan desk, give it strong evidence and watch it act, then give it thin evidence and watch it hold the same action for a person. Along the way you read the decision record field by field, so that when a real decision is held you know where to look for the reason.
It takes about 15 minutes. You need the package installed
(Install) and a Python prompt: uv run python in a
development checkout, or python in your own project. If the words
proposed action, evidence and check are new, read
Why a score isn't enough first.
1. Write a policy¶
A policy says which events lead to which actions. Ours: an
applicant flagged eligible proposes approve; one flagged
ineligible proposes decline.
from grounded_decision import Event, EvidenceSnapshot, Module, parse
policy = parse("test(eligible) ; act(approve) | test(ineligible) ; act(decline)")
module = Module.from_expr("loan_review", policy)
Read the policy text as: receive the event eligible, then propose
approve; or receive ineligible, then propose decline. test(...)
names an event, act(...) names an action, ; means "then", and |
means "or". Write a policy has the full
syntax.
Module.from_expr loads the policy and runs the two policy checks
(is it simple enough to analyse, can it keep going) once, now. A
policy that fails them raises an error here rather than at decision
time.
Give each action its own event
Write test(a) ; act(x) | test(b) ; act(y), not
test(a) ; (act(x) | act(y)). In the second form both actions
follow the same event, so the policy cannot say which to propose.
Module.from_expr refuses it with a CompileError:
2. Describe strong evidence¶
Evidence is a set of numbers describing the related cases around this decision: for a loan, past applicants linked to this one (same employer, same address history) and whether they repaid. Here we type the numbers in directly so you can see each one. In production they come from your data; From your data to evidence shows how.
strong = EvidenceSnapshot(
n=80,
p_hat=0.35,
p_threshold=0.08,
degree=8.0,
lambda_hat=0.55,
scores=tuple(0.40 + 0.02 * i for i in range(40)),
label_score=0.1,
)
| Field | Plain meaning | Value here | Check that reads it |
|---|---|---|---|
n |
How many related cases there are | 80 past applicants | knife-edge (more cases, narrower window) |
p_hat |
Link density: the share of possible links between those cases that exist | 0.35 (35%) | knife-edge |
p_threshold |
Your decision line for that density: above it, the links count as a pattern | 0.08 | knife-edge |
degree |
Average number of links per case | 8 | neighbour agreement |
lambda_hat |
Neighbour agreement: how much more often linked cases share an outcome than chance (0 = coin flip, 1 = always) | 0.55 | neighbour agreement |
scores |
The calibration set: unusualness scores of 40 recent cases where the answer was right | 0.40, 0.42, …, 1.18 | unusual case |
label_score |
This case's unusualness score, on the same scale | 0.1 | unusual case |
lambda_hat is not a model probability. It is measured from the
links: how often two linked cases have the same outcome.
3. Decide, and see it act¶
record = module.decide(Event("eligible", {"applicant": "A-1042"}), strong)
print(record.verdict.value, record.action, record.proposed_action)
print(record.reason)
Each check passed, and here is why, in the record's own numbers:
- Neighbour agreement: evidence strength = degree × agreement² = 8 × 0.55² = 2.42, above the required 1.05.
- Knife-edge: with 80 cases the window around the line is 0.084 wide on each side (0.08 ± 0.084). The density 0.35 is well outside it.
- Unusual case: 0.1 is lower than every calibration score, so the p-value is 1.0, above the 0.10 bar.
The event's payload ({"applicant": "A-1042"}) is carried into the
record for your logs. The library never reads it.
4. Read the record¶
record.to_dict() is the form you store and send to other services.
Print it:
Here it is with the per-check catalog entries shortened to […]
(they list the mathematical results each check relies on):
{
"verdict": "act",
"action": "approve",
"proposed_action": "approve",
"reason": "all channels passed",
"module": "loan_review",
"event": {"name": "eligible", "payload": {"applicant": "A-1042"}},
"channels": [
{"name": "certificate", "passed": true, "trust": "lean",
"summary": "star-height 0 ≤ 3; 3 states ≤ 256", "metrics": {…}, "catalog": […]},
{"name": "game", "passed": true, "trust": "lean",
"summary": "parity_win=True nonnegative_mean=True", "metrics": {…}, "catalog": […]},
{"name": "reconstruction", "passed": true, "trust": "lean",
"summary": "d λ² = 2.4200 vs 1+0.050 (supercritical)", "metrics": {…}, "catalog": […]},
{"name": "threshold", "passed": true, "trust": "operational",
"summary": "evidence density outside operational window (theorem width=4.39e+04, vacuous=True)", "metrics": {…}, "catalog": […]},
{"name": "conformal", "passed": true, "trust": "operational",
"summary": "conformal p=1.000 vs α=0.100 (cover)", "metrics": {…}, "catalog": []},
{"name": "support", "passed": true, "trust": "operational",
"summary": "support not measured", "metrics": {…}, "catalog": []}
],
"health": {
"d_lambda_sq": 2.4200000000000004,
"reconstruction_margin": 0.05,
"p_hat": 0.35,
"p_threshold": 0.08,
"threshold_width_theorem": 43943.44684666311,
"threshold_width_operational": 0.1676309465281033,
"in_theorem_window": false,
"in_operational_window": false,
"theorem_window_vacuous": true,
"conformal_p": 1.0,
"star_height": 0,
"game_solved": true,
"mixer_status": "idle",
"support": null
},
"envelope": "p_exact",
"extras": {"state": "s1"}
}
What each part is for:
| Field | What it tells you |
|---|---|
verdict |
act, abstain or degrade. |
action |
What to carry out. Set only when the verdict lets you act; null on abstain. |
proposed_action |
What the policy wanted, whatever the verdict. On abstain, this is what the person reviews. |
reason |
"all channels passed", or the summaries of the checks that failed, joined by ;. |
channels |
The six checks. In records a check is called a channel. Each has passed, a one-line summary, its metrics, and a trust level: lean (relies on a machine-checked proof), paper (a published result) or operational (a practical rule with a stated constant). |
health |
The key numbers from all six checks in one flat object, ready for dashboards. |
envelope |
How hard the underlying computation is; p_exact for ordinary decisions. See Questions the library refuses. |
extras.state |
Where this case now is in the policy. Save it if the next event for the same case should continue from here. |
Two numbers in the threshold summary need a word. The library reports
the width the original theorem would give (theorem width=4.39e+04),
notes that it is vacuous (wider than the whole 0-to-1 range, so it
says nothing at this size), and decides with a practical window instead
(threshold_width_operational, 0.168 in total). Trust levels and
honest limits explains why both are kept.
The decision record documents every field.
5. Thin evidence: watch it hold¶
Now the same applicant, but the related cases are few, barely linked,
and barely agree. The helper thinly_corroborated_evidence() returns
such a bundle: 40 cases, 2 links each, agreement 0.2, and a density of
0.22 against a line at 0.20.
from grounded_decision.examples.common import thinly_corroborated_evidence
module.reset() # a fresh case starts at the beginning of the policy
held = module.decide(
Event("eligible", {"applicant": "A-1043"}), thinly_corroborated_evidence()
)
print(held.verdict.value, held.action, held.proposed_action)
for channel in held.channels:
print(
f"{channel.name.value:15} {'PASS' if channel.passed else 'FAIL'} {channel.summary}"
)
abstain None approve
certificate PASS star-height 0 ≤ 3; 3 states ≤ 256
game PASS parity_win=True nonnegative_mean=True
reconstruction FAIL d λ² = 0.0800 vs 1+0.050 (at or below KS; abstain)
threshold FAIL evidence density inside operational window (theorem width=6.2e+04, vacuous=True)
conformal PASS conformal p=0.143 vs α=0.100 (cover)
support PASS support not measured
Nothing is carried out (action is None), but approve is still on
the record for the person who reviews it. Two checks say why:
- Neighbour agreement failed. Strength 2 × 0.2² = 0.08, far below 1.05. ("KS" is Kesten–Stigum, the name of the 1.0 line.)
- Knife-edge failed. With 40 cases the window is 0.118 either side of the line. The density 0.22 is only 0.02 from 0.20.
- The unusual-case check passed (p = 0.143). One passing check is not enough; every check has to pass.
- The support check passed because this evidence does not measure
support (which outcome the related cases point to). Typed-in evidence
can set it with
support=; see Do the related cases support this action?
A score rule would have approved. In a service you route this record to
a review queue with proposed_action and reason;
Act, abstain, degrade shows the routing.
6. The other kind of abstain¶
Not every abstain is a failed check. Send a second event to the case from step 3 without resetting it:
module.reset()
module.decide(Event("eligible"), strong) # act approve, as before
again = module.decide(Event("eligible"), strong)
print(again.verdict.value, again.proposed_action, again.reason)
The applicant was already approved, and the policy says nothing about a
second eligible, so the event is not handled: the case stays where it
is and there is nothing to propose. Every check passed;
proposed_action is empty. This kind of abstain means "wait for the
next event", not "ask a person". An event name the policy doesn't know
(a typo such as eligble) behaves the same way: the case stays where
it was and nothing is proposed.
The rule for routing: an abstain with a proposed_action goes to a
person; an abstain without one waits.
What you learned¶
- A policy proposes; six checks decide whether the proposal may be carried out.
- Strong evidence acts; thin evidence holds and keeps the proposal.
- The record says which check failed, with its numbers.
- Reset (or restore a saved state) between independent cases.
Next¶
- From your data to evidence: compute these numbers from a graph of your own cases instead of typing them in.
- Build a decision service: put this behind HTTP with saved state, an audit log and a review queue.
- State and policy versions: saving
extras.statebetween requests.