<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Prayant Mohanty</title>
    <description>The latest articles on DEV Community by Prayant Mohanty (@prayant_mohanty_77d03552f).</description>
    <link>https://dev.to/prayant_mohanty_77d03552f</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4142237%2F02a56ae6-9dcd-433e-afc9-112e035b5259.png</url>
      <title>DEV Community: Prayant Mohanty</title>
      <link>https://dev.to/prayant_mohanty_77d03552f</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/prayant_mohanty_77d03552f"/>
    <language>en</language>
    <item>
      <title>The calibration bug that taught our fraud agent to wave fraud through</title>
      <dc:creator>Prayant Mohanty</dc:creator>
      <pubDate>Fri, 25 Sep 2026 04:59:42 +0000</pubDate>
      <link>https://dev.to/prayant_mohanty_77d03552f/the-calibration-bug-that-taught-our-fraud-agent-to-wave-fraud-through-onc</link>
      <guid>https://dev.to/prayant_mohanty_77d03552f/the-calibration-bug-that-taught-our-fraud-agent-to-wave-fraud-through-onc</guid>
      <description>&lt;p&gt;&lt;em&gt;Building an agentic fraud investigator on TigerGraph — and the two measurement&lt;br&gt;
mistakes that silently inverted it.&lt;/em&gt;&lt;/p&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;All 18 architecture diagrams, interactive:&lt;/strong&gt; &lt;a href="https://claude.ai/artifact/VioVT79phoeLTjBKkMWRvb" rel="noopener noreferrer"&gt;FraudGraph Blueprints&lt;/a&gt;:&lt;br&gt;
system, agent loop, MCP path, schema, GraphRAG, policy rules, all twenty&lt;br&gt;
benchmark cases, evaluation and failure modes.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The architecture at a glance
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fatu70vj585f75v50uub8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fatu70vj585f75v50uub8.png" alt="System architecture" width="800" height="325"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Three triggers, one LangGraph agent, TigerGraph reached through the MCP server, and a deterministic core the LLM cannot reach.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7t5d9qpb7cxqqvhavq1d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7t5d9qpb7cxqqvhavq1d.png" alt="Investigation loop" width="800" height="2621"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The one conditional edge that makes it an agent: gather more evidence, or act.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem, stripped down
&lt;/h2&gt;

&lt;p&gt;TigerGraph's Hacker House Goa challenge hands you six months of card&lt;br&gt;
transactions — 590,742 of them, from the IEEE-CIS dataset — and takes away the&lt;br&gt;
one column everyone reaches for. There is no &lt;code&gt;isFraud&lt;/code&gt; flag. Every transaction&lt;br&gt;
carries a &lt;strong&gt;risk score&lt;/strong&gt; from the bank's own model instead, and the dataset&lt;br&gt;
README is blunt about what that score is worth:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Above 0.7, most flagged transactions turn out to be legitimate. Some fraud&lt;br&gt;
scores near zero.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What you get instead of labels is four months of &lt;strong&gt;closed investigations&lt;/strong&gt; —&lt;br&gt;
5,565 of them, confirmed fraud and cleared false alarms, with the analyst's&lt;br&gt;
notes. And twenty new alerts to decide.&lt;/p&gt;

&lt;p&gt;So the task isn't classification. It's investigation: work out what kind of&lt;br&gt;
fraud this is, how far it goes, what to do about it, &lt;strong&gt;and when you know&lt;br&gt;
enough to act&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That last clause is the whole thing.&lt;/p&gt;


&lt;h2&gt;
  
  
  The shape that the submission format forces
&lt;/h2&gt;

&lt;p&gt;Buried in the answer format is a requirement that quietly determines the&lt;br&gt;
architecture:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The next best action and required approval route recorded: &lt;strong&gt;before&lt;/strong&gt; any&lt;br&gt;
additional evidence is requested, &lt;strong&gt;after&lt;/strong&gt; any additional evidence is&lt;br&gt;
received.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You cannot produce that pair honestly from a single-shot pipeline. There is no&lt;br&gt;
"before" unless the agent genuinely commits to a recommendation under&lt;br&gt;
uncertainty, &lt;em&gt;then&lt;/em&gt; decides what it needs, &lt;em&gt;then&lt;/em&gt; revises. So the topology is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;retrieve → believe → is this defensible?
                          │ no
                          ▼
              PROVISIONAL action + approval route
                          │
              pick the question worth asking
                          │
                      response
                          │
                          ▼
              REVISED action + what changed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything else follows from wanting that loop to be honest.&lt;/p&gt;




