<?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: Asheesh Kumar</title>
    <description>The latest articles on DEV Community by Asheesh Kumar (@asheesh7298).</description>
    <link>https://dev.to/asheesh7298</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%2F4140737%2F7e90d24c-7db6-47d1-a81a-cc06dd75a3f5.jpg</url>
      <title>DEV Community: Asheesh Kumar</title>
      <link>https://dev.to/asheesh7298</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/asheesh7298"/>
    <language>en</language>
    <item>
      <title>Finding fraud rings a risk model can't see: an agentic investigator on TigerGraph</title>
      <dc:creator>Asheesh Kumar</dc:creator>
      <pubDate>Thu, 24 Sep 2026 08:07:00 +0000</pubDate>
      <link>https://dev.to/asheesh7298/finding-fraud-rings-a-risk-model-cant-see-an-agentic-investigator-on-tigergraph-14gf</link>
      <guid>https://dev.to/asheesh7298/finding-fraud-rings-a-risk-model-cant-see-an-agentic-investigator-on-tigergraph-14gf</guid>
      <description>&lt;p&gt;&lt;em&gt;Built for the TigerGraph × Hacker House Goa 2026 challenge, on the IEEE-CIS&lt;br&gt;
(Vesta) card-fraud dataset.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Fraud analysts don't lack data — they lack time. Every alert means pulling a&lt;br&gt;
card's history, tracing connected accounts, checking devices and regions,&lt;br&gt;
reading policy, and writing it all up before deciding. This project is an AI&lt;br&gt;
agent that does that investigation on a TigerGraph knowledge graph and comes&lt;br&gt;
back with a verdict, an exposure figure, a policy-compliant next action, and —&lt;br&gt;
where the rules require it — a suspicious activity report.&lt;/p&gt;

&lt;p&gt;The dataset has a deliberate twist: &lt;strong&gt;there is no fraud label.&lt;/strong&gt; The only&lt;br&gt;
ground truth is 5,565 closed historical investigations. The 20 exam cases are&lt;br&gt;
unlabeled and graded against a hidden key. You have to decide each one from&lt;br&gt;
evidence alone.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;For every alert, one investigation runs a fixed loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TRIGGER → GATHER → RETRIEVE → ASSESS
   → snapshot the initial recommendation → REQUEST EVIDENCE → RE-ASSESS
   → DECIDE → EXPLAIN → PERSIST
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It gathers the transaction and the cardholder's baseline from the graph,&lt;br&gt;
retrieves connected prior cases and the relevant policy via GraphRAG, assesses&lt;br&gt;
the situation, records what it would do &lt;em&gt;before&lt;/em&gt; asking for more evidence,&lt;br&gt;
simulates the cardholder's response, re-assesses, and then commits to a final&lt;br&gt;
recommendation. Each case becomes one answer file: the case record, the SAR&lt;br&gt;
when policy calls for one, and the next-best-action with its approval route,&lt;br&gt;
both before and after the evidence request.&lt;/p&gt;

&lt;p&gt;It's a plain Python state machine — a dataclass and one function per stage. No&lt;br&gt;
agent framework. The transitions are explicit enough that a framework would&lt;br&gt;
have added vocabulary, not structure.&lt;/p&gt;
&lt;h2&gt;
  
  
  The decision that shaped everything: what the LLM is &lt;em&gt;not&lt;/em&gt; allowed to do
&lt;/h2&gt;

&lt;p&gt;About 60% of the grade is read straight out of the answer files, and roughly&lt;br&gt;
half of those fields are mechanically checkable — an exposure figure that must&lt;br&gt;
equal the sum of the flagged transactions, an approval route fixed by policy, a&lt;br&gt;
SAR flag that must match whether &lt;code&gt;FILE_REPORT&lt;/code&gt; is in the action list. A model&lt;br&gt;
that guesses at those will eventually contradict itself.&lt;/p&gt;

&lt;p&gt;So the work is split down the middle:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The LLM decides&lt;/th&gt;
&lt;th&gt;Code decides&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;verdict on ambiguous cases&lt;/td&gt;
&lt;td&gt;exposure (sum of the episode)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fraud probability, within anchored bands&lt;/td&gt;
&lt;td&gt;whether a report is filed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the evidence wording, the case summary&lt;/td&gt;
&lt;td&gt;every approval route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the SAR narrative&lt;/td&gt;
&lt;td&gt;the action set (rules R1–R10)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;em&gt;confirming&lt;/em&gt; the pattern&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;proposing&lt;/em&gt; the pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;the stop condition, the case status&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This wasn't a guess. Handed the four-charges-under-$500 structuring burst, the&lt;br&gt;
model described it perfectly in prose — "four online charges of $450–499 within&lt;br&gt;
thirty minutes, consistent with structuring" — and then labeled it&lt;br&gt;
&lt;code&gt;card_not_present_fraud&lt;/code&gt;. Reasoning about evidence and picking a label from a&lt;br&gt;
closed vocabulary are different skills, and only one of them needs a model. The&lt;br&gt;
label is a rule; the model explains it.&lt;/p&gt;

