DEV Community

Cover image for Three crashes, one body shop: a fraud agent where the graph picks the next query
Prabal Patra
Prabal Patra

Posted on

Three crashes, one body shop: a fraud agent where the graph picks the next query

Three crashes, all late at night, all at the same intersection, all within a month. Each time, the other driver drove off. Each time, nobody called the police.

All three cars were fixed at the same body shop. All three drivers were treated at the same clinic. The same adjuster handled all three claims and had already approved two of them, for $38,000 and $35,500. Two of the drivers share a phone number. Two of them share an address.

The third claim, CLM-48291, is still in the queue. It asks for $42,500 for a night-time side-impact at a blind intersection, and on its own it looks ordinary. Someone reading that one file has nothing to flag.

The claims are my demo data, but the shape is real. In a South Florida case reported in 2018, a ring paid tow-truck drivers and body-shop workers $1,500 to $2,000 per patient to steer crash victims to its chiropractic clinics, and took about $23 million from insurers (Fox News).

No single claim gives the fraud away. The connections between them do. So for Graph Hacks I built Diktiax. You give it a claim id. It walks a FalkorDB graph, decides which checks are worth running based on what it finds, scores the claim with plain code, and then acts: it writes the verdict back into the graph, freezes the payout and assigns an investigator. Before it looks at a claim again, it reads its own past verdicts there. It's my entry for Track 01 (agents that act) and Track 02 (agent memory).

Try it: live demo · code · video · every number and its test

Similar text is the wrong question

My first instinct was the 2026 default: embed the claims and let a model look for patterns. I dropped it quickly.

Vector search finds claims that read alike. Staged-crash stories often do, but so do honest ones, and a ring member who files a plain claim drifts out of reach. What ties these three together is a phone number in two customer records and a body shop on all three, and neither moves an embedding much. In a graph that tie is a two-hop path, Customer → Phone ← Customer, which gave me the one modelling rule the project hangs on: anything a fraud ring reuses gets its own node. In the demo graph, the claimant on CLM-48291 and the claimant on CLM-39102, the claim already approved for $38,000, list the same phone, and both cars went to SHOP-801. Neither file mentions the other.

Same three claims: similar text vs shared connections

Entity resolution in one MERGE

Diktiax graph model: everything a ring reuses is its own node; dashed edges are written by the agent and the reviewer

Phones, addresses and VINs are nodes, merged on their value. This is how filing a claim starts:

From INGEST_CLAIM in backend/src/db/queries.rs:

MERGE (phone:Phone {number: $phone})
MERGE (addr:Address {line: $address})
MERGE (vin:VIN {vin: $vin})
MERGE (loc:Location {id: $location_id}) ON CREATE SET loc.name = $location_id
MERGE (shop:RepairShop {id: $repair_shop_id}) ON CREATE SET shop.name = $repair_shop_id
Enter fullscreen mode Exit fullscreen mode

If another customer already has that number, the new customer is attached to the same node, and the link exists the moment the claim is filed. No matching job, no fuzzy join, no nightly batch. Two Vehicle registrations in different states pointing at one VIN node is the cloned-VIN signal, straight out of the shape of the data.

The demo graph is synthetic and small on purpose: one staged-collision ring, one cloned-VIN pair and three clean claims as controls.

Four fraud patterns, four multi-hop traversals

Each pattern is one parameterized Cypher query that returns the node ids making up the evidence. The kickback triangle asks whether an unreported accident's adjuster, shop and clinic keep showing up together:

KICKBACK_TRIANGLE, from backend/src/db/queries.rs:

MATCH (a:Accident)-[:TRIGGERED_CLAIM]->(c:Claim {id: $claim_id}),
      (c)-[:REPAIRED_AT]->(shop:RepairShop), (c)-[:TREATED_AT]->(clinic:Clinic), (c)-[:ASSIGNED_TO]->(adj:Adjuster),
      (other:Claim)-[:REPAIRED_AT]->(shop), (other)-[:TREATED_AT]->(clinic), (other)-[:ASSIGNED_TO]->(adj)
WHERE other.id <> c.id AND a.police_report_filed = false
OPTIONAL MATCH (shop)-[ref:COLLUDES_WITH]->(clinic)
RETURN c.id AS claim_id, adj.id AS adjuster_id, shop.id AS shop_id, clinic.id AS clinic_id,
       other.id AS linked_claim_id, ref IS NOT NULL AS referral_link