&lt;h2&gt;
  
  
  The split: what may hallucinate, and what may not
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdv06jabw215el7t1dfmi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdv06jabw215el7t1dfmi.png" alt="Trust boundaries" width="800" height="421"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The model sits outside the decision boundary; people sit on the approval boundary.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every decision a bank would have to justify to a regulator lives in a&lt;br&gt;
&lt;code&gt;core/&lt;/code&gt; package that &lt;strong&gt;cannot import an LLM&lt;/strong&gt;. Not "does not" — cannot. A test&lt;br&gt;
walks the AST of every file in that directory and fails the build if an import&lt;br&gt;
of &lt;code&gt;anthropic&lt;/code&gt;, &lt;code&gt;openai&lt;/code&gt;, &lt;code&gt;langchain&lt;/code&gt; or anything with &lt;code&gt;llm&lt;/code&gt; in the name ever&lt;br&gt;
appears:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_core_cannot_import_an_llm&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;banned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anthropic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;google&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;langchain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;langgraph&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ROOT&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fraudagent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;core&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;tree&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;offenders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;core/ must stay LLM-free: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;offenders&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That covers the Bayesian ledger, the policy engine, the approval routing, the&lt;br&gt;
stopping rule and the episode reconstruction. The model plans which graph&lt;br&gt;
primitives to run, synthesises evidence into prose, writes the case summary and&lt;br&gt;
the SAR narrative. If it proposes an action anyway, the policy decision object&lt;br&gt;
drops it before it can reach an answer file.&lt;/p&gt;

&lt;p&gt;The practical consequence, which I like more than the principle: the agent runs&lt;br&gt;
end to end with &lt;strong&gt;no API key at all&lt;/strong&gt;. Verdicts, probabilities, actions and&lt;br&gt;
approval routes are bit-identical; only the wording changes. Anyone can clone&lt;br&gt;
the repo and reproduce the twenty answer files without buying anything.&lt;/p&gt;


&lt;h2&gt;
  
  
  What TigerGraph is actually for here
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcaxco2ium7i9di13v72n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcaxco2ium7i9di13v72n.png" alt="GraphRAG memory" width="800" height="277"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Similar past cases found by meaning and by shared entities inside one GSQL query, then fused.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The obvious answer is "traversal", and that's true — but the reason this project&lt;br&gt;
belongs on a graph database with vectors &lt;em&gt;in the same store&lt;/em&gt; is a query that&lt;br&gt;
needs both at once.&lt;/p&gt;

&lt;p&gt;Case memory is the requirement: prior closed cases must measurably change what&lt;br&gt;
the agent concludes. The naive implementations both fail in the same&lt;br&gt;
characteristic way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vector search alone&lt;/strong&gt; returns prior cases that &lt;em&gt;sound&lt;/em&gt; similar. Narratives
are templated, so it happily returns five cases about card testing when you
asked about a different burst pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traversal alone&lt;/strong&gt; returns cases that are &lt;em&gt;connected&lt;/em&gt; — same card, same
device, same customer. Precise, and usually empty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither is what an analyst means by "have we seen this before". So fuse them, in&lt;br&gt;
one GSQL statement, inside the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;candidates = vectorSearch({ClosedCase.narrative_emb}, query_vec, k * 8);
candidates = SELECT c FROM candidates:c WHERE c.opened_at &amp;lt; as_of
             ACCUM @@vec_score += (c -&amp;gt; c.@distance);

linked_d = SELECT c FROM seed_dev:v -(CC_DEVICE_OF:e)- ClosedCase:c
           WHERE c.opened_at &amp;lt; as_of
           ACCUM @@shared += (c -&amp;gt; 1);

