Combinations: adding checks beside the decision¶
Most real decisions need more than one question answered before anything happens. A loan approval may also need the new policy version to behave like the old one; an experiment may need a significant lift as well as a coherent set of affected cases; a payout may need two teams' services to agree. This track shows six small services that combine one library decision with one or more extra checks, and how to wire the combination so every reason for a hold is visible in your own code and logs.
It is for engineers wiring a decision into a service that already has other gates, and for the owners of those gates. It assumes you have seen one decision made (the step track or Your first decision). Terms are defined in the glossary.
uv run grounded-decision examples --track combo
uv run grounded-decision examples combo_underwriting
The source is src/grounded_decision/examples/combo.py.
The pattern: an AND you write yourself¶
A decision call (module.decide(event, evidence)) always runs the same
six checks and nothing else: two on the policy itself, and four on the
evidence (neighbour agreement, knife-edge, unusual cases, support; see
the six checks). Any other condition,
such as a statistical test, a comparison of two distributions, or another
team's verdict, runs beside the decision, in your service, and you
combine the answers yourself:
record = module.decide(event, evidence) # the library's decision
extra_check_passed = my_other_gate(...) # a helper, another service, a test
carry_out = record.verdict == "act" and extra_check_passed
flowchart LR
E[event + evidence] --> D[decide: six checks]
X[other inputs] --> S[extra check]
D --> A{act AND passed?}
S --> A
A -- yes --> C[carry out the action]
A -- no --> H[hold or fall back; log both answers]
The library does not add a hidden sixth check. Because you write the
and, a hold always has a reason you can point to: "the decision held",
"the extra check failed", or both.
The extra checks in this track come from two places in the library:
- Comparison helpers (
grounded_decision.compare): measure something and return a dict, for example how far two distributions are apart or which single feature to keep. See Compare outcomes and features. - The router (
grounded_decision.router.dispatch): runs a graph computation within a cost budget, or refuses a request that has no algorithm or is too expensive. See Use the hardness router.
The evidence in these runners is ready-made: well_corroborated_evidence()
(passes every check) and thinly_corroborated_evidence() (fails
neighbour agreement and knife-edge), from examples/common.py, sometimes
with one field changed. That keeps the focus on how the pieces combine.
A real service builds evidence from its own records with
snapshot_from_graph, as Fraud review does.
| Id | Situation | Extra check | Does it gate the action? |
|---|---|---|---|
combo_underwriting |
Approve a loan under a new policy version | Did the version change the distribution of credit offers? | Yes |
combo_marketplace_session |
Open a rideshare match | Graph fingerprint and ping list from the router | No (they serve other parts of the worker) |
combo_moderation_tables |
Ship a pre-post warning prompt | Is the A/B lift significant? | Yes |
combo_cache_and_features |
Admit an object to a one-slot cache | Pick the object; pick one logged feature | No (telemetry) |
combo_parent_and_session |
Release an instant payout | A second team's verdict | Yes |
combo_full_conjunction |
Enable a new merchant's payouts | A refused pricing request; a promotion pick | No (separate consumers) |
combo_underwriting¶
The situation. A lender is rolling out a new version of its
underwriting policy. Each application is still decided by the library:
the policy test(eligible) ; act(approve) | test(ineligible) ; act(decline)
proposes approve for an eligible applicant, and the evidence links the
applicant to past borrowers who share an employer and income band. But a
new version that quietly changes how much credit everyone is offered is a
portfolio risk that no single-applicant check can see.
The extra check runs once per candidate version, in the release pipeline.
The same 200 past applicants are replayed through the current version and
the candidate, and compare_outcome_laws measures how far apart the two
distributions of credit-line offers are. The measure is the
Wasserstein-2 distance: line up both sets of offers from smallest to
largest and take the root-mean-square gap between matched pairs, here in
hundreds of dollars. The allowance is scale * tau**3 with
tau = 0.5 (CREDIT_LINE_MAP_ERROR_TOLERANCE) and scale = 50
(CREDIT_LINE_DISTANCE_SCALE): 6.25.
What to notice in the output.
- The decision acts:
approvefor applicantapp_1042, with evidence strength dλ² = 2.42 (links per case × agreement², needs to be above 1.05). - The minor tweak moves each offer by about $30. Distance 0.198, within
6.25:
maps_comparable=True, so the approval is carried out (final_verdict_with_minor_tweak=act). - The regime shift offers larger and more spread-out lines (mean $14,000
instead of $10,000). Distance 39.84:
maps_comparable=False, so even this approved application is held and the previous version is used (final_verdict_with_regime_shift=abstain).
The takeaway. A check on the policy version and a check on the
applicant answer different questions, and you need both. The distance has
no direction: a version that offered everyone a uniformly smaller line
would also be blocked, so a block means "review this version", not "this
version is worse". Choose the allowance by replaying two versions you know
are equivalent and setting it comfortably above their distance; only the
product scale * tau**3 matters. Canary latency
explains the same helper in detail, and
Credit underwriting the per-applicant
decision.
combo_marketplace_session¶
The situation. A rideshare matching worker proposes rider–driver
pairs several times a minute and opens a match only when the decision
acts. The evidence describes the pickup zone: riders recently served by
the same driver are linked, and each is labelled "last trip completed
without complaint". On the same worker, two graph computations run on the
six-person candidate-pairing graph (CANDIDATE_PAIRINGS, three riders and
three drivers, six candidate pairs forming a cycle):
- a fingerprint of the graph, so the worker can reuse the last assignment when the graph has not changed shape. It uses Weisfeiler–Leman colour refinement (repeatedly relabelling each person by the labels of their neighbours) at depth 1, and also asks for depth 5;
- a ping list: the people to ask about availability so that every candidate pair includes at least one of them (a vertex cover).
What to notice in the output.
- Three proposed pairs: a busy downtown zone (act
open_match), a quiet late-night zone (hold: dλ² = 0.08, far below 1.05, and link density 0.22 inside the knife-edge window around its 0.20 cut-off; the rider stays queued), and the busy zone again (act). - Fingerprint depth 1 runs (
ok=True). Depth 5 is refused (ok=False) on every tick without running, because the default cost budget allows depth 2 at most (Limits.max_wl_k = 2). The report calls this an exponential-time trap; read it as "too expensive under our budget". For a fixed depth the method runs in polynomial time, but the cost grows steeply with depth. - The ping list has 6 people. The smallest possible list is 3. The router uses a quick method that is never worse than twice the smallest list (both ends of a maximal set of disjoint pairs).
The takeaway. Router answers and the decision are independent: good evidence does not make a refused computation allowed, and a refusal does not hold the match. Use the router's answer honestly too. The ping-list method's factor of 2 is what it promises on any graph, but this graph is two-sided (riders only pair with drivers), and on two-sided graphs the smallest ping list can be found exactly and quickly with a maximum-matching algorithm. Marketplace matching works through both computations.
combo_moderation_tables¶
The situation. A trust-and-safety team A/B tests a "this may break community rules" prompt shown before posting. In control, 50 of 200 posts were withdrawn by their authors; with the prompt, 70 of 200: a lift of 0.10. The team ships the prompt only if the lift is significant and the decision acts on evidence that the prompted posts form a coherent, reviewer-confirmed class (posts reported by the same users are linked; each is labelled "reviewer confirmed a violation").
The significance test draws random two-arm tables with the same arm sizes (200 and 200) and the same total withdrawals (120), using the library's table sampler. Those tables follow the same distribution as Fisher's exact test (the hypergeometric distribution). The p-value is the share of random tables whose lift is at least as large as 0.10 either way, with a +1 correction.
What to notice in the output.
- The decision acts (
ship_variant), dλ² = 2.42. - The p-value is 0.037 from 80 draws (
RANDOM_TABLE_DRAW_COUNT): 2 of the 80 random tables had a lift as large, so \(p = (1 + 2)/(1 + 80)\). That is below 0.05, soship_variant=True.
The takeaway. A significant lift does not show that the affected posts are a coherent class, and a coherent class does not show the lift is real; the AND asks for both. With 80 draws the smallest possible p-value is 1/81 and the estimate is coarse; use thousands of draws in production. A/B test launch covers the same sampler in a full case.
combo_cache_and_features¶
The situation. A CDN edge node has one free premium cache slot. Objects arrive one at a time with a predicted hit rate, and each must be pinned or skipped as it arrives. Separately, the team wants to log one yes/no request feature that best predicts "hit again within an hour".
Two helpers run first, and a decision runs on the chosen object:
single_item_one_sampleskips objects below a bar from history (yesterday's value, 4.0 hits per hour) and pins the first object that reaches it.best_coordinatelooks at 400 logged requests with six yes/no features and keeps the single feature that agrees most often with the target.- The decision then asks whether the chosen object's demand is
corroborated: objects are linked when the same clients requested both,
and labelled "stayed hot for an hour". The object is admitted only on
act.
What to notice in the output.
- Arrivals 1.0, 1.5, 9.0, 2.0, 12.0: the rule pins object 2 (9.0) and never sees the 12.0 object. Pinning the first object would have taken 1.0.
- The kept feature is
same_region_as_origin, with agreement 1.00. The synthetic target is defined as exactly that column, so the right answer is known in advance; this shows the call, not that the method finds signal in real data. - The decision acts (
cache).
The takeaway. Three different jobs: choose (the slot rule), summarise
(the feature), and decide whether to act on the choice (the module). Here
the helpers are telemetry: they feed the decision and the logs, but they
do not gate it. The slot rule here uses a single scalar bar from
yesterday, so it reports no guaranteed factor (factor_claimed=None;
the guaranteed 1/2 needs one past sample per arrival, as in cache
admission); see Cache admission and
Feature selection.
combo_parent_and_session¶
The situation. A marketplace offers sellers instant payouts. Two teams
own two stateless services. Compliance asks whether the seller's
identity check is current and corroborated (event kyc_current).
Fraud asks whether the seller's device neighbourhood is clean (event
low_risk). The payout orchestrator releases money instantly only when
both act; otherwise the seller gets the standard next-day payout.
Each day, call for call:
- Each service loads its own saved state for the seller, sends
{"state", "event", "evidence"}tohandle_request, saves the returnedextras["state"], and returns the decision record as JSON. - The orchestrator rebuilds both records with
DecisionRecord.from_dictand combines them withcompose_parent. - The combined verdict acts only if every child acted.
What to notice in the output.
- Twelve rows: compliance then fraud, for six days. Compliance acts every day.
- On days 2 and 3 a wave of brand-new accounts with no review history
joins the seller's device neighbourhood. Fraud's neighbour agreement
falls from 0.55 to 0.30 and 0.28 (
FRAUD_NEIGHBOR_CORRELATION_BY_DAY), so evidence strength drops to \(8 \times 0.30^2 = 0.72\) and 0.63, below 1.05. Fraud holds on those days. - The combined verdicts are
[act, act, abstain, abstain, act, act]: instant payouts on days 0, 1, 4 and 5. Releasing every day, as the naive orchestrator does, would have paid out instantly on the two degraded days.
The takeaway. Combine services with AND, never OR: a parent that acted
when either child acted would ignore exactly the days fraud could not
vouch for the seller. Keep one saved state per service; sharing one state
between two different policies is a bug. When the modules run in one
process on the same evidence, use Pipeline instead of calling
compose_parent by hand (see Composing modules).
combo_full_conjunction¶
The situation. A payments platform onboards a new merchant and wants
to enable full payouts as soon as the merchant's customer graph supports
it, not on a fixed calendar. The policy is
test(evidence_refreshed) ; act(enable_payouts), decided once a day for
eight days. The evidence ramps from a thin day 0 (2 links per customer,
agreement 0.2, density close to its cut-off) to a strong day 7 (8 links,
agreement 0.55). In production each day's evidence comes from that day's
records; here a straight-line ramp (ramp_snapshots) stands in for "data
accumulates".
Two unrelated requests run beside it: a pricing service asks for a rational number \(x\) with \(x^2 = 2\), and a promotion picker chooses one offer from three with the same slot rule as above (bar 3.0).
What to notice in the output.
- Days 0 to 4 hold. Neighbour agreement fails on all five (dλ² rises from 0.08 to 0.87, still below 1.05), and the knife-edge check also fails on days 0 and 1, when density is inside the window around the cut-off.
- Days 5 to 7 act (dλ² = 1.27, 1.79, 2.42):
first_enabled_day=5.trajectory(records)gives the rows an onboarding dashboard would plot. - The pricing request is refused at once (
ok=False), without a search, because the request was sent as "no complete algorithm exists" (Envelope.UNDEC). Finding rational solutions of arbitrary polynomial equations has no general algorithm (Hilbert's tenth problem over the rationals; see the theory page for its status). - The promotion picker skips 1.0 and takes the 4.0 offer.
The takeaway. The decision waits for the evidence, not the calendar: five days held, then acting from day 5. The side requests have their own consumers and do not gate payouts. One caution about the refusal: this particular equation is easy (a one-variable polynomial with integer coefficients has only finitely many rational roots, and they can be listed exactly; \(x^2 = 2\) has none). The router refuses the whole class without looking at the polynomial, so route one-variable requests to an exact method first; Polynomial solving shows how.
Wiring combinations into your system¶
How each runner maps onto services:
| Id | Who calls decide |
Extra check, computed where | On act + passed | Otherwise |
|---|---|---|---|---|
combo_underwriting |
Underwriting service, per application | maps_comparable, release pipeline, once per policy version |
Issue the approval | Fall back to the previous version |
combo_marketplace_session |
Matching worker, per proposed pair | dispatch(...).ok, inline, per graph computation |
Open the match | Keep the rider queued; log refused computations |
combo_moderation_tables |
Rollout service, once per experiment | Random-table p-value, experiment platform at close | Ship the variant | Keep control; record which half failed |
combo_cache_and_features |
Edge node, per chosen object | Slot rule and kept feature (telemetry, not gating) | Admit to the cache | Serve from origin |
combo_parent_and_session |
Compliance and fraud services, per request | Both children act, payout orchestrator | Release the instant payout | Standard payout; log both children's reasons |
combo_full_conjunction |
Daily onboarding job | None (the side requests are independent) | Enable full payouts | Keep payouts limited; retry tomorrow |
Rules that hold for all of them:
- Store the record and the extra check's result together. Put
record.to_dict()and the helper's output (the p-value, the distance, the router'sdetail) in one audit row, keyed by entity and policy version, so a hold never needs recomputing to explain. - Each service owns its own state. Save
extras["state"]per entity in the service that owns the module, with the policy version beside it (State and policy versions). - Compute expensive checks off the request path. Distribution comparisons and random-table p-values belong in batch jobs whose result is read at request time. Router calls are cheap when allowed and instant when refused, so they can run inline.
- Write down whether each extra check gates. In
combo_cache_and_featuresthe helpers are telemetry; everywhere else they gate. The code cannot tell which you meant. - Treat a refusal as an answer. A refused router request
(
ok=False) is normal. Return it to the caller as a clear error and do not retry it with a bigger budget.
Why the extra checks stay outside decide¶
A sixth check would run on every module, including ones that have nothing
for it to look at. The distribution comparison needs two samples of
outcomes, the slot rule needs a sequence of arrivals, the fingerprint
needs a graph and a depth, and none of those are fields of the evidence
snapshot. Building them in would force every caller to invent dummy
inputs. Keeping them as calls beside decide also keeps the library's
behaviour stable: adding a sibling call to your service changes nothing
for anyone else, while adding a check to decide would change every
existing decision.
Adding a combination to this track¶
For contributors:
- Write a runner in
examples/combo.pywith the signature(*, as_json=False) -> dictreturning{id, title, records, summary}viaemit_run. Name every constant and say what it measures. - Register it in
RUNNERS; the catalog picks it up. - Add a section to this page with the id in backticks (a test checks that every combo id appears here): the situation, what to notice in the output, and the takeaway.
- Keep router and comparison calls outside
Module.decide, and write the AND in the runner. - If it is really a single product decision, with an owner, today's rule and a hold path, it belongs in the example library as a case page instead.
More on adding examples: Development and automation.