Fraud review: block a card or send it to a reviewer¶
Your risk model has flagged a card authorisation as suspicious, and the authorisation service must either block it or let it through. Blocking a real fraudster is cheap; blocking a customer buying a laptop is a support call, a churn risk and sometimes a complaint. This page shows how the library lets the block go ahead automatically only when the accounts linked to the flagged one are numerous and consistent enough to lean on, and sends everything else to a fraud reviewer. It is written for a fraud lead or fraud-ops manager; the maths is in collapsed boxes and linked pages.
At a glance
| Decision | Block the card (block) or let the authorisation through (pass). |
| Owner | Fraud operations; the authorisation service carries it out. |
| Today's rule | If the model says suspicious, block. If it says clean, pass. |
| What this adds | Before blocking or passing automatically, check that the flagged account's device neighbourhood is big and consistent enough to support an automatic call, and that most of it points the same way as the call. Otherwise hold for a reviewer. |
| Demo outcome | 3 authorisations: 1 acted on, 2 held (the block that rested on a thin laptop chain, and the pass of a card inside a fraud ring). |
| Example id | fraud_review (uv run grounded-decision examples fraud_review) |
The decision today¶
A card issuer's risk model scores each authorisation and labels it
suspicious or clean. Today's rule blocks on suspicious and passes on
clean, whatever else is known. Both kinds of mistake cost money:
| Mistake | What it costs |
|---|---|
| Block a legitimate customer | Declined at the till, support call, churn risk, possible complaint. |
| Pass a fraudster | The charge, the chargeback handling, and more charges on the same card. |
| Hold for a reviewer | Reviewer time, and the customer waits while the authorisation is decided by your fallback (see What happens next). |
Where today's rule goes wrong¶
Take two $840 electronics purchases, both flagged suspicious by the model.
- Card A belongs to an account in a device ring: sixteen accounts that log in from the same eight phones in rotation. Each account shares a phone with about five others, and thirteen of the sixteen have been confirmed as fraud in past reviews. Blocking this card is the job.
- Card B belongs to an account linked to three others only through a chain of shared laptops: account 900 shares laptop A with 901, 901 shares laptop B with 902, 902 shares laptop C with 903. Two of those four were confirmed fraud in the past; the links barely predict each other. This is what a family laptop, an office PC, a VPN exit or an internet café looks like.
Today's rule blocks both, because it never looks at the neighbourhood. The customer behind card B is declined at the till, and fraud ops hears about it from the complaint.
flowchart LR
subgraph A["Card A: dense device ring (16 accounts, 40 links)"]
a1((acct)) --- a2((acct)) --- a3((acct)) --- a1
a2 --- a4((acct)) --- a3
a4 --- a5((…))
end
subgraph B["Card B: thin laptop chain (4 accounts, 3 links)"]
b0((900)) -- laptop A --- b1((901)) -- laptop B --- b2((902)) -- laptop C --- b3((903))
end
What the system checks, in your terms¶
The library sees the same suspicious flag, plus a summary of the flagged
account's related cases: the accounts that shared a device with it in
the last 30 days, which of them are linked to which, and each account's
last review outcome. It runs six checks; all six must pass for the
proposed action to be carried out. Two of them check the policy itself and
pass in every row here (see the six checks).
The four that read your data are:
| Check | Question, in fraud terms | Reads | Fails when | What a hold means |
|---|---|---|---|---|
| Neighbour agreement | Are there enough linked accounts, agreeing with each other often enough, that their review history says something about this account? | Shared-device links and each account's last review outcome | Few links per account, or linked accounts' outcomes are close to a coin flip | "The neighbourhood is too thin or too mixed to lean on." |
| Knife-edge | Is the neighbourhood's link density far enough from your "this is a ring" cut-off that a few missing or extra logins would not change the reading? | Number of accounts, number of links, your ring-density cut-off | Density is inside the uncertainty window around the cut-off. Small neighbourhoods have wide windows. | "Too few accounts to tell a ring from coincidence." |
| Unusual cases | Does this authorisation's model score look like the scores of recent cases we got right? | Model score for this auth; scores of the last 40 confirmed cases (the calibration set) | The score is more extreme than almost all of the calibration set | "The model has not been right on cases like this recently." |
| Support | Do most of the linked accounts carry the outcome the action implies (confirmed fraud for block, cleared for pass)? |
Each account's last review outcome | Fewer than half the accounts carry that outcome | "The model and the linked accounts disagree." |
Strong evidence can point the other way
Neighbour agreement measures whether linked accounts agree with
each other, not whether they agree with the proposed action. A
dense ring of confirmed fraudsters is "strong evidence" for any
action, including pass. The support check is what catches that:
the demo's third row is a clean $12 grocery purchase on a card
inside the same fraud ring. Neighbour agreement, the knife-edge and
the unusual-case checks all pass; only 3 of the 16 accounts were
cleared, so support fails (0.19, below 0.50) and the pass is held
for a reviewer.
This works because the example passes the outcome each action
implies (SUPPORTING_LABEL: block needs confirmed fraud, pass
needs cleared) when it builds the evidence. Without it, the support
check passes with support not measured, and the same clean
event would act pass. Do not read an act pass as "the
neighbourhood vouched for this customer" unless support is measured.
The knife-edge check also needs a word of honesty: in the demo, its cut-off (0.08 link density) is a policy number, and with only four accounts its uncertainty window is so wide that it fails almost regardless of density. That is useful here (four accounts really is too few to judge), but it means small neighbourhoods are held by this check alone even when they look decisive.
Data you provide¶
| Input | Source system | Refresh | Owner | Minimum / quality bar |
|---|---|---|---|---|
Device logins: (account_id, device_id) rows for the flagged account's neighbourhood, last 30 days |
Device-fingerprint or login warehouse | Per authorisation (or a cache refreshed every few minutes) | Fraud data engineering | Exclude devices everyone shares (public Wi-Fi exits, app-store test devices) or they will link unrelated accounts. |
Last review outcome per account (CONFIRMED_FRAUD = 1, CLEARED = 0) |
Case-management / review-outcomes table | As reviews close | Fraud ops | Accounts never reviewed count as cleared in the demo; decide if that is right for you. Never include the current authorisation's own outcome. |
Model score for this authorisation (candidate_model_score) |
Risk model | Per authorisation | Fraud data science | Same scale as the calibration set. |
Calibration set: model scores on recent confirmed cases (RECENT_CONFIRMED_CASE_SCORES, 40 in the demo) |
Review-outcomes table joined to model scores | Daily | Fraud data science | At least 9 scores at the default 10 % level, or the unusual-cases check can never pass; 40+ is better. |
Ring-density cut-off (RING_DENSITY_THRESHOLD, 0.08) |
Your history | Quarterly | Fraud lead + data science | The link density at which past rings and past innocent clusters were hardest to tell apart. See Choose thresholds and calibration. |
device_graph_evidence in fraud_review.py turns the first two inputs into
the evidence summary: it links every pair of accounts that used the same
device and labels each account with its last review outcome.
Worked example¶
These rows come from uv run grounded-decision examples fraud_review.
| Row | Event | Neighbourhood | Agreement check | Knife-edge check | Unusual-cases check | Today's rule | This system |
|---|---|---|---|---|---|---|---|
| 1 | suspicious, $840 electronics |
Dense ring: 16 accounts, 40 shared-device links | Pass: many links, outcomes consistent | Pass: density well away from the cut-off | Pass | block | act block |
| 2 | suspicious, $840 electronics |
Laptop chain: 4 accounts, 3 links | Fail: 1.5 links per account, linked outcomes barely agree | Fail: 4 accounts is too few to judge density | Pass | block | hold; proposed block kept on the record |
| 3 | clean, $12 grocery |
Same dense ring as row 1 | Pass | Pass | Pass | pass | hold: support 0.19 (3 of 16 accounts cleared); proposed pass kept on the record |
The support check passes in rows 1 and 2 (13 of 16 accounts confirmed
fraud, 0.8125; 2 of 4, 0.5) and fails in row 3. Row 2 is held because
the neighbourhood is too thin: the model's score looked fine, which is
exactly why a score cut-off alone would have frozen the card. Row 3 is
held because the model and the neighbourhood disagree: the model says
clean, the linked accounts say fraud. Over the three rows, today's rule
acts three times; this system acts once and holds twice
(blocked_overconfident_acts: 2 in the --json summary).
The numbers behind each check
Neighbour agreement uses \(d\), the average number of links per account, and \(\lambda\), how much more often linked accounts share an outcome than chance (0 = chance, 1 = always). Evidence strength is \(d\lambda^2\); the check passes when it is above \(1 + 0.05 = 1.05\).
| Dense ring (rows 1, 3) | Laptop chain (row 2) | |
|---|---|---|
| accounts \(n\) | 16 | 4 |
| links per account \(d\) | 5.0 | 1.5 |
| agreement \(\lambda\) | 0.55 | 0.333 (two links agree, one disagrees) |
| evidence strength \(d\lambda^2\) | 1.513 (above 1.05: pass) | 0.167 (below 1.05: fail) |
| link density \(\hat p\) | 0.333 | 0.5 |
| ring cut-off | 0.08 | 0.08 |
| knife-edge half-width at this \(n\) | 0.209 | 0.5 (the cap) |
| distance from cut-off | 0.253 (outside: pass) | 0.42 (inside: fail) |
| unusualness p-value | 1.0 (above 0.10: pass) | 1.0 (pass) |
| support share | 13/16 = 0.8125 for block (pass); 3/16 = 0.1875 for pass (fail) |
2/4 = 0.5 for block (pass: the rule is "at least 0.5") |
The half-width is \(\ln(1/(2\varepsilon))/(\ln n)^2\) with \(\varepsilon = 0.1\), capped at 0.5. The unusualness p-value is \((1 + \#\{\text{calibration scores} \ge \text{this score}\}) / (1 + 40)\). The demo sends a fixed model score of 0.10, below every calibration score (0.40 to 1.18), so this check cannot fail in this demo. With your own scores it can.
The record's reason for row 2 reads:
d λ² = 0.1667 vs 1+0.050 (at or below KS; abstain); evidence density inside operational window (…).
"KS" is the Kesten–Stigum line at 1; "operational window" is the
knife-edge window. Full derivations:
neighbour agreement,
knife-edge,
unusual cases.
What happens next¶
flowchart TD
A[Authorisation flagged] --> D{All six checks pass?}
D -- yes --> X[Carry out block or pass]
D -- no --> H[Hold: record with proposed action and reason]
H --> F[Authorisation fallback now: step-up or soft decline]
H --> Q[Review queue]
Q --> R[Reviewer decides]
R --> L[Outcome written to review table]
L --> N[Next neighbourhoods and calibration set]
| Verdict | Who is told | What they see | Time limit | If nobody acts in time | Feedback |
|---|---|---|---|---|---|
Act block |
Cardholder (decline), fraud ops dashboard | action=block, the full decision record |
Immediate | n/a | Confirmed or reversed after the customer contacts you; write the outcome to the review table. |
Act pass |
Nobody (normal authorisation) | action=pass |
Immediate | n/a | Chargebacks later become CONFIRMED_FRAUD labels. |
| Hold | Fraud review queue | proposed_action (block or pass), the failed checks and the reason text |
The authorisation itself needs an answer in well under a second, so a hold cannot wait for a person. Choose a fallback for the authorisation (step-up authentication such as 3-D Secure or a one-time passcode, or a soft decline with a "call us" message) and a review deadline for the account (for example, before the next authorisation on the card or within your queue's SLA). | Apply your fallback; do not silently convert the hold into a block. | The reviewer's ruling becomes the account's review outcome and enters the next neighbourhoods and, once confirmed, the calibration set. |
The module never blocks or declines on a hold; it hands back the proposed action and the reason. What the authorisation does in the meantime is your policy. A common choice is step-up authentication: the genuine customer on the family laptop gets through, and a fraudster usually does not.
A hold is also cheap to retry: if the reviewer finds one more independent
device link and the neighbourhood becomes dense and consistent, sending the
same event again with the rebuilt evidence can act block.
If the authorisation path combines fraud review with another check (for example, a sanctions screen) using composition, the combined decision acts only when every part acts; a hold in either part holds the whole.
Tuning and expected workload¶
| Setting | Policy or data? | How to choose | Too tight | Too loose |
|---|---|---|---|---|
| Ring-density cut-off (0.08) | Data | Density at which past rings and innocent clusters overlap most | More neighbourhoods land near it; more holds | Rings and households are treated alike |
| Agreement margin (0.05 above the line at 1) | Policy | Keep the default unless reviewers find holds that were obviously decidable | More holds on borderline rings | Thin rings act automatically |
| Unusualness level α (0.10) | Policy | Fraction of "right" cases you accept treating as unusual | More holds | Fewer holds on odd scores |
| Support minimum (0.5) | Policy | Learn it from history: the share of linked accounts that must carry the action's outcome | More holds where the model and the neighbourhood half-agree | Passes and blocks against a mixed neighbourhood |
| Calibration set size (40) and refresh (daily) | Data | Enough recent confirmed cases to cover the score range | — | Stale scores make new cases look normal |
Expected holds. The demo has three hand-built rows, so it cannot tell you your hold rate. The knife-edge window is the main driver for small neighbourhoods: its half-width is 0.5 (the cap) up to about 6 accounts, about 0.30 at 10, 0.21 at 16, 0.12 at 40 and 0.08 at 80. If many of your flagged accounts have fewer than about ten device neighbours, most of them will be held. Estimate the rate on last month's flagged authorisations before launch: Estimate impact before launch.
Limits, risks and compliance¶
- Direction is checked only through support (see the warning
above). The model's
suspicious/cleancall sets the direction; the support check holds it when most linked accounts carry the opposite outcome, and only when the evidence carries the support share. Never-reviewed accounts count as cleared in the demo, which inflates support forpass. - The demo's unusual-cases check cannot fail, because it sends a fixed score. Do not conclude from the demo that the model score is being scrutinised; with your real scores it is.
- Synthetic data. The ring, the chain and the scores are constructed to show one pass and one hold. They are not drawn from real fraud data.
- Latency. Building a 30-day device neighbourhood per authorisation is
a warehouse query. Precompute neighbourhoods per account and refresh
them on a schedule if you cannot afford it inline. The
decidecall itself has a 50 ms target (checked by a test on the library, not a timer); see Limits and performance. - Shared devices. Hotel PCs, carrier-grade NAT and corporate laptops can create large, dense, consistent neighbourhoods of unrelated customers. Filter them out when you build the graph.
- Fair treatment. Device sharing correlates with household size and income. Review held and blocked rates by customer segment.
Wiring it in¶
The pieces of src/grounded_decision/examples/fraud_review.py map onto an
authorisation path:
- At service start, build the policy once:
fraud_module = build(). It is a two-path policy:cleanproposespass,suspiciousproposesblock. - Daily, refresh
RECENT_CONFIRMED_CASE_SCORESfrom the review outcomes table. - Per authorisation, fetch the flagged account's device logins and each linked account's last review outcome, and build the evidence:
from grounded_decision.examples.fraud_review import (
SUPPORTING_LABEL,
device_graph_evidence,
)
# After restoring the card's saved state:
proposed_action = fraud_module.peek_proposed_action(event.name)
evidence = device_graph_evidence(
device_logins, # [("acct_000", "phone_0"), ...]
last_review_outcome, # {"acct_000": CONFIRMED_FRAUD, ...}
candidate_model_score=model_score_for_this_auth,
supporting_label=SUPPORTING_LABEL[proposed_action], # block: fraud, pass: cleared
)
- Decide through the stateless entry point, passing the card's saved
state (or
Nonefor a new card):
response = serve_one_authorisation(fraud_module, saved_state_for_card, event, evidence)
save_card_state(card_id, response["extras"]["state"])
append_to_audit_log(response) # the full decision record
if response["verdict"] == "act":
execute(response["action"]) # "block" or "pass"
else:
apply_authorisation_fallback(card_id) # step-up or soft decline
enqueue_for_review(card_id, response["proposed_action"], response["reason"])
save_card_state, append_to_audit_log, execute,
apply_authorisation_fallback and enqueue_for_review are your own
functions.
- Concurrency. A policy module holds one current state and is not thread-safe. Restore the card's state at the start of every request under a lock, or give each worker its own module. The reference service shows the pattern: Build a decision service.
Run it yourself¶
uv run grounded-decision examples fraud_review
uv run grounded-decision examples fraud_review --json
The text report prints one block per row ("tick"). How to read it:
Example library: reading a report. HOLD
and hold_for_human in the output are this page's "hold" (the record's
verdict field says abstain).
Further reading¶
- Naive vs grounded: the same pattern across cases.
- Live: card authorization desk (
live_card_auth): this decision as a running service with a review queue. - Build evidence from your data and Troubleshoot: why did it abstain?.
- Theory: recovering a label from related cases (Kesten–Stigum), knife-edge densities (Friedgut–Kalai), conformal prediction.
Results this case relies on
| Check | Result | Math catalog number | Trust level of the check |
|---|---|---|---|
| Neighbour agreement | Kesten–Stigum reconstruction | 229 | proven (lean) that labels are recoverable above the line; holding at or below it is policy (paper on a failing row) |
| Knife-edge | Friedgut–Kalai sharp thresholds | 186 | practical (operational): the proven width is far wider than 1 at every realistic size |
| Unusual cases | Split conformal prediction | — | published |
| Support | A counting rule with an explicit minimum | — | practical (operational) |
| Policy shape and progress | Star height; parity and mean-payoff games | 134, 104 | proven (lean) |