Problem solving
Fraud rings
Existence + Graph + Explanation
The problem
"Money is being cycled between accounts to launder it. Find the rings — groups of accounts that pass money round in a loop — flag every account within a few hops of a known-bad one, and, for anything we escalate, show exactly which transfers made it suspicious."
Classifying it
Three questions, three classes — say them out loud before writing anything:
- "accounts that pass money round in a loop" — a graph question. A ring is a cycle: an account reachable from itself.
CLOSURE, then keep the self-pairs. - "within a few hops of a known-bad account" — still graph, but bounded and distance-aware:
PATHwith a hop window, scoped to the flagged account. - "show which transfers made it suspicious" — an explanation question:
WHYreifies the lineage of a flagged result.
The data
Accounts := [
| account | holder | flagged |
|---------|--------|---------|
| 1001 | Ada | false |
| 1002 | Bo | false |
| 1003 | Cy | true |
| 1004 | Dee | false |
| 2001 | Eve | false |
| 2002 | Fin | false |
];
Transfers := [
| src | dst | amount |
|------|------|--------|
| 1001 | 1002 | 500 |
| 1002 | 1003 | 480 |
| 1003 | 1001 | 460 |
| 1001 | 1004 | 200 |
| 2001 | 2002 | 300 |
];
Edges := { π src, dst (Transfers) };
Accounts := [
| account | holder | flagged |
|---------|--------|---------|
| 1001 | Ada | false |
| 1002 | Bo | false |
| 1003 | Cy | true |
| 1004 | Dee | false |
| 2001 | Eve | false |
| 2002 | Fin | false |
];
Transfers := [
| src | dst | amount |
|------|------|--------|
| 1001 | 1002 | 500 |
| 1002 | 1003 | 480 |
| 1003 | 1001 | 460 |
| 1001 | 1004 | 200 |
| 2001 | 2002 | 300 |
];
Edges := { PROJECT src, dst (Transfers) };
1001 → 1002 → 1003 → 1001 is a ring; 1004 is paid out of it; 2001 → 2002 is an ordinary one-way transfer.
Stage 1: the rings (graph — a cycle)
An account is in a ring when it can reach itself. Compute reachability, then keep the self-pairs:
Reach := { CLOSURE src, dst (Edges) };
RingMembers := { δ (π src → account (σ src = dst (Reach))) };
query { RingMembers };
Reach := { CLOSURE src, dst (Edges) };
RingMembers := { DISTINCT (PROJECT src -> account (SELECT src = dst (Reach))) };
query { RingMembers };
account
───────
1001
1002
1003
(3 rows)
Stage 2: proximity to a flagged account (graph — bounded)
Which accounts are within three transfer hops of the flagged 1003? A σ on the endpoint scopes the traversal, and PATH stamps each with its distance:
query {
τ gen, account (
π dst → account, depth → gen (
σ src = 1003 (PATH src, dst HOPS 1 TO 3 AS depth (Edges))))
};
query {
SORT gen, account (
PROJECT dst -> account, depth -> gen (
SELECT src = 1003 (PATH src, dst HOPS 1 TO 3 AS depth (Edges))))
};
account gen
─────── ───
1001 1
1002 2
1004 2
1003 3
(4 rows)
Put the holders back with an ordinary existence semi-join to RingMembers (each account once):
query { π account, holder (Accounts ⋉ Accounts.account = RingMembers.account RingMembers) };
query { PROJECT account, holder (Accounts SEMI Accounts.account = RingMembers.account RingMembers) };
account holder
─────── ──────
1001 Ada
1002 Bo
1003 Cy
(3 rows)
Stage 3: why is this escalated (explanation)
For a transfer into a flagged account, WHY names the exact source rows behind it — the audit trail an escalation needs:
Flagged := { π account (σ flagged = true (Accounts)) };
query { WHY (π src, dst, amount (Transfers ⨝ Transfers.dst = Flagged.account Flagged)) };
Flagged := { PROJECT account (SELECT flagged = true (Accounts)) };
query { WHY (PROJECT src, dst, amount (Transfers >< Transfers.dst = Flagged.account Flagged)) };
src dst amount provenance
──── ──── ────── ──────────────────────────────
1002 1003 480 [{coefficient: 1, variables: …
(1 row)
What this shows
The three classes compose without any special machinery: the graph stage produces a relation of ring members, the existence stage filters accounts by it, and the explanation stage reifies lineage as a column the other operators can read. Each stage is named after what its rows are — RingMembers, Flagged — so the pipeline reads as the investigation does.
A caution the graph stage earns: one shared value merges a ring. A placeholder account, or a legitimate hub everyone transacts with, will pull unrelated accounts into one giant "ring" — look at the largest component first, exactly as the connected-groups recipe warns. And a ring is evidence, not proof; keep the WHY trail so a human decides.
Recipes drawn on
- Reachable, how far, and by what route —
CLOSURE,PATH. - Has at least one — the semi-join back to accounts.
- Why is this row here —
WHY. - Things that belong together — the merge caution.