&lt;p&gt;Every invariant is enforced at construction with Pydantic, so an answer file&lt;br&gt;
that contradicts itself literally cannot be built. When the model's probability&lt;br&gt;
comes back clustered at 0.9 (which it does, every time, un-anchored), it's&lt;br&gt;
clamped into the band its evidence supports.&lt;/p&gt;
&lt;h2&gt;
  
  
  How TigerGraph is used
&lt;/h2&gt;

&lt;p&gt;The graph is the investigation surface, not a bucket the agent dumps data into&lt;br&gt;
and reads back. Eight GSQL queries do the work, and one of them is the whole&lt;br&gt;
reason this project has a story.&lt;/p&gt;
&lt;h3&gt;
  
  
  Finding a ring without being told one exists
&lt;/h3&gt;

&lt;p&gt;Two of the confirmed historical patterns are undocumented rings. One is a&lt;br&gt;
device ring: a single phone, behind an anonymising proxy, used to make small&lt;br&gt;
purchases on dozens of unrelated cards. The obvious way to find it — count how&lt;br&gt;
many cards share each device profile — &lt;strong&gt;does not work.&lt;/strong&gt; Over the exam window,&lt;br&gt;
the most-shared profiles are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;367 cards   unknown | unknown | unknown | unknown         (missing data)
251 cards   Windows | Windows 10 | chrome 65.0            (a popular browser)
183 cards   iOS Device | iOS 11.3.0 | mobile safari       (a popular phone)
 28 cards   &amp;lt;the actual ring&amp;gt;                             — ranked 71st
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Popularity is not conspiracy. What separates the ring is two behavioural&lt;br&gt;
ratios: how often the device was &lt;em&gt;new to the account&lt;/em&gt; using it, and how often&lt;br&gt;
it sat behind an &lt;em&gt;anonymising proxy&lt;/em&gt;. A genuinely shared device — a family&lt;br&gt;
tablet, an office desktop — has returning users and no proxy. The ring is new&lt;br&gt;
to &lt;strong&gt;every&lt;/strong&gt; account it touches and proxied on &lt;strong&gt;every&lt;/strong&gt; transaction. Ranking&lt;br&gt;
by &lt;code&gt;cards × new_ratio × anon_ratio&lt;/code&gt; puts it first, at a 4× margin over the next&lt;br&gt;
candidate — with no device string anywhere in the query. &lt;code&gt;ring_detect&lt;/code&gt; finds it&lt;br&gt;
as a property of the portfolio, not something hard-coded.&lt;/p&gt;

&lt;p&gt;The other queries are quieter but carry the verdicts: &lt;code&gt;customer_baseline&lt;/code&gt;&lt;br&gt;
(is this consistent with how this card is used?), &lt;code&gt;card_window&lt;/code&gt; (the episode),&lt;br&gt;
&lt;code&gt;device_neighbors&lt;/code&gt;, &lt;code&gt;region_history&lt;/code&gt; (a trip or a cloned card?), and&lt;br&gt;
&lt;code&gt;similar_closed_cases&lt;/code&gt; (prior cases connected by shared entity).&lt;/p&gt;

&lt;h3&gt;
  
  
  GraphRAG that retrieves a rule, not a blob
&lt;/h3&gt;