result = SELECT c FROM (candidates UNION linked_t UNION linked_c UNION linked_d):c
         ACCUM c.@score += 0.55 * @@vec_score.get(c)
                         + 0.45 * (1.0 - exp(-0.6 * @@shared.get(c)))
         ORDER BY c.@score DESC LIMIT k;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details do real work. The &lt;strong&gt;saturating&lt;/strong&gt; term on the graph leg means the&lt;br&gt;
first shared entity is worth a lot and the fourth almost nothing — with a linear&lt;br&gt;
term, one busy device profile dominates every ranking. And the &lt;code&gt;as_of&lt;/code&gt; guard on&lt;br&gt;
&lt;strong&gt;both&lt;/strong&gt; legs: a memory system that can retrieve a case opened after the one&lt;br&gt;
it's reasoning about isn't a memory system, it's a leak, and every number&lt;br&gt;
downstream of it is fiction.&lt;/p&gt;

&lt;p&gt;There's a second embedding space too, and it's the one I'd defend hardest.&lt;br&gt;
&lt;code&gt;V1&lt;/code&gt;–&lt;code&gt;V339&lt;/code&gt; are Vesta's engineered features: real signal, no names, and utterly&lt;br&gt;
hopeless as LLM context. 339 unnamed columns isn't evidence, it's noise with a&lt;br&gt;
schema. Compressed by incremental PCA to 24 components and concatenated with&lt;br&gt;
rank-normalised count and time-delta columns, they become a &lt;strong&gt;64-dimensional&lt;br&gt;
behaviour vector&lt;/strong&gt; stored on every &lt;code&gt;Transaction&lt;/code&gt;. Now "which confirmed-fraud&lt;br&gt;
transactions &lt;em&gt;behave&lt;/em&gt; like this one" is a vector query grounded in data, rather&lt;br&gt;
than a language model's impression of a table.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mistake one: the closed-case file is not a sample of alerts
&lt;/h2&gt;

&lt;p&gt;Here's the part I'd want to read in someone else's write-up.&lt;/p&gt;

&lt;p&gt;The plan was to measure likelihood ratios instead of guessing them: replay all&lt;br&gt;
5,565 closed investigations through the same extractor the agent uses, under&lt;br&gt;
each case's own &lt;code&gt;as_of&lt;/code&gt;, and count. Real numbers from real outcomes.&lt;/p&gt;

&lt;p&gt;The first run produced this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;signal&lt;/th&gt;
&lt;th&gt;P(s | fraud)&lt;/th&gt;
&lt;th&gt;P(s | cleared)&lt;/th&gt;
&lt;th&gt;LR&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;known_device_and_region&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.610&lt;/td&gt;
&lt;td&gt;0.188&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.30&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;amount_typical&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.394&lt;/td&gt;
&lt;td&gt;0.150&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.63&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;device_new_on_account&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.134&lt;/td&gt;
&lt;td&gt;0.484&lt;/td&gt;
&lt;td&gt;0.28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;amount_anomalous&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.078&lt;/td&gt;
&lt;td&gt;0.125&lt;/td&gt;
&lt;td&gt;0.62&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that carefully. It says a transaction on a &lt;strong&gt;known device, in a known&lt;br&gt;
region, for a typical amount&lt;/strong&gt; is over three times more likely to be fraud. And&lt;br&gt;
that a large, anomalous purchase from a brand-new device is &lt;em&gt;evidence of&lt;br&gt;
legitimacy&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Which is, taken at face value, insane. And the agent believed it completely —&lt;br&gt;
93% held-out accuracy, beautiful Brier score, and on the benchmark it confidently&lt;br&gt;
called quiet, ordinary transactions fraud while clearing the obvious ones.&lt;/p&gt;

&lt;p&gt;The bug isn't in the modelling. It's in what the closed-case file &lt;em&gt;is&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Every &lt;strong&gt;cleared&lt;/strong&gt; case in that history got there by scoring high on the bank's&lt;br&gt;
model. It's a travel trip, a new phone, an unusual but genuine purchase — an&lt;br&gt;
investigation that happened &lt;em&gt;because&lt;/em&gt; the transaction looked strange. Every&lt;br&gt;
&lt;strong&gt;confirmed&lt;/strong&gt; case got there because a cardholder phoned up about a charge they&lt;br&gt;
didn't recognise, and those are frequently small and unremarkable.&lt;/p&gt;

