"""Generate Cortex Bank's operational graph (~1M nodes, ~5M relationships) with planted fraud rings. Usage: python -m generator.ops_data [--scale 1.0] (run from cortex/) Outputs CSVs for falkordb-bulk-insert into data/ops/, and the fraud-ring answer key into data/ops_gold.json. Deterministic: the same seed always produces the same bank. """ import argparse import csv import json import random from pathlib import Path ROOT = Path(__file__).resolve().parent.parent OUT = ROOT / "data" / "ops" SEED = 1926 BASE = {"Customer": 250_000, "Account": 300_000, "Device": 180_000, "Address": 170_000, "IP": 100_000, "Merchant": 5_000, "Branch": 40} TRANSFERS_PER_ACCOUNT = 11 PAYMENTS_PER_ACCOUNT = 2 RINGS = 60 def main(): ap = argparse.ArgumentParser() ap.add_argument("--scale", type=float, default=1.0, help="0.05 = small test graph") scale = ap.parse_args().scale rnd = random.Random(SEED) n = {k: max(5, int(v * scale)) if k != "Branch" else v for k, v in BASE.items()} OUT.mkdir(parents=True, exist_ok=True) def w(name, header, rows): with open(OUT / f"{name}.csv", "w", newline="") as f: cw = csv.writer(f) cw.writerow(header) cw.writerows(rows) segs = ["Retail", "Retail", "Retail", "Premier", "SME", "Wealth"] risk = ["Low"] * 7 + ["Medium"] * 2 + ["High"] w("Branch", ["id", "name", "region"], ((f"B{i}", f"Branch {i:02d}", rnd.choice(["North", "South", "East", "West", "Harbour"])) for i in range(n["Branch"]))) w("Customer", ["id", "segment", "risk_rating", "since"], ((f"C{i}", rnd.choice(segs), rnd.choice(risk), rnd.randint(1960, 2026)) for i in range(n["Customer"]))) w("Device", ["id", "type"], ((f"D{i}", rnd.choice(["iOS", "Android", "Web"])) for i in range(n["Device"]))) w("Address", ["id", "postcode"], ((f"AD{i}", f"PA{rnd.randint(1, 99)} {rnd.randint(1, 9)}XZ") for i in range(n["Address"]))) w("IP", ["id", "country"], ((f"IP{i}", rnd.choice(["GB"] * 9 + ["NL", "AE", "CY"])) for i in range(n["IP"]))) w("Merchant", ["id", "category"], ((f"M{i}", rnd.choice(["Grocery", "Fuel", "Travel", "Online", "Gambling", "Crypto"])) for i in range(n["Merchant"]))) nc, na = n["Customer"], n["Account"] acct_owner = [i if i < nc else rnd.randrange(nc) for i in range(na)] w("Account", ["id", "type", "branch"], ((f"A{i}", rnd.choice(["Current", "Current", "Savings", "Business"]), f"B{rnd.randrange(n['Branch'])}") for i in range(na))) w("OWNS", ["src", "dst"], ((f"C{o}", f"A{i}") for i, o in enumerate(acct_owner))) # Normal behaviour: customers mostly have their own device and address. uses, lives, logs = [], [], [] for c in range(nc): uses.append((f"C{c}", f"D{rnd.randrange(n['Device'])}")) if rnd.random() < 0.4: uses.append((f"C{c}", f"D{rnd.randrange(n['Device'])}")) lives.append((f"C{c}", f"AD{rnd.randrange(n['Address'])}")) for d in range(n["Device"]): logs.append((f"D{d}", f"IP{rnd.randrange(n['IP'])}")) transfers = [] for a in range(na): for _ in range(rnd.randint(TRANSFERS_PER_ACCOUNT // 2, TRANSFERS_PER_ACCOUNT * 3 // 2)): transfers.append((f"A{a}", f"A{rnd.randrange(na)}", round(rnd.lognormvariate(5, 1.2), 2), rnd.randint(1, 365))) payments = [(f"A{rnd.randrange(na)}", f"M{rnd.randrange(n['Merchant'])}", round(rnd.lognormvariate(3.5, 1), 2)) for _ in range(na * PAYMENTS_PER_ACCOUNT)] # Plant fraud rings: shared device + shared address + circular sub-10,000 transfers (structuring). gold, used = [], set() for r in range(RINGS if scale >= 0.2 else 5): size = rnd.randint(5, 12) members = [] while len(members) < size: c = rnd.randrange(nc) if c not in used and c < na: used.add(c) members.append(c) dev, addr, ip = f"D{rnd.randrange(n['Device'])}", f"AD{rnd.randrange(n['Address'])}", f"IP{rnd.randrange(n['IP'])}" for c in members: uses.append((f"C{c}", dev)) if rnd.random() < 0.7: lives.append((f"C{c}", addr)) logs.append((dev, ip)) accts = [f"A{c}" for c in members] # account i is owned by customer i for i < nc for i, a in enumerate(accts): transfers.append((a, accts[(i + 1) % len(accts)], round(rnd.uniform(9000, 9990), 2), rnd.randint(300, 365))) gold.append({"ring": r, "customers": [f"C{c}" for c in members], "accounts": accts, "shared_device": dev, "shared_address": addr}) w("USES_DEVICE", ["src", "dst"], uses) w("LIVES_AT", ["src", "dst"], lives) w("LOGGED_FROM", ["src", "dst"], logs) w("TRANSFER", ["src", "dst", "amount", "day"], transfers) w("PAID", ["src", "dst", "amount"], payments) (ROOT / "data" / "ops_gold.json").write_text(json.dumps(gold)) nodes = sum(n.values()) rels = len(acct_owner) + len(uses) + len(lives) + len(logs) + len(transfers) + len(payments) print(f"nodes={nodes:,} relationships={rels:,} rings={len(gold)}") if __name__ == "__main__": main()