ORDER BY linked_claim_id
Enter fullscreen mode Exit fullscreen mode

Read it clause by clause. The first line pins the claim and its accident. The next two lines say this claim's shop, clinic and adjuster must also all be attached to some other claim: that's the triangle. WHERE keeps only accidents with no police report, and the OPTIONAL MATCH checks for an explicit referral link between the shop and the clinic without requiring one. In SQL this is three provider tables self-joined on two claims, then filtered; here it's one pattern that reads like the description. The shared-contact query walks Customer → Phone|Address ← Customer → Claim; ghost collision looks for other claims at the same location and shop within 90 days; cloned VIN follows one VIN to every registration that filed a claim.

The ring, highlighted on the claim's graph in the dashboard

Okay, but does the graph actually matter?

My own seed data makes it look too easy. The seeded fraud also carries the classic red flags (no police report, big amounts), so a dumb rule like "flag it if there's no police report or it's over $25,000" catches it too. So I added ring members who learned the rules (police reports, small claims, same phone, corner and body shop) and honest drivers who trip the red flags, with labels fixed before anything ran. Two reviewers that see one claim at a time got the same claims: those rules, and an LLM (gpt-oss-120b on Groq) handed each claim's full file.

Group Claims Per-claim rules LLM, one claim at a time Graph agent
Seeded fraud (ring + cloned VIN) 5 5/5 5/5 5/5
Seeded clean controls 3 0/3 0/3 0/3
Evasive ring members 3 0/3 0/3 3/3
Honest claims with red flags 2 2/2 1/2 0/2

On the easy data, all three tie. Once the ring adapts, both single-claim reviewers wave it through. The graph judges a claim by its connections, and a ring can't stop reusing what makes it a ring. The groups are small and I built them, so this shows the mechanism, not an accuracy number.

So I scaled it up to 5,040 generated claims: mostly honest ones with chance overlaps, plus tight rings, evasive rings, and loose rings that share only a corner and a body shop.

Thirty fraud rings hidden among 5,040 generated claims

Group Claims Per-claim rules flagged Graph froze payout Graph sent to audit
Tight rings 120 120 120 0
Evasive rings 80 0 80 0
Loose rings 40 0 0 40
Honest 4,800 2,789 0 169
Precision / recall / F1 (frozen = flagged) 0.04 / 0.50 / 0.08 1.00 / 0.83 / 0.91
Median / p95 per investigation 17 ms / 23 ms

The graph froze every tight and evasive ring claim and no honest ones, at a median of 17 ms per investigation. The evasive rings carry a police report and a small amount, so the rules miss them by construction. The loose rings are the graph's blind spot: they land in audit, along with 169 honest claims. An LLM reading a sample one claim at a time missed every evasive and loose-ring claim.

I then re-ran it on a fresh seed as a pre-registration: the answer key, bound to a hash of the generated data, went into its own commit before the results, so git log shows the order. Same outcome: the rules flagged 120 of 240 fraud claims and 2,753 honest drivers; the graph froze 200 of 240 and no honest claim, with 178 honest claims sent to audit. The hash shows the key wasn't edited after scoring; it can't show the generator wasn't tuned between runs. In backend/, cargo test --release --test scale_benchmark -- --ignored --nocapture reproduces the first run, and SCALE_SEED=20_261_019 with scale_benchmark -- --ignored --exact the pre-registered one, failing if the key's hash changed.

The graph picks the next query

How Diktiax picks its next query

The agent first explores the claim's neighbourhood three hops out. With the rule-based planner, a detector then only runs if that neighbourhood makes a match possible. Here's the real trace on the ring claim:

# Tool Why the agent chose it Result
1 explore_claim_subgraph Map the neighbourhood 24 nodes, 32 edges
2 detect_ghost_collision LOC-441 hosts several accidents; repaired at SHOP-801 match, +45
3 detect_shared_contacts Phone +1-555-019-8821 has more than one owner match, +25
4 detect_vin_cloning A VIN is in the neighbourhood no match
5 detect_kickback_triangle No police report; adjuster and clinic attached match, +40
6 shop_centrality SHOP-801 The shop appeared in the evidence 1.71× the average
7 ring_components FalkorDB's algo.WCC picks which linked claims to re-check ring of 3 claims
8 detect_ghost_collision on CLM-44730 In the ring component and named in the evidence corroborated
9 detect_kickback_triangle on CLM-39102 In the ring component and named in the evidence corroborated

