Skip to content

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:

Policy is ambiguous: after event 'eligible' (state s1) it could propose 'approve' or 'deny'; give each action its own event, for example test(ok) ; act(approve) | test(bad) ; act(deny).

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)
act approve approve
all channels passed

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:

import json

print(json.dumps(record.to_dict(), indent=2))

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)
abstain None event 'eligible' is not handled in state 's1'; nothing proposed

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