&lt;p&gt;So inside the closed-case file, "looks anomalous" genuinely does predict&lt;br&gt;
&lt;em&gt;cleared&lt;/em&gt;. The measurement was correct. The population was wrong.&lt;/p&gt;

&lt;p&gt;The fix is a third stratum the file doesn't contain: &lt;strong&gt;ordinary transactions&lt;br&gt;
from the same four months that no investigation ever touched.&lt;/strong&gt; Because months&lt;br&gt;
1–4 were fully worked by the bank's analysts, an untouched transaction there is&lt;br&gt;
a usable negative — and now the negative class represents non-fraud rather than&lt;br&gt;
representing false alarms.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mistake two: unmatched negatives are confounded by card tenure
&lt;/h2&gt;

&lt;p&gt;Adding untouched negatives helped, and did not fix it. &lt;code&gt;known_device_and_region&lt;/code&gt;&lt;br&gt;
came down to LR 1.56 — still pointing the wrong way.&lt;/p&gt;

&lt;p&gt;The remaining confound is the card, not the transaction. Confirmed-fraud cases&lt;br&gt;
sit disproportionately on &lt;strong&gt;long-lived, busy cards&lt;/strong&gt;: more history, therefore&lt;br&gt;
more established devices and regions, therefore "the device and region are&lt;br&gt;
already established" correlates with fraud through a variable that has nothing&lt;br&gt;
to do with fraud.&lt;/p&gt;

&lt;p&gt;The fix is a &lt;strong&gt;matched control design&lt;/strong&gt;. For each confirmed-fraud case, draw&lt;br&gt;
another transaction &lt;strong&gt;on the same card&lt;/strong&gt; as its control. The card-level confound&lt;br&gt;
cancels exactly, and the question the calibration answers becomes the question&lt;br&gt;
an analyst actually asks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What is different about &lt;em&gt;this&lt;/em&gt; transaction compared with the rest of this&lt;br&gt;
card's behaviour?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  And then the uncomfortable part
&lt;/h2&gt;

&lt;p&gt;With both corrections in, held-out fraud-versus-not accuracy went from &lt;strong&gt;0.93 to&lt;br&gt;
0.57&lt;/strong&gt;, and most of the likelihood ratios collapsed to about 1.&lt;/p&gt;

&lt;p&gt;My first instinct was that I'd broken something. I hadn't. That number is the&lt;br&gt;
data answering the question I'd finally asked correctly.&lt;/p&gt;

&lt;p&gt;Every confirmed case in this history was found by a cardholder ringing up. Fraud&lt;br&gt;
never entered that file &lt;em&gt;because it looked anomalous&lt;/em&gt; — it entered because&lt;br&gt;
someone noticed a charge. So once you remove the trigger as a feature, which you&lt;br&gt;
must because it is perfectly confounded with the outcome, the graph signals&lt;br&gt;
genuinely cannot separate confirmed fraud from ordinary activity on the same&lt;br&gt;
card. Most fraud in this dataset is small and unremarkable. That is the point.&lt;/p&gt;

&lt;p&gt;The 0.93 was the artefact. The 0.57 is the finding.&lt;/p&gt;

&lt;p&gt;What the history &lt;em&gt;can&lt;/em&gt; teach is which typology a fraud is, and it teaches that&lt;br&gt;
strongly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;signal&lt;/th&gt;
&lt;th&gt;CNP-new-device&lt;/th&gt;
&lt;th&gt;out-of-region&lt;/th&gt;
&lt;th&gt;account takeover&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;device_new_on_account&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.85&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.001&lt;/td&gt;
&lt;td&gt;0.07&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;match_flag_anomaly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.001&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.56&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.47&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;online_burst_2_to_4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.43&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.001&lt;/td&gt;
&lt;td&gt;0.04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;known_device_and_region&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.14&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.86&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the architecture follows the evidence rather than the other way round:&lt;br&gt;
fraud-versus-not is carried by the trigger prior and by the structural typology&lt;br&gt;
matchers; &lt;em&gt;which&lt;/em&gt; typology is carried by the measured table. I'd rather ship&lt;br&gt;
that with the 0.57 written on the tin than ship the 0.93 and let the reliability&lt;br&gt;
curve look excellent right up until the agent clears a real fraud for being&lt;br&gt;
unremarkable.&lt;/p&gt;