The clean control takes three calls: explore, two detectors, done. Nobody shares its claimant's phone or address, and its accident has a police report and no clinic, so those checks never run. Same agent, same code. The graph made the investigations different.

Agent trace in the dashboard

Three planners sit behind one interface: rule-based (needs nothing), a Groq-hosted model (openai/gpt-oss-120b), or Claude. All pick from the same nine graph tools, and every step shows the planner's reason, its arguments and the Cypher it ran.

The bug that could have paid the fraudster

A failed query used to score zero and clear the claim; now it means inconclusive and manual review

This one had no symptom, which was the problem. The scoring code treated a failed query exactly like an empty one: no rows, no match, zero points. Say FalkorDB hiccups (a timeout, a restart, a dropped connection) while the detectors run on CLM-48291. Every detector comes back empty, the score reads 0, and the claim lands in CLEARED_AUTOMATED. A $42,500 staged crash gets marked clean, with nothing red on the dashboard.

The fix is one rule: an error is not a no. A failed query means the evidence is incomplete, so the score is only a lower bound, and missing detectors can only add points. If that lower bound already freezes the payout, it freezes. Otherwise the verdict is INCONCLUSIVE with zero confidence:

apply_graph_failures in backend/src/agent/verdict.rs:

/// With graph failures the score is only a LOWER bound (missing detectors can only add points).
/// A bound that already escalates (HIGH/CRITICAL) stands; anything lower becomes INCONCLUSIVE
/// with zero confidence, which the dispatcher maps to `NEEDS_MANUAL_REVIEW` (no clear, no freeze).
pub fn apply_graph_failures(assessment: &mut FraudRiskAssessment, failures: &[String]) {
    if failures.is_empty() || crate::actions::status_for(&assessment.risk_tier).1 {
        return;
    }
    assessment.risk_tier = INCONCLUSIVE_TIER.to_string();
    assessment.confidence = 0.0;
}
Enter fullscreen mode Exit fullscreen mode

The claim goes to NEEDS_MANUAL_REVIEW, nothing is cleared, and the rationale names every failed query. In an agent that acts, the dangerous bugs are the quiet defaults that resolve in the wrong person's favour. A crash at least gets noticed.

The model never sets the score

The model picks which of the nine graph tools to run; plain code computes the score

That bug made me stricter about the LLM too. Models are good at choosing what to look at next. I didn't want one deciding whether to freeze somebody's payout. The planner's job ends at choosing tools:

From backend/src/agent/investigator.rs:

let patterns = evidence::scoring_patterns(claim_id, &outcomes)?;
let mut assessment = FraudRiskEvaluator::evaluate(&patterns);
Enter fullscreen mode Exit fullscreen mode

FraudRiskEvaluator is a plain function: it adds the points from detectors that matched on the target claim, counts each pattern once, and caps at 100. When an LLM plans, any detector it skipped runs anyway before scoring, so a model can't clear a claim by not looking.

Acting on it, remembering it, and how it's put together

The verdict goes back into the graph as a claim status plus an Investigation node with the decision and score. The investigator is picked from the graph too: best specialty coverage of the matched patterns, fewest open cases.

Diktiax architecture

The dashboard runs on Vercel; the Rust backend and FalkorDB share one container. The same nine tools, plus seven for outside agents, are served over MCP, so another agent runs exactly the Cypher this one runs. One is FalkorDB's algo.WCC: the agent runs it once per investigation, and the ring component it returns picks which linked claims get re-checked, so on the ring claim both other members are corroborated, not one twice.

Verdicts stay under the claim in the graph, so a new session or another agent over MCP reads the same certificate

That makes the graph the agent's memory. Each new verdict supersedes the last in one chain, linked to its evidence claims. On a re-run the agent reads that chain first: it says whether it agrees with the last verdict and skips corroboration the last verdict already relied on (9 tool calls drop to 7 on the ring claim). The score never reads the past verdict; it's context, not evidence. Two agents, one claim: the Diktiax agent and a scripted MCP client playing the reviewer never message each other; the graph is the shared memory

