Home
Blog
From Transaction Graph to Replayable Audit Trail: Fraud Scoring on TuringDB

From Transaction Graph to Replayable Audit Trail: Fraud Scoring on TuringDB

From Transaction Graph to Replayable Audit Trail: Fraud Scoring on TuringDB

Why a Fraud Decision You Cannot Replay Becomes a Liability

A fraud alert creates an obligation that outlives the moment it fires. You may have to defend that decision months later, long after the transaction graph that justified it has moved on. The accounts have transacted again, new edges have landed, and the subgraph that once looked like laundering no longer exists in that shape. When an auditor asks you to show your work, it is gone.

Most teams answer this with an audit log written next to the database. It records what was decided: the account, the score, the timestamp. It does not record what the data looked like at the time, which is the part the examiner wants. So you reassemble the evidence by hand, pulling from a few systems and copying between tabs, and a single suspicious-activity report can eat hours.

The regulatory pressure is real but specific. AML (anti-money-laundering) recordkeeping and SAR (suspicious activity report) retention rules expect you to reconstruct the basis for a filing, and a subject has a right to meaningful information about a decision a model made about them. A log line you take on trust satisfies none of it.

This tutorial builds a system that can reproduce, not just assert, what justified a decision. A log file asserts what was decided. A commit reproduces what the system saw.

One Engine for the Graph, the Vectors, and the History

Think of the transaction graph as one object with a past: account nodes, timestamped TRANSFER edges carrying an amount and an is_laundering flag, a small set of typology reference vectors, and a commit history that makes every earlier state addressable. The pipeline needs all four, and keeps them in one place.

The edges hold chain structure. The vectors hold pattern semantics. The version history holds provenance. All three live inside the same self-hosted engine. Scoring reads from it, and replay reads from it, so there is no second database in the path and nothing to keep in sync.

TuringDB borrows git's terms, and it helps to learn theirs rather than translate as you go. You open a change (an isolated branch to write in), commit into it, then submit it to fold it into the main line. A past state is recovered by checking out its commit hash, which is what lets a decision made months ago be reconstructed exactly as it looked at the time.

Two facts about the engine's behaves shape every query below, so it's worth knowing them upfront instead of running into them mid-script. The Cypher is a restricted subset: no variable-length paths, and MATCH filters only on literals. And the vector index ranks by dot product, not cosine, even when you ask for cosine.

The full pipeline is seven small scripts and a runnable notebook in a GitHub repository. This article walks through the pieces that carry the ideas and links the rest.

Running TuringDB Locally and Loading the Transaction Graph

Setup is one install command. No cloud account, no API key, nothing to provision:

pip install turingdb==1.36 pandas
turingdb start -ui

The engine comes up on port 6666 and a visualizer on 8080. One caveat: the turingdb wheel has no Windows build, so on Windows you run it under Docker or WSL.

The data is the IBM Transactions for Anti Money Laundering set, HI-Small variant, released under CDLA-Sharing-1.0. The reason is specific. It ships real minute-granularity timestamps and a companion patterns file labeling complete laundering typologies, cycles, and fan-outs among them, which is what lets you check that a traversal caught a genuine ring rather than a coincidence.