&lt;p&gt;Three lessons I'd take anywhere, not just here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A held-out accuracy number will not save you.&lt;/strong&gt; Ours was 93% while the
agent was systematically inverted. The metric was faithfully measuring how
well we'd learned a selection effect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at the sign, not just the size.&lt;/strong&gt; The clue was never the accuracy. It
was one likelihood ratio pointing in a direction no fraud analyst would
accept.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A metric getting worse can be the fix landing.&lt;/strong&gt; If you only ever ship
changes that move the number up, you will ship the bias.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Mistake three, smaller: the episode teaches its own baseline
&lt;/h2&gt;

&lt;p&gt;Related, and worth thirty seconds. If you measure "is this device new to the&lt;br&gt;
card" against the card's whole history up to the alert, the &lt;strong&gt;earlier legs of&lt;br&gt;
the same fraud episode&lt;/strong&gt; are in that history. The first fraudulent transaction&lt;br&gt;
teaches the baseline that the new device is normal, and by the fourth one&lt;br&gt;
nothing looks unusual at all.&lt;/p&gt;

&lt;p&gt;So anomaly is measured as &lt;strong&gt;suspect window against established baseline&lt;/strong&gt;, with&lt;br&gt;
the baseline cut off 72 hours before the alert. Seventy-five percent of&lt;br&gt;
closed-case episodes open within 29 hours of their alert, so the window catches&lt;br&gt;
the tail without swallowing ordinary history.&lt;/p&gt;




&lt;h2&gt;
  
  
  The two fraud patterns that aren't in the documentation
&lt;/h2&gt;

&lt;p&gt;The dataset drops a deliberate hint:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Not every fraud pattern present in the data is documented.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Five typologies are described. Nine closed cases are marked &lt;code&gt;undocumented&lt;/code&gt; —&lt;br&gt;
confirmed fraud the bank's own analysts could not categorise. Their narratives&lt;br&gt;
split cleanly into two signatures, and both turn up in the graded twenty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;U1 — sub-threshold authorisation structuring.&lt;/strong&gt; Four online purchases inside&lt;br&gt;
roughly forty minutes, each amount parked just beneath a $500 authorisation&lt;br&gt;
ceiling, about $1,900 in total. It's invisible to per-transaction scoring&lt;br&gt;
because &lt;em&gt;every individual leg looks completely ordinary&lt;/em&gt;. It only exists as a&lt;br&gt;
property of the burst. &lt;strong&gt;HHG-006&lt;/strong&gt; in the benchmark is one: $478.95, $456.96,&lt;br&gt;
$488.04, $482.12 in thirty minutes, $1,906.07 total — against five closed cases&lt;br&gt;
between $1,871 and $1,922.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;U2 — a shared-device ring behind an anonymising proxy.&lt;/strong&gt; One device profile&lt;br&gt;
across a run of unrelated cardholders in a single month, marked new on every&lt;br&gt;
account it touches. &lt;strong&gt;HHG-014&lt;/strong&gt; is one of these; the analyst request that&lt;br&gt;
triggers it even says so.&lt;/p&gt;

&lt;p&gt;The detection subtlety on U2 is the bit I'd put on a slide. A "device profile"&lt;br&gt;
in this dataset is a &lt;code&gt;DeviceInfo | OS | browser | screen&lt;/code&gt; string — a model, not&lt;br&gt;
a serial number — and &lt;strong&gt;92% of online transactions sit on a profile shared by&lt;br&gt;
three or more cards&lt;/strong&gt;. Counting cards per device finds nothing but noise, and a&lt;br&gt;
naive fan-out rule fired on half the benchmark.&lt;/p&gt;