Over MCP, investigation_history returns that chain, and another agent's second investigate_claim gets the stored verdict instead of a second run:

→ investigate_claim {"claim_id":"CLM-48291"}
← {"reused":true,"certificate_id":"CERT-02987df7-…","decision":"CRITICAL_FRAUD","risk_score":100}
Enter fullscreen mode Exit fullscreen mode

What FalkorDB did, and what I built

FalkorDB did the heavy lifting: MERGE gave me entity resolution, openCypher let each pattern read like its description, and as a Redis module it shares one small container with the agent.

Evidence queries run on the read-only lane with a timeout; only ingest, the verdict and the assignment write

Every evidence query goes through GRAPH.RO_QUERY with a server-side timeout, so the part of the agent that gathers evidence can't write to the graph. GRAPH.EXPLAIN showed one flaw: the kickback pattern starts at (a:Accident), so it opens with a label scan, not an index seek. I left it, because an investigation spends about a millisecond in the database; every second you wait is the LLM.

I built the parts around it: the graph model, the detectors and scoring, the planner loop, the write-back and assignment, and the dashboard.

Other things that bit me

  • Path depth can't be a parameter. [*1..$depth] fails on v4.22.1 with "unhandled type in inlined properties" (another team reported it as FalkorDB#3350). The depth is now clamped in Rust and inlined.
  • A race my own test found, twice. Concurrent runs could assign one claim to two investigators when the check and the MERGE were separate queries. I folded them into one statement, but FalkorDB doesn't document that as atomic, so the guarantee is a unique constraint on ASSIGNED_INVESTIGATOR.claim_id, awaited at startup until enforced. Now 32 racing writers always leave one edge.
  • Groq's rate limits. Raw JSON results blew the per-minute token budget, so each result now goes back as a one-line summary.

What I'd tell the next builder

  1. Model the things your problem reuses as nodes. Half my detectors are just "who else points at this node?"
  2. Make an error loud. Any "no result means no" default in an agent that acts will eventually act on a failure.
  3. Let the LLM choose, let code decide. The planner is replaceable; the scorer isn't.
  4. Build the baseline that could prove you wrong, and keep it as a test.
  5. Put the guarantee in the database. Folding a check-then-write into one statement looked atomic; only a unique constraint made 32 racing writers leave one edge every time.

Where the graph loses, and what it doesn't do

The graph loses on loose rings. Claims that share only a corner and a body shop never get frozen: all 40 went to audit, along with 169 honest claims (178 on the pre-registered re-run) that happened to share a shop or a household address. On the easy seed claims, the rules and the LLM tie it. The rings were generated the way the detectors look, so the evasive result is a ceiling, not a field number. The labels are the generator's, though, and the detectors never see them, which is why the graph can and does miss.

The data is synthetic, so this shows the approach, not production scale or real-world accuracy. I ran a vector-search baseline late (MiniLM embeddings in a FalkorDB vector index): on the 8 seeded accident stories it found every ring-mate in its top 3, but every fraud claim also had an honest neighbour, and the plain-filed ring members drifted away from their ring. That's a sanity check, not a benchmark. The LLM reading each claim stands in for a reviewer who understands the text. Severity uses fixed thresholds, not learned weights. A flag means a person should look; it doesn't prove fraud, which is why Diktiax freezes and assigns instead of denying. The ghost-collision window is 90 days, so new claims stop matching the seeded ring once its dates age out.

Try it

docker compose up -d falkordb
cd backend && cargo run --release            # API on :3001, seeds the graph
cd frontend && bun install && bun run dev    # dashboard on :3000
Enter fullscreen mode Exit fullscreen mode

Add GROQ_API_KEY to .env for the LLM planner, or leave it out for the rule-based one. Then file a claim from the dashboard with phone +1-555-019-8821, location LOC-441 and shop SHOP-801, and watch it walk straight into the ring.

Built with FalkorDB for WeMakeDevs Graph Hacks: Context for AI Agents, Tracks 01 and 02.

If you've worked on fraud or any other link-analysis problem: which pattern would you add as the fifth detector?

Top comments (0)