The raw file runs to millions of rows. [01_sample_data.py](https://github.com/mostafaibrahim17/turingdb-replayable-fraud-scoring/blob/main/01_sample_data.py) trims it with one rule that matters: keep every laundering transaction, then add 50,000 legitimate ones at random. That preserves the labeled rings while shrinking the graph to 76,302 accounts and 55,177 transfers.

Loading the graph takes two passes, one for accounts and one for transfers, and they don't work the same way.

Accounts are simple. A single LOAD CSV ... CREATE (:Account {...}) creates every account node in one shot.

Transfers are harder, because creating a TRANSFER edge means connecting two accounts that already exist, and TuringDB's MATCH can't take a value straight from a CSV row, only fixed values written directly into the query. That rules out LOAD CSV and UNWIND for edges entirely. The workaround in [02_load_graph.py](https://github.com/mostafaibrahim17/turingdb-replayable-fraud-scoring/blob/main/02_load_graph.py) is one MATCH ... CREATE per transfer, with each account id written in as a literal.

Two gotchas live here too: semicolon-joined writes silently run only the first statement and drop the rest, no error, no warning. And every value coming out of LOAD CSV arrives as a string, so ids and amounts both need an explicit toInteger or toFloat before they're usable.

The cost is real: sending 55,000 statements one at a time takes one to two minutes on a laptop. That's the only slow part of the whole pipeline, everything after loading returns in under a second. It's also the only speed claim in this article, no formal latency benchmark was run.

Confirm the load before moving on: count nodes and edges, pull one flagged transfer. One retrieval note, since it will confuse you otherwise: RETURN n on a bare node gives back an internal id that means nothing on its own, so project the properties you want.

The loaded transaction graph: accounts as nodes, transfers as edges, all inside one self-hosted engine.

Scoring a Laundering Ring on Structure Alone

The score is built from structure and nothing else: a detected cycle adds 0.6, a wide fan-out adds up to 0.4. The is_laundering flag never touches the score. It is read separately, after the fact, only to confirm the structural signal landed on real laundering. Scoring with the label would be scoring with the answer key, so the pipeline keeps them apart.

Tracing the money is where the missing variable-length paths get concrete. You cannot write -[:TRANSFER*1..5]-> and let the engine walk; you spell the hops out. To detect a cycle of length k, the scorer [04_score_account.py](https://github.com/mostafaibrahim17/turingdb-replayable-fraud-scoring/blob/main/04_score_account.py) builds the pattern one hop at a time and binds the same account id at both ends to close the loop:

def build_cycle_query(seed, k):
   sid = cypher_id(seed)
   parts = [f"(a:Account {{id: {sid}}})"]
   for i in range(1, k):
       parts.append(f"-[:TRANSFER]->(n{i}:Account)")
   parts.append(f"-[:TRANSFER]->(z:Account {{id: {sid}}})")
   return f"MATCH {''.join(parts)} RETURN count(z)"

You cannot close the loop by reusing one variable; the engine rejects that as Loop detected. Binding the same literal id to two different variables is what works. The scorer tries k from 2 to 10 and takes the shortest cycle.

The output includes the traced money trail, the score, a one-line reason, and a separate validation line counting how many of the account's transfers were ground-truth laundering. Against three accounts, it separates them cleanly:

Account Structural shape Score Reason
8013C4030 10-hop cycle 0.60 funds return to origin after 10 hops (cycle)
800737690 16-way fan-out 0.40 high fan-out to 16 accounts (structuring)
802207750 ordinary activity 0.00 no structural laundering pattern

Ten accounts, ten transfers, one closed loop back to the origin. This is the cycle the scorer detects.

One more limitation shapes the helper code: an aggregate like count cannot share a RETURN with other items, and there is no GROUP BY, so each count comes back alone. The code ends up running many tiny one-value queries instead of one big one because of it.

Naming the Shape with the Native Vector Index

The score tells you how risky an account is, not what kind of laundering you are looking at. That is the vector index's job, and the way it does it is probably not what you would guess.

There's no text embedding here and no external model. Each account is reduced to a four-number fingerprint of its own structure: [fan_out, fan_in, cycle, depth]. Out-degree and in-degree scaled down, a zero-or-one cycle flag, and how deep a chain runs out of the account. Those four numbers are the vector, matched against a handful of hand-defined reference vectors, one per shape: FAN-OUT, FAN-IN, CYCLE, SCATTER-GATHER, CHAIN-STACK, and NORMAL.

The NORMAL reference earns its place. Without it a vector search always returns the nearest laundering shape, so an ordinary account gets labeled a weak cycle. With it, ordinary accounts match NORMAL, which is why 802207750 lands on NORMAL at cosine 1.00 instead of a pattern it does not have.

Now the detail that shapes every line of [05_classify_typology.py](https://github.com/mostafaibrahim17/turingdb-replayable-fraud-scoring/blob/main/05_classify_typology.py). TuringDB's VECTOR SEARCH ranks by the dot product of stored and query vectors, even under a METRIC COSINE index. Dot product rewards longer vectors, so a raw fan-out fingerprint gets dragged toward the higher-magnitude SCATTER-GATHER reference and mislabeled. The fix is to unit-normalize every vector before it goes near the index:

def unit(vec):
   n = math.sqrt(sum(x * x for x in vec))
   return [x / n for x in vec] if n > 0 else vec

Normalize both sides and dot product becomes cosine, and the ranking is correct. That single fact is what most shapes the code here. With the index at dimension four and the reference vectors loaded from a headerless CSV, the three accounts classify:

Account Score Typology Confidence
8013C4030 0.60 CYCLE 1.00
800737690 0.40 FAN-OUT 0.98
802207750 0.00 NORMAL 1.00

Vector search names the shape: CYCLE, FAN-OUT, and NORMAL, each with a cosine confidence.

Committing the Decision and Replaying It for an Examiner

So far, this has all produced a decision. What happens next is what makes that decision defensible months later: you store it in a way that can be reproduced

At alert time, the scorer [06_commit_decision.py](https://github.com/mostafaibrahim17/turingdb-replayable-fraud-scoring/blob/main/06_commit_decision.py) opens a change and writes the decision as a Decision node with EVIDENCE edges out to every account in the ring. Node and edges go in a single MATCH ... CREATE, and that is not stylistic: MATCH cannot see an uncommitted CREATE from an earlier statement, so the node is created in the same query that matches the already-committed evidence accounts. Name one thing plainly: those EVIDENCE edges point at the evidence accounts, not the transfers, because you cannot anchor an edge to another edge. The ring's TRANSFER edges sit underneath, so the cycle stays reconstructable.

Then commit, submit, capture the hash, and later travel back to it:

client.query("COMMIT")
client.query("CHANGE SUBMIT")          # merge the change into main
client.checkout()
commit_hash = head_commit_hash()       # e.g. "226a92273bcd981", read from CALL db.history()
# ... later, to replay, the stored hash is checked out:
client.checkout(commit=commit_hash)    # the graph exactly as it was at decision time

The external case store keeps only that hash and a little metadata. Not the evidence, on purpose. The evidence stays in the database; the hash is the pointer back to it.

The Decision node points to the ten ring accounts, and their transfers still form the cycle underneath.

The replay is the payoff, and [07_replay_decision.py](https://github.com/mostafaibrahim17/turingdb-replayable-fraud-scoring/blob/main/07_replay_decision.py) runs it in order. First it lets the graph move on, the way a real one would, by adding a single post-decision transfer. Then it checks out the stored hash. The contrast is the whole argument. The live graph now holds 55,178 transfers; the checked-out commit holds 55,177. The later transfer is simply not there, because this is the past. The decision reproduces exactly score=0.6, typology=CYCLE, ten evidence edges, and the ten-hop ring re-traces from the hash. Nothing was rebuilt by hand.

Checked out from the hash: the graph returns one transfer behind the present, and the decision reconstructs down to the last edge.

Because you are standing in the past, you can ask a what-if without touching the record. The counterfactual varies the look-back depth and re-runs against the historical state:

Look-back policy Verdict on the ring
5 hops missed
6 hops missed
10 hops caught

A shallow-traversal system would miss a ten-hop ring outright. That is the concrete case for walking deep, and it costs nothing here, because checkout() puts you back in the present with the record untouched.

A log file asserts what was decided. A commit hash reproduces what the system saw, down to the last edge, from a short string rather than a hand-assembled file. That difference is the entire reason to put the history inside the database.

Gotchas, What's Next, and Conclusion

A few edges worth knowing before you build on this.

Scoring is hand-written. shortestPath handles point-to-point routing, not the cycle and fan-out patterns this pipeline needed to find, so the scoring here was written directly against the graph rather than adapted from a native primitive.

Normalize your vectors. The index ranks by dot product under a cosine metric.

Pin the version. Built against turingdb==1.36, and the package moves fast.

One claim to make carefully. The TuringDB docs present zero-lock concurrency as core architecture, the thing that lets scoring keep running while new transactions land. This build never load-tested that; the notebook writes sequentially through one client on Community Edition. Treat continuous scoring under streaming writes as an architectural claim you have not verified, not something this tutorial demonstrated.

Three directions from here. Stream the ingest so the graph updates as payments land. Widen the typology coverage beyond the six shapes here. And wire the replay into a case-management workflow, so the commit hash rides along with the case file an analyst already opens.

One engine holds the graph. It holds the vectors. It holds the full history. Nothing gets reassembled from separate systems after the fact. The score comes from structure, never from a label. An auditor doesn't get a log line. They get a commit hash they can check out and see exactly what the system saw.

Clone the repo, run the notebook, then point it at your own transaction data, and the thing it reproduces is the graph itself, exactly as the scorer saw it.

Leave a Reply

Your email address will not be published. Required fields are marked *
✨ Message sent! I’ll respond as soon as possible.
⚡ Submission failed. Please refresh the page or try again later.
Ready to Elevate Your Technical Content & Blog?

Great things happen when engineering and storytelling unite, let’s create content that educates, inspires, and drives results.

See My Work
See My Work
Trusted by Top AI & SaaS Brands Worldwide
150+
Technical Articles Written
500K+
Readers Across Platforms