Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 166 additions & 36 deletions README.md

Large diffs are not rendered by default.

Binary file removed assets/images/graph.png
Binary file not shown.
1 change: 1 addition & 0 deletions assets/images/graph.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
507 changes: 413 additions & 94 deletions diagrams/data_model.excalidraw

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ services:
- neo4j_data:/data
- neo4j_logs:/logs
- ./data/raw:/var/lib/neo4j/import
- ./queries:/var/lib/neo4j/import/queries
healthcheck:
test: ["CMD", "cypher-shell", "-u", "${NEO4J_USER}", "-p", "${NEO4J_PASSWORD}", "RETURN 1"]
interval: 10s
Expand Down
Empty file modified queries/README.md
100644 → 100755
Empty file.
Empty file modified queries/basic_traversal.cypher
100644 → 100755
Empty file.
13 changes: 10 additions & 3 deletions queries/ring_detection.cypher
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
MATCH (start:Account {fraud_confirmed: true})-[:HAS_PHONE|HAS_EMAIL|HAS_DEVICE*1..6]-(connected:Account)
WHERE start <> connected
RETURN DISTINCT connected.name, connected.fraud_confirmed
// Graph visualization — paste into Neo4j browser, switch to graph view.
// Returns fraud accounts and their shared identifier nodes (Phone/Email/Device).
// The browser renders which identifiers are shared across accounts, revealing the rings.
MATCH (a:Account {fraud_confirmed: true})-[r:HAS_PHONE|HAS_EMAIL|HAS_DEVICE]->(identifier)
RETURN a, r, identifier

// Tabular form — returns one row per connected account, useful for analysis.
// MATCH (start:Account {fraud_confirmed: true})-[:HAS_PHONE|HAS_EMAIL|HAS_DEVICE*1..6]-(connected:Account)
// WHERE start <> connected
// RETURN DISTINCT connected.name, connected.fraud_confirmed
Empty file modified queries/risk_scoring.cypher
100644 → 100755
Empty file.
Empty file modified queries/shared_identifiers.cypher
100644 → 100755
Empty file.
Empty file modified queries/velocity_checks.cypher
100644 → 100755
Empty file.
14 changes: 14 additions & 0 deletions src/main/java/ringnet/LoadData.java
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,20 @@ public static void main(String[] args) {
r.transaction_id = row.id
""").consume());

step("Computing FLAGGED_BY from shared identifiers", () ->
session.run("""
MATCH (a:Account)-[:HAS_PHONE|HAS_EMAIL|HAS_DEVICE]->(shared) <-[:HAS_PHONE|HAS_EMAIL|HAS_DEVICE]-(b:Account)
WHERE a.id < b.id
WITH a, b, count(DISTINCT shared) AS shared_count
MERGE (a)-[r:FLAGGED_BY]->(b)
SET r.rule = 'shared_identifier',
r.confidence = CASE
WHEN shared_count >= 3 THEN 0.9
WHEN shared_count = 2 THEN 0.6
ELSE 0.3
END
""").consume());

System.out.println("\nLoad complete. Run VerifyLoad to confirm counts.");
}
}
Expand Down
13 changes: 12 additions & 1 deletion src/main/java/ringnet/VerifyLoad.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ public static void main(String[] args) {
String password = dotenv.get("NEO4J_PASSWORD");

try (Driver driver = GraphDatabase.driver(uri, AuthTokens.basic(user, password));
Session session = driver.session()) {
Session session = driver.session()) {

printNodeCounts(session);
printRelationshipCounts(session);
List<List<String>> rings = detectFraudRings(session);
printRingSummary(rings);
}
Expand All @@ -32,6 +33,16 @@ static void printNodeCounts(Session session) {
}
}

static void printRelationshipCounts(Session session) {
System.out.println("\n--- Relationship counts ---");
String[] types = {"HAS_PHONE", "HAS_EMAIL", "HAS_DEVICE", "HAS_ADDRESS", "SENT", "TO", "TRANSFERRED_TO", "FLAGGED_BY"};
for (String type : types) {
long count = session.run("MATCH ()-[r:" + type + "]->() RETURN count(r) AS c")
.single().get("c").asLong();
System.out.printf(" %-20s %d%n", type + ":", count);
}
}

static List<List<String>> detectFraudRings(Session session) {
List<String> fraudAccounts = session
.run("MATCH (a:Account {fraud_confirmed: true}) RETURN a.id AS id")
Expand Down
2 changes: 1 addition & 1 deletion system_design/theory.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ If the phone number is stored as a text field on each account, it stays invisibl

Ring membership is a **structural signal**: it tells you an account is connected to a network of other suspicious accounts, regardless of its own transaction history. An account could have very few transactions but still be deeply embedded in a fraud ring.

The risk score in `05_risk_scoring.cypher` combines both signals into one number per account — how close it is to a confirmed fraud node, how many shared identifiers it has with flagged accounts, how fast it moves money, and whether it belongs to a ring. That combined score is what you hand to an analyst. Instead of reviewing thousands of accounts blindly, they start from the top of the list.
The risk score in `risk_scoring.cypher` combines both signals into one number per account — how close it is to a confirmed fraud node, how many shared identifiers it has with flagged accounts, how fast it moves money, and whether it belongs to a ring. That combined score is what you hand to an analyst. Instead of reviewing thousands of accounts blindly, they start from the top of the list.

---

Expand Down
Loading