Skip to content

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.

uv run grounded-decision examples combo_underwriting

What to notice in the output.

  • The decision acts: approve for applicant app_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).
uv run grounded-decision examples combo_marketplace_session

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.

uv run grounded-decision examples combo_moderation_tables

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, so ship_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_sample skips objects below a bar from history (yesterday's value, 4.0 hits per hour) and pins the first object that reaches it.
  • best_coordinate looks 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.
uv run grounded-decision examples combo_cache_and_features

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:

  1. Each service loads its own saved state for the seller, sends {"state", "event", "evidence"} to handle_request, saves the returned extras["state"], and returns the decision record as JSON.
  2. The orchestrator rebuilds both records with DecisionRecord.from_dict and combines them with compose_parent.
  3. The combined verdict acts only if every child acted.
uv run grounded-decision examples combo_parent_and_session

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).

uv run grounded-decision examples combo_full_conjunction

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's detail) 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_features the 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:

  1. Write a runner in examples/combo.py with the signature (*, as_json=False) -> dict returning {id, title, records, summary} via emit_run. Name every constant and say what it measures.
  2. Register it in RUNNERS; the catalog picks it up.
  3. 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.
  4. Keep router and comparison calls outside Module.decide, and write the AND in the runner.
  5. 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.