&lt;p&gt;What separates a real hub is the &lt;strong&gt;anonymous-proxy ratio&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;device profile&lt;/th&gt;
&lt;th&gt;cards&lt;/th&gt;
&lt;th&gt;via anonymous proxy&lt;/th&gt;
&lt;th&gt;ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;`SM-G935F \&lt;/td&gt;
&lt;td&gt;Android 7.0 \&lt;/td&gt;
&lt;td&gt;chrome 62.0 \&lt;/td&gt;
&lt;td&gt;1920x1080`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`Windows \&lt;/td&gt;
&lt;td&gt;Windows 10 \&lt;/td&gt;
&lt;td&gt;chrome 65.0 \&lt;/td&gt;
&lt;td&gt;1920x1080`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`Trident/7.0 \&lt;/td&gt;
&lt;td&gt;Windows 10 \&lt;/td&gt;
&lt;td&gt;ie 11.0 \&lt;/td&gt;
&lt;td&gt;1920x1080`&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every card that ever touched the ring device arrived anonymously. That is not a&lt;br&gt;
household sharing a tablet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Choosing &lt;em&gt;what&lt;/em&gt; to ask, not just &lt;em&gt;that&lt;/em&gt; you need more
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc968t136fwuorj60fx2l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc968t136fwuorj60fx2l.png" alt="Decision authority" width="799" height="208"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Evidence becomes numbers through measured likelihood ratios; numbers become actions only through the written policy.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When belief won't support a defensible action, the agent has to pick one&lt;br&gt;
evidence action. Picking the strongest is wrong; picking the best information&lt;br&gt;
per unit of &lt;strong&gt;friction&lt;/strong&gt; is right — and friction is not money. Pinging a&lt;br&gt;
cardholder at two in the morning has a real cost even when the API call is free.&lt;/p&gt;

&lt;p&gt;Each candidate is scored by expected reduction in Shannon entropy over the&lt;br&gt;
hypothesis space, divided by its friction cost, and filtered through the policy&lt;br&gt;
&lt;strong&gt;first&lt;/strong&gt; — because the policy constrains &lt;em&gt;asking&lt;/em&gt;, not only acting. That lets&lt;br&gt;
the agent produce a sentence I'm fond of:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The highest-value next evidence is customer validation, but the contact-fatigue&lt;br&gt;
limit forbids a third contact this week, so I am escalating to an analyst&lt;br&gt;
instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That single sentence demonstrates uncertainty handling, policy compliance, next&lt;br&gt;
best action and explainability at once.&lt;/p&gt;

&lt;p&gt;And the stopping rule is two-sided. Anyone can stop when confident. The second&lt;br&gt;
condition is the one that matters:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;No permitted evidence action carries enough information per unit of customer&lt;br&gt;
friction to justify it. Further investigation would not change the decision, so&lt;br&gt;
the case goes to a human with what is known.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's a different and more defensible claim than "I'm sure", and it's what a&lt;br&gt;
real fraud desk does.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two cases the first build got wrong, and how the data said so
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9zuhzc3stgt3uj5y8v5f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9zuhzc3stgt3uj5y8v5f.png" alt="HHG-018 before and after evidence" width="800" height="378"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;HHG-018: a disputed charge the card has paid 21 times. Asked first, recognised, closed without a block.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxfgd4ozbgjw1wwf670fi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxfgd4ozbgjw1wwf670fi.png" alt="Ring detection" width="800" height="655"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Rings you can't see from one card: gated so a popular browser string never becomes a false ring.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After the first complete build I read ten other teams' write-ups for the same&lt;br&gt;
brief. Two disagreements with my answers were worth checking against the data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HHG-019 is a ring you can't see from one card.&lt;/strong&gt; It is a risk-model alert, and&lt;br&gt;
this history's risk-model alerts are almost always false alarms (900 of 900&lt;br&gt;
cleared). But ask what happened on &lt;em&gt;other&lt;/em&gt; cards: the flagged purchase's device —&lt;br&gt;
a profile only three cards had ever used — bought $100.00 and $100.06 on two other&lt;br&gt;
cards that week. &lt;code&gt;peer_purchases.gsql&lt;/code&gt; is that two-hop question. The gate matters&lt;br&gt;
more than the query: on a generic browser string ("chrome 66.0", 175 cards)&lt;br&gt;
ordinary shoppers with similar baskets would make a false ring, so it only fires&lt;br&gt;
on a rare device. Its weight is measured, not chosen. It fired on 31&lt;br&gt;
confirmed-fraud closed cases and no cleared ones, a likelihood ratio of 22.4,&lt;br&gt;
the strongest in the model.&lt;/p&gt;

