Skip to content

Commit 206163d

Browse files
fabio-rovaiclaude
andcommitted
Fix CI: format evaluate.py with Black and raise coverage to 98%
Addresses the two red gates from review on PR #16: - Lint: Black --check failed because evaluate.py's multi-line print statements were not in canonical form. Reformatted with the project's pinned Black target (py310-py312); ruff and `mypy src/` stay green. - Coverage: total was 88.6% (<90% gate); evaluate.py was 56% covered. Added 8 tests exercising the previously untested paths: the edge/CSV and headerless flag loaders, discover_account_universe, the empty-ring branch, the missing-fraud_cases FileNotFoundError, and the CLI (run_cli human + --json, main entrypoint, _print_summary). evaluate.py is now 99% covered and total coverage is 98.4%; the full suite is 62 tests passing. Defaults and behaviour unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 725d1eb commit 206163d

2 files changed

Lines changed: 136 additions & 10 deletions

File tree

src/gen_fraud_graph/evaluate.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -330,16 +330,20 @@ def _print_summary(metrics: dict) -> None:
330330
print(f" precision : {acc['precision']:.4f}")
331331
print(f" recall : {acc['recall']:.4f}")
332332
print(f" f1 : {acc['f1']:.4f}")
333-
print(f" tp/fp/fn : {acc['true_positives']}/{acc['false_positives']}/"
334-
f"{acc['false_negatives']}")
333+
print(
334+
f" tp/fp/fn : {acc['true_positives']}/{acc['false_positives']}/"
335+
f"{acc['false_negatives']}"
336+
)
335337
print(f"Ring level (threshold {ring['threshold']}):")
336338
print(f" precision : {ring['precision']:.4f}")
337339
print(f" recall : {ring['recall']:.4f}")
338340
print(f" f1 : {ring['f1']:.4f}")
339341
print(f" detected : {ring['detected']}/{ring['total']}")
340342
tn = conf["true_negatives"]
341-
print(f"Confusion : tp={conf['true_positives']} fp={conf['false_positives']} "
342-
f"fn={conf['false_negatives']} tn={tn if tn is not None else 'unknown'}")
343+
print(
344+
f"Confusion : tp={conf['true_positives']} fp={conf['false_positives']} "
345+
f"fn={conf['false_negatives']} tn={tn if tn is not None else 'unknown'}"
346+
)
343347
print("=" * 50)
344348

345349

tests/test_evaluate.py

Lines changed: 128 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
from __future__ import annotations
1313

1414
import csv
15+
import json
1516
import os
1617
import shutil
18+
import sys
1719
import tempfile
1820

1921
import pytest
@@ -22,10 +24,13 @@
2224
from gen_fraud_graph.embeddings import EmbeddingGenerator
2325
from gen_fraud_graph.evaluate import (
2426
FraudRing,
27+
discover_account_universe,
2528
evaluate,
2629
evaluate_dataset,
2730
load_flagged_accounts,
2831
load_fraud_rings,
32+
main,
33+
run_cli,
2934
)
3035
from gen_fraud_graph.typologies import FraudRingGenerator
3136

@@ -123,9 +128,7 @@ def test_load_fraud_rings_roundtrip(tmp_dir):
123128
path = os.path.join(tmp_dir, "fraud_cases.csv")
124129
with open(path, "w", newline="") as fh:
125130
w = csv.writer(fh)
126-
w.writerow(
127-
["pattern_id", "start_acc_id", "pattern_type", "depth", "involved_accounts"]
128-
)
131+
w.writerow(["pattern_id", "start_acc_id", "pattern_type", "depth", "involved_accounts"])
129132
w.writerow(["pat_0", "acc_1", "cycle", "3", "acc_1|acc_2|acc_3"])
130133
rings = load_fraud_rings(path)
131134
assert len(rings) == 1
@@ -149,15 +152,134 @@ def test_load_flagged_accounts_plain_and_csv(tmp_dir):
149152
assert load_flagged_accounts(csv_path) == {"acc_9", "acc_8"}
150153

151154