&lt;p&gt;The fraud policy is split per-rule and embedded into TigerGraph's vector store&lt;br&gt;
alongside the pattern typologies and case history. Retrieving "the policy" as&lt;br&gt;
one blob would be useless — it's thousands of words and the model still has to&lt;br&gt;
find the relevant rule. Instead, the situation &lt;em&gt;"customer disputes a charge&lt;br&gt;
they make regularly; should we block the card?"&lt;/em&gt; retrieves &lt;strong&gt;R7 (disputed but&lt;br&gt;
legitimate) at 0.79&lt;/strong&gt;, then R2, then the false-alarm calibration note — the&lt;br&gt;
governing rule and the argument &lt;em&gt;against&lt;/em&gt; acting, together. Vectors live in&lt;br&gt;
TigerGraph; similarity runs in GSQL. The whole retrieval surface — plus every&lt;br&gt;
query above — is also served over the &lt;strong&gt;Model Context Protocol&lt;/strong&gt;, so the&lt;br&gt;
agent's graph capabilities are real MCP tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agentic capabilities
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Uncertainty handling.&lt;/strong&gt; A weak single signal doesn't get a block; it gets a
verification request. The recommendation is snapshotted before evidence and
again after, and the two differ when the assumed response moves the needle —
which is exactly what the policy asks to be shown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The R7 trap.&lt;/strong&gt; The closed history contains 900 cleared cases and &lt;strong&gt;zero&lt;/strong&gt;
are customer disputes — every one is a model false alarm. An agent that
learns its prior from history concludes "dispute = fraud" and blocks the very
cases R7 exists to protect. The recurrence check runs against the graph, not
precedent: a charge the cardholder has made 170 times before is a charge they
forgot they authorise, not fraud.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chronological case memory.&lt;/strong&gt; The pack is processed in &lt;code&gt;opened_at&lt;/code&gt; order, so
the ring case lands fourth and the sixteen investigations after it can
retrieve it from the graph and cite it. The agent writes its own closed cases
back as vertices — memory that grows across the run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policy as code.&lt;/strong&gt; Routes, the SAR decision, and the R1–R10 action sets are
deterministic and reproduce the organisers' one worked example exactly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The final distribution on the 20 cases: 7 fraud, 12 legitimate, 1 uncertain;&lt;br&gt;
both rings found; 3 SARs filed including a case sitting &lt;em&gt;exactly&lt;/em&gt; on the&lt;br&gt;
$1,000.03 reporting boundary; zero over-blocking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;The lesson that repeated itself, five times, was this: &lt;strong&gt;every serious bug&lt;br&gt;
reported success while doing nothing.&lt;/strong&gt; In order of appearance:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;code&gt;card_id&lt;/code&gt; reconstruction rule I was confident in scored 20/20 on the
exam cases — and was &lt;em&gt;wrong&lt;/em&gt;, failing 309 of the closed cases. The exam set
was too small to distinguish the real rule (&lt;code&gt;(card1, card4, card6)&lt;/code&gt;
lexicographic) from a plausible impostor.&lt;/li&gt;
&lt;li&gt;The graph load reported perfect vertex counts — 590,742 transactions — and
was completely empty. The REST upsert API wants attribute values wrapped as
&lt;code&gt;{"value": ...}&lt;/code&gt;; given bare values it creates the vertex and silently
defaults every field. Every timestamp was 1970.&lt;/li&gt;
&lt;li&gt;A device query returned nothing because &lt;code&gt;urlencode&lt;/code&gt; renders a space as &lt;code&gt;+&lt;/code&gt;
and TigerGraph matched it literally — the profile key could never equal the
stored one.&lt;/li&gt;
&lt;li&gt;The ring detector's first threshold admitted a generic corporate browser
(IE11-on-Windows-7, 138 cards) as a "ring" and mislabeled a legitimate
recurring-charge case.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these would have been caught by "did it return something?" All were&lt;br&gt;
caught by asserting against facts known independently — this transaction is&lt;br&gt;
$482.12 on &lt;em&gt;that&lt;/em&gt; card; the ring spans exactly 28 cards; R7 must retrieve&lt;br&gt;
first. The test suite is built almost entirely from ground truth, not from the&lt;br&gt;
code's own output, and that is the only reason the submission is trustworthy.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bulk loading.&lt;/strong&gt; I loaded via batched REST upserts (resumable, which mattered
when the free-tier workspace auto-suspended mid-run). The canonical path is a
GSQL loading job — 600K rows in minutes rather than twenty. It works; it's
just not the fast road.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Holdout coverage.&lt;/strong&gt; The scoring harness reshapes October closed cases into
exam-shaped alerts, but every cleared case there is a risk-score false alarm,
so it can't test the "customer disputes something legitimate" path — exactly
the R7 case that matters most. That path is unit-tested against ground truth
instead, but I'd rather measure it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model routing.&lt;/strong&gt; Reasoning runs on a free, fast open model; the deterministic
fields never touch it. With budget I'd A/B the SAR narratives against a
stronger model, since explainability is the one place prose quality shows up
in the grade.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The strongest thing I can say about the result is that it moves from an&lt;br&gt;
uncertain signal to a defensible action and shows its work at every step — and&lt;br&gt;
that when it's unsure, it says so and asks, instead of blocking a customer who&lt;br&gt;
did nothing wrong.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Code: github.com/Asheesh7298/tigergraph-fraud-agent&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>data</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