&lt;p&gt;My first version of it made two mistakes, and the October holdout caught both.&lt;br&gt;
It treated the peer cards as &lt;em&gt;connected cards&lt;/em&gt;, which switched on R6 and the&lt;br&gt;
filing rule — and filed 19 reports on holdout cases the bank itself never&lt;br&gt;
reported. And it folded in "did not fire" on every other case, which nudged&lt;br&gt;
each one away from card-not-present fraud and cost nineteen correct pattern&lt;br&gt;
calls. Both fixes are about what the evidence actually proves. Peers move the&lt;br&gt;
probability; they don't prove a shared origin, so R6 and the report still need&lt;br&gt;
one. And the absence of a rare hub is not a finding, so the signal is&lt;br&gt;
fired-only. HHG-019 now ends as fraud at 0.95, card blocked, and no report:&lt;br&gt;
$99.92 on one card with a documented pattern meets none of §3a's conditions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HHG-018 is a subscription the cardholder forgot.&lt;/strong&gt; The disputed $39.08 charge&lt;br&gt;
appears on the card 21 times since July, every one to three weeks. R7 exists for&lt;br&gt;
exactly this. The first fix — "four identical charges over six weeks is a habit"&lt;br&gt;
— was wrong, and the October backtest said so: it fired on 15 disputes, 14 of&lt;br&gt;
them confirmed fraud, because a fraud episode repeats an amount too, in a burst&lt;br&gt;
(one had 21 charges 2.8 days apart). The rule that survives needs a quarter of&lt;br&gt;
steady history: at least eight charges over 90 days or more, a median gap of 5&lt;br&gt;
to 35 days. A burst can't fake that; HHG-018 clears it easily. The simulated&lt;br&gt;
reply follows R7's own premise — the cardholder recognises the charge — and the&lt;br&gt;
answer file says so.&lt;/p&gt;

&lt;p&gt;The lesson is the same one as the calibration trap: measure the change against&lt;br&gt;
data it could be wrong on, not only the case it was written for.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reaching the graph through MCP, and letting the algorithms speak
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F462ew0ai26dcdte6kdl2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F462ew0ai26dcdte6kdl2.png" alt="Graph access through MCP" width="800" height="332"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Every graph call goes through the official TigerGraph MCP server first, with a per-call fallback.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The agent never holds a database connection of its own. It starts the official&lt;br&gt;
&lt;code&gt;tigergraph-mcp&lt;/code&gt; server over stdio and calls &lt;code&gt;tigergraph__run_installed_query&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;get_node&lt;/code&gt;, &lt;code&gt;add_node&lt;/code&gt; and friends — 1,492 calls on the last full run, zero&lt;br&gt;
fallbacks. A gateway falls back to a direct connection per call, so a flaky&lt;br&gt;
tool call degrades one read, not a case; &lt;code&gt;GRAPH_STRICT=1&lt;/code&gt; turns that into a hard&lt;br&gt;
failure when you'd rather know.&lt;/p&gt;