155+
def test_load_flagged_accounts_edge_csv(tmp_dir):
156+
"""A CSV with src_id/dst_id columns flags both endpoints of each edge."""
157+
csv_path = os.path.join(tmp_dir, "edges.csv")
158+
with open(csv_path, "w", newline="") as fh:
159+
w = csv.writer(fh)
160+
w.writerow(["src_id", "dst_id", "amount"])
161+
w.writerow(["acc_1", "acc_2", "9999.00"])
162+
w.writerow(["acc_2", "acc_3", "9999.00"])
163+
w.writerow(["", "acc_4", "0"]) # blank src is ignored, dst still flagged
164+
assert load_flagged_accounts(csv_path) == {"acc_1", "acc_2", "acc_3", "acc_4"}
165+
166+
167+
def test_load_flagged_accounts_csv_without_header(tmp_dir):
168+
"""A comma-bearing file with no recognised id column reads the first column."""
169+
csv_path = os.path.join(tmp_dir, "unknown.csv")
170+
with open(csv_path, "w", newline="") as fh:
171+
w = csv.writer(fh)
172+
w.writerow(["foo", "bar"]) # unrecognised header -> treated as first-column data
173+
w.writerow(["acc_7", "ignored"])
174+
w.writerow(["", "skip"]) # blank first cell is skipped
175+
assert load_flagged_accounts(csv_path) == {"foo", "acc_7"}
176+
177+
178+
def test_discover_account_universe(tmp_dir):
179+
"""discover_account_universe reads account ids from <data>/accounts/*.csv."""
180+
assert discover_account_universe(tmp_dir) is None # no accounts dir yet
181+
182+
acc_dir = os.path.join(tmp_dir, "accounts")
183+
os.makedirs(acc_dir)
184+
with open(os.path.join(acc_dir, "accounts_0.csv"), "w", newline="") as fh:
185+
w = csv.writer(fh)
186+
w.writerow(["account_id", "name"])
187+
w.writerow(["acc_1", "Alice"])
188+
w.writerow(["acc_2", "Bob"])
189+
# A non-CSV file in the directory is ignored.
190+
with open(os.path.join(acc_dir, "README.txt"), "w") as fh:
191+
fh.write("not a csv")
192+
193+
assert discover_account_universe(tmp_dir) == {"acc_1", "acc_2"}
194+
195+
196+
def test_evaluate_ignores_empty_ring():
197+
"""A ring with no involved accounts is skipped at the ring level."""
198+
rings = [
199+
FraudRing("R1", frozenset({"a1", "a2"})),
200+
FraudRing("R_empty", frozenset()),
201+
]
202+
m = evaluate(rings, {"a1", "a2"})
203+
assert m["ring"]["detected"] == 1
204+
assert m["ring"]["total"] == 2
205+
206+
207+
def test_evaluate_dataset_missing_fraud_cases(tmp_dir):
208+
"""evaluate_dataset raises a clear error when fraud_cases.csv is absent."""
209+
flagged = os.path.join(tmp_dir, "flagged.txt")
210+
with open(flagged, "w") as fh:
211+
fh.write("acc_1\n")
212+
with pytest.raises(FileNotFoundError, match="fraud_cases.csv"):
213+
evaluate_dataset(tmp_dir, flagged)
214+
215+
216+
def _build_dataset(root):
217+
"""Write a minimal dataset (fraud/ + accounts/) and a flagged file.
218+
219+
Ground truth: one ring {acc_1, acc_2}. Universe adds one legit acc_3.
220+
Flagged: acc_1, acc_2 (perfect detection, one true negative).
221+
"""
222+
fraud_dir = os.path.join(root, "fraud")
223+
os.makedirs(fraud_dir)
224+
with open(os.path.join(fraud_dir, "fraud_cases.csv"), "w", newline="") as fh:
225+
w = csv.writer(fh)
226+
w.writerow(["pattern_id", "start_acc_id", "pattern_type", "depth", "involved_accounts"])
227+
w.writerow(["pat_0", "acc_1", "cycle", "2", "acc_1|acc_2"])
228+
acc_dir = os.path.join(root, "accounts")
229+
os.makedirs(acc_dir)
230+
with open(os.path.join(acc_dir, "accounts_0.csv"), "w", newline="") as fh:
231+
w = csv.writer(fh)
232+
w.writerow(["account_id"])
233+
for aid in ("acc_1", "acc_2", "acc_3"):
234+
w.writerow([aid])
235+
flagged = os.path.join(root, "flagged.txt")
236+
with open(flagged, "w") as fh:
237+
fh.write("acc_1\nacc_2\n")
238+
return flagged
239+
240+
241+
def test_run_cli_human_summary(tmp_dir, capsys):
242+
"""run_cli prints a human-readable summary and returns exit code 0."""
243+
flagged = _build_dataset(tmp_dir)
244+
rc = run_cli(["--data", tmp_dir, "--flagged", flagged])
245+
assert rc == 0
246+
out = capsys.readouterr().out
247+
assert "gen_fraud_graph evaluate" in out
248+
assert "Account level:" in out
249+
assert "Ring level" in out
250+
assert "tn=1" in out # acc_3 is the single true negative
251+
252+
253+
def test_run_cli_json_output(tmp_dir, capsys):
254+
"""run_cli --json prints valid JSON metrics."""
255+
flagged = _build_dataset(tmp_dir)
256+
rc = run_cli(["--data", tmp_dir, "--flagged", flagged, "--json", "--ring-threshold", "0.5"])
257+
assert rc == 0
258+
payload = json.loads(capsys.readouterr().out)
259+
assert payload["account"]["f1"] == pytest.approx(1.0)
260+
assert payload["ring_threshold"] == pytest.approx(0.5)
261+
assert payload["confusion"]["true_negatives"] == 1
262+
263+
264+
def test_main_entrypoint(tmp_dir, capsys, monkeypatch):
265+
"""main() parses sys.argv and exits 0 on success."""
266+
flagged = _build_dataset(tmp_dir)
267+
monkeypatch.setattr(
268+
sys, "argv", ["gen-fraud-graph-evaluate", "--data", tmp_dir, "--flagged", flagged]
269+
)
270+
with pytest.raises(SystemExit) as exc:
271+
main()
272+
assert exc.value.code == 0
273+
assert "gen_fraud_graph evaluate" in capsys.readouterr().out
274+
275+
152276
def test_evaluate_dataset_end_to_end(tmp_dir):
153277
"""evaluate_dataset reads fraud_cases.csv and a flagged file from disk."""
154278
fraud_dir = os.path.join(tmp_dir, "fraud")
155279
os.makedirs(fraud_dir)
156280
with open(os.path.join(fraud_dir, "fraud_cases.csv"), "w", newline="") as fh:
157281
w = csv.writer(fh)
158-
w.writerow(
159-
["pattern_id", "start_acc_id", "pattern_type", "depth", "involved_accounts"]
160-
)
282+
w.writerow(["pattern_id", "start_acc_id", "pattern_type", "depth", "involved_accounts"])
161283
w.writerow(["pat_0", "acc_1", "cycle", "2", "acc_1|acc_2"])
162284
flagged = os.path.join(tmp_dir, "flagged.txt")
163285
with open(flagged, "w") as fh:

0 commit comments

Comments
 (0)