&lt;p&gt;TigerGraph's GDS library earns its place once you point it at the right&lt;br&gt;
subgraph. WCC over the full card co-occurrence network returns one 6,125-card&lt;br&gt;
blob, held together by popular browser strings. Over the anonymous-proxy slice&lt;br&gt;
(1,725 edges) it isolates the HHG-014 ring as a 54-card component, with the&lt;br&gt;
case's card second by PageRank. That result is all-time structure, so it is&lt;br&gt;
cited next to the time-bounded evidence and never weighed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Things that turned out to be load-bearing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;card_id&lt;/code&gt; isn't in the data.&lt;/strong&gt; &lt;code&gt;transactions.csv&lt;/code&gt; has no card column, but the&lt;br&gt;
case pack and the closed cases are expressed entirely as &lt;code&gt;C01234-K1&lt;/code&gt;. A card is&lt;br&gt;
the &lt;code&gt;(customer_id, card2..card6)&lt;/code&gt; tuple — but the &lt;code&gt;K&lt;/code&gt; index isn't derivable from&lt;br&gt;
it, and every obvious ordering tops out near 50% agreement with the labels. The&lt;br&gt;
answer was to stop guessing and &lt;em&gt;pin&lt;/em&gt; it from the 14,975 transactions the&lt;br&gt;
dataset itself labels. 100% agreement, asserted by a verification step. Get this&lt;br&gt;
wrong and every card-scoped query silently breaks on half the cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reported probability is capped at 0.97.&lt;/strong&gt; &lt;code&gt;fraud_probability&lt;/code&gt; is explicitly&lt;br&gt;
scored for calibration, and reporting 1.00 isn't confidence — it's a missing&lt;br&gt;
error bar. The ceiling sits above the 0.85 action threshold, so it never changes&lt;br&gt;
an action.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every decision is hash-chained.&lt;/strong&gt; Edit a recorded rationale after the fact and&lt;br&gt;
&lt;code&gt;verify()&lt;/code&gt; tells you which link broke.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A write isn't a write until it reads back.&lt;/strong&gt; After writing the case subgraph&lt;br&gt;
the agent walks it with &lt;code&gt;case_chain&lt;/code&gt; and checks the transactions, connected cards&lt;br&gt;
and SAR landed. &lt;code&gt;written_to_graph&lt;/code&gt; means read back, not attempted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model may only cite what it was given.&lt;/strong&gt; Any LLM-written sentence that&lt;br&gt;
names a case, card or transaction absent from its brief is discarded for the&lt;br&gt;
template. Every evidence item says whether it is a graph fact, an inference, a&lt;br&gt;
model score, policy text or a simulated reply.&lt;/p&gt;




&lt;h2&gt;
  
  
  Every benchmark case, on one page
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnxsk4hva572nkrfzqtwt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnxsk4hva572nkrfzqtwt.png" alt="All twenty benchmark cases" width="800" height="2076"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;11 legitimate, 9 fraud, 2 SARs, 5 decisions changed after asking one question.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fco2cxlm8q2yawj5pzv17.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fco2cxlm8q2yawj5pzv17.png" alt="Evaluation and verification" width="800" height="391"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Measured on July–September, tested on an October holdout the calibration never saw.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The other diagrams (schema, case lifecycle, policy rules, console, monitoring, failure modes) are on the &lt;a href="https://claude.ai/artifact/VioVT79phoeLTjBKkMWRvb" rel="noopener noreferrer"&gt;interactive page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do with more time
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Close the memory loop properly.&lt;/strong&gt; Cases the agent writes are retrievable by
the next investigation, but the &lt;em&gt;likelihood-ratio table&lt;/em&gt; isn't recomputed from
them yet. Memory that updates the agent's evidence weighting, not just its
retrieval, is a materially stronger claim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learn the episode boundary instead of windowing it.&lt;/strong&gt; 72 hours is a defensible
constant fitted to the closed cases; a changepoint model over the card's own
activity would be better.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Propagate the ring.&lt;/strong&gt; When U2 fires, every connected card is a case waiting
to be opened. Right now they're monitored, not investigated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time-slice the graph algorithms.&lt;/strong&gt; WCC and PageRank see all six months, so
they can only be context. A projection cut at each alert's &lt;code&gt;as_of&lt;/code&gt; would let
them count as evidence.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
make ingest
python benchmark/run_20.py
python &lt;span class="nb"&gt;eval&lt;/span&gt;/audit_answers.py
python ui/server.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No API key, no database, no build step. The model writes the prose; the graph&lt;br&gt;
makes the decisions.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>graphdatabase</category>
      <category>agents</category>
    </item>
  </channel>
</rss>
