<?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: Swasthika Devadiga</title>
    <description>The latest articles on DEV Community by Swasthika Devadiga (@swasthikadev).</description>
    <link>https://dev.to/swasthikadev</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%2F4142312%2Fa4913270-cf61-4078-9a50-362e345d4353.png</url>
      <title>DEV Community: Swasthika Devadiga</title>
      <link>https://dev.to/swasthikadev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/swasthikadev"/>
    <language>en</language>
    <item>
      <title>Dragnet: an agentic fraud investigator on TigerGraph</title>
      <dc:creator>Swasthika Devadiga</dc:creator>
      <pubDate>Fri, 25 Sep 2026 05:49:05 +0000</pubDate>
      <link>https://dev.to/swasthikadev/dragnet-an-agentic-fraud-investigator-on-tigergraph-1im5</link>
      <guid>https://dev.to/swasthikadev/dragnet-an-agentic-fraud-investigator-on-tigergraph-1im5</guid>
      <description>&lt;p&gt;Built for the TigerGraph Hacker House Goa challenge. Take 20 fraud alerts from the last two months of a six-month card ledger, investigate each against a graph and 5,565 closed investigations, work out what kind of fraud it is if any, and recommend an action under a written bank policy. Half the exam cases are legitimate, so an agent that blocks everything fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the system
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UI (static SPA, app/frontend)  -&amp;gt;  FastAPI (app/backend/app.py)
investigator.py   trigger -&amp;gt; graph evidence -&amp;gt; GraphRAG retrieval -&amp;gt; score
                  -&amp;gt; R1..R10 decision -&amp;gt; evidence request -&amp;gt; re-score
                  -&amp;gt; explain -&amp;gt; write the case back to the graph
graphfactory.py   TigerGraphAdapter when the workspace answers, else
                  LocalGraphAdapter (sqlite), and it logs which and why
features.py / classifier.py / refit_head_v2.py / laya.py   scoring
corpus.py / retrieval.py / llm.py                          GraphRAG, narration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The graph is a TigerGraph Savanna workspace in ap-south-1, TG-0 (4 vCPU, 32Gi), TigerGraph 4.2.5, graph &lt;code&gt;FraudGraph&lt;/code&gt;. Loaded and checked against expected counts: Transaction 590,742, Card 14,317, Customer 13,553, DeviceProfile 9,706, ClosedCase 5,565, BillingRegion 332, EmailDomain 60. Thirteen GSQL queries drive the investigation; twelve were installed and enabled at the time of writing.&lt;/p&gt;

&lt;p&gt;Two schema decisions worth defending. The sparse Vesta columns (C1-C14, D1-D15, M1-M9, id_01-id_38) are stored as STRING, verbatim: typed numeric, an empty token becomes 0.0 and the agent cites "D1 = 0 days" for a value nobody measured. And identity data rides on the &lt;code&gt;FROM_DEVICE&lt;/code&gt; edge, not the Transaction vertex, because an identity record describes a device session, not a payment. Every edge declares a REVERSE_EDGE: walking from a device back to its transactions is the traversal this task turns on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent loop
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trigger.&lt;/strong&gt; A risk score, a customer report or an analyst request. Nothing dated after &lt;code&gt;max(flagged ts, opened_at)&lt;/code&gt; is read, so the agent cannot see its own future.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graph evidence.&lt;/strong&gt; Card timeline, device and region profiles, velocity, card-testing burst, shared-device cards, recurring match, and the closed cases on the card, the customer and the device. Each becomes an &lt;code&gt;evidence[]&lt;/code&gt; entry with &lt;code&gt;source: "graph"&lt;/code&gt; and a &lt;code&gt;ref&lt;/code&gt; naming the query behind it. Retrieved prior cases, rule text and typology text come back the same way with &lt;code&gt;source: "document"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score.&lt;/strong&gt; A calibrated probability, a 7-way pattern, a precondition veto, two undocumented detectors, and Laya as an optional disagreement signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide, ask, re-decide.&lt;/strong&gt; R1 to R10 produce initial actions, each carrying its route and its rule. When R1, R4 or R8 hold, the agent issues one evidence request with an explicit assumed response, re-scores, and emits final actions plus &lt;code&gt;what_changed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions.&lt;/strong&gt; &lt;code&gt;L1&lt;/code&gt; for DECLINE_TRANSACTION and BLOCK_CARD at or below $2,500, &lt;code&gt;L2&lt;/code&gt; above it and for BLOCK_ALL_CARDS and FILE_REPORT. The agent recommends and routes; it never executes an approval-gated action.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory.&lt;/strong&gt; The case is written back as a &lt;code&gt;FraudCase&lt;/code&gt; vertex on the same edge types the closed cases use, so history queries read agent-written cases with no second code path.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The handset that was not diagnostic
&lt;/h2&gt;

&lt;p&gt;The dataset ships nine closed cases labelled &lt;code&gt;undocumented&lt;/code&gt;: confirmed fraud the analysts could not match to any documented typology. They are not one pattern. They are two.&lt;/p&gt;

&lt;p&gt;Four (CC-2649, CC-2971, CC-2985, CC-3035, opened 27 Aug to 4 Sep 2016) are a device ring: a Samsung SM-G935F on Chrome for Android behind an anonymous proxy, worked across cards belonging to different customers, &lt;code&gt;connected_card_ids&lt;/code&gt; union to 24 cards, exposure $108 to $390 each.&lt;/p&gt;

&lt;p&gt;The other five (CC-3748, CC-3841, CC-3907, CC-4086, CC-4124, opened 20 to 29 Sep) are something else: four online purchases inside forty minutes, each just under $500, exposure between $1,871.13 and $1,922.65, &lt;code&gt;connected_card_ids&lt;/code&gt; empty. Nobody is sharing a device. Somebody knows where a $500 authorization ceiling sits and splits one theft into pieces that each clear alone.&lt;/p&gt;

&lt;p&gt;The obvious move is to treat the handset as the signal. It is wrong, and the graph says so. The closed cases that touch any SM-G935F profile through their transactions come to 15, across four patterns: 9 &lt;code&gt;card_not_present_new_device&lt;/code&gt;, 4 &lt;code&gt;undocumented&lt;/code&gt;, 1 &lt;code&gt;card_not_present_fraud&lt;/code&gt;, 1 cleared alert. The Galaxy S7 Edge was a popular phone, and this data holds 24 distinct SM-G935F profiles.&lt;/p&gt;

&lt;p&gt;The discriminator is the full profile plus the connection: &lt;code&gt;SM-G935F Build/NRD90M | Android 7.0 | chrome 62.0 for android | 1920x1080&lt;/code&gt;. That profile carries 114 transactions across 52 cards and 52 distinct customers between 15 Aug and 4 Dec 2016, every one of them marked &lt;code&gt;id_15 = New&lt;/code&gt; and &lt;code&gt;id_23 = IP_PROXY:ANONYMOUS&lt;/code&gt;. Exactly four closed cases sit on it, and all four are the ring.&lt;/p&gt;

&lt;p&gt;So the detector never fires on a device alone. It needs a specific handset, not a generic platform string, plus at least two other cards on it already carrying confirmed fraud. The commonest profile in this data reaches 842 unrelated customers, which is why &lt;code&gt;q_shared_device_cards&lt;/code&gt; returns &lt;code&gt;device_reach_cards&lt;/code&gt; and &lt;code&gt;device_reach_customers&lt;/code&gt; next to the hit list.&lt;/p&gt;

&lt;p&gt;The ring runs past the closed-case window into the exam. HHG-014's flagged transaction 3478561 (card C13487-K1, 2016-11-22 16:11, $74.96) sits on that exact profile with &lt;code&gt;New&lt;/code&gt; and &lt;code&gt;IP_PROXY:ANONYMOUS&lt;/code&gt;, on a card named in none of the four closed cases. The agent reaches &lt;code&gt;undocumented&lt;/code&gt; at probability 0.83 and retrieves those four at the top of its precedent list.&lt;/p&gt;

&lt;h2&gt;
  
  
  A phrase that poisoned every query
&lt;/h2&gt;

&lt;p&gt;The GraphRAG corpus is 5,613 chunks and 431,751 postings over 22,165 terms in a local BM25 index: the 5,565 closed cases, the 10 policy rules as separate chunks, and the README's typology and answer-format sections.&lt;/p&gt;

&lt;p&gt;An early retriever queried by exemplar, paraphrasing the situation and matching it against the notes. The paraphrase for a disputed charge was "purchases they did not make". That phrase appears verbatim in 4 of 5,565 analyst notes, and those four are the device ring. It was not a query, it was a fingerprint. Every disputed-charge case pulled the ring to the top of its precedent list, including HHG-003, a $49 charge the cardholder had paid before at the same amount.&lt;/p&gt;

&lt;p&gt;Two fixes. Each pattern now carries a discriminating sentence copied verbatim from the notes that actually separate it. And structured re-ranking applies a 0.65 multiplier when a candidate's pattern contradicts what the graph features imply. BM25 takes 0.55 of the final weight, structured signals the rest, and every matched component is returned in &lt;code&gt;why_matched&lt;/code&gt;. On templated analyst prose, lexical retrieval finds the template, not the meaning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calibration, and a leak that looked like skill
&lt;/h2&gt;

&lt;p&gt;The training pool is 84% fraud; the README says half the exam is legitimate. Platt scaling calibrates to the training prior, so inference applies one explicit offset in log odds, &lt;code&gt;logit_exam = logit_train + logit(0.5) - logit(p_train)&lt;/code&gt;. That moves the intercept only, never the evidence weights, and both probabilities are returned. On a time-ordered held-out block of 1,113 closed cases the fit never saw: AUC 0.9849, 10-bin ECE 0.0290 uncalibrated, 0.0242 after Platt, 0.0175 after the shift.&lt;/p&gt;

&lt;p&gt;Leakage is the more instructive half. Outcome and trigger are perfectly confounded in the history: all 4,665 confirmed-fraud cases open with a customer report, all 900 cleared cases with a model score. Fit on that alone, a model learns the trigger and reads the bank's own risk score as evidence against fraud. So no feature encodes the trigger, and the negative class was widened with controls matched to the positives' risk-score distribution.&lt;/p&gt;

&lt;p&gt;Necessary, not sufficient. The first control sampler drew only from cards with no confirmed fraud case, and only from August to October, so &lt;code&gt;prior_cases_on_card&lt;/code&gt; was near-zero on controls by construction, and since every exam case is dated 12 Nov or later, the count was partly a clock. That head gave HHG-018 a &lt;code&gt;prior_cases_on_card&lt;/code&gt; of 20, a +19.13 logit contribution and p = 1.0000 on a $39.08 charge the card had already paid at the same amount and merchant. A reliability curve would not have caught it. Reading per-feature contributions on the exam inputs did.&lt;/p&gt;

&lt;p&gt;The head now in use draws controls from all cards and all months, stratified by month and risk-score decile, log1p's the counts, splits by time (train 6,393, calibrate 2,131, test 2,131, spanning 23 Oct to 31 Dec) and fits under 50/50 class weights. On that block: AUC 0.8808, weighted ECE 0.1288 before calibration and 0.0284 after. Lower AUC than the leaky head, and real. One honest weakness stays: &lt;code&gt;undocumented&lt;/code&gt; has support 0 in every test split, since only 9 exist in 5,565, so that detection is rule-based, not learned, and the case files say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two failures that looked like success
&lt;/h2&gt;

&lt;p&gt;Eleven distinct bugs came out of the loading path. Two returned success.&lt;/p&gt;

&lt;p&gt;The REST loader rejects &lt;code&gt;eol '\r\n'&lt;/code&gt; with REST-30000, so we converted the shipped CRLF files to LF. The loading jobs still declared &lt;code&gt;EOL="\r\n"&lt;/code&gt;. A load ran in 4.9 seconds, returned no error, and loaded zero rows. The installer now measures every file's line ending at run time and prints any disagreement with the job's USING clause.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;/restpp/ddl&lt;/code&gt; ignores the job's &lt;code&gt;HEADER="true"&lt;/code&gt; and &lt;code&gt;runLoadingJobWithFile&lt;/code&gt; has no header argument, so every header row loaded as data. The graph held a Transaction with id &lt;code&gt;TransactionID&lt;/code&gt;, EmailDomain vertices named &lt;code&gt;P_emaildomain&lt;/code&gt; and &lt;code&gt;R_emaildomain&lt;/code&gt;, and every vertex type sat at exactly +1 against expected. Headers are now stripped before upload.&lt;/p&gt;

&lt;p&gt;The rest, briefly: &lt;code&gt;BOOL DEFAULT FALSE&lt;/code&gt; needs a quoted literal; &lt;code&gt;proxy&lt;/code&gt; is a reserved keyword, and the parse error pointed at the wrong token; multi-pair edges need &lt;code&gt;|&lt;/code&gt; between FROM/TO pairs, not a comma; and GSQL 4.2 rejects a function call inside a vertex-set literal, so &lt;code&gt;Seed = {to_vertex(id,"Card")}&lt;/code&gt; becomes &lt;code&gt;VERTEX v = to_vertex(id,"Card"); Seed = {v};&lt;/code&gt; in 14 places across 12 files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The graph decides, the LLM explains
&lt;/h2&gt;

&lt;p&gt;Claude Opus 5 writes case summaries, SAR narratives and action reasons. It never decides. &lt;code&gt;investigator.py&lt;/code&gt; passes the decision to the model as authoritative and asks for the model's own assessment separately, so the engine can detect disagreement rather than absorb it.&lt;/p&gt;

&lt;p&gt;That is a design choice, not a limitation. R1 says do not block on a single signal below 0.70. R2 and R7 split on whether a disputed charge matches the card's own recurring pattern. These are computable predicates over graph facts, and an LLM asked to apply them will apply them most of the time, which is not a control. &lt;code&gt;_apply_r1&lt;/code&gt; strips BLOCK_CARD and BLOCK_ALL_CARDS from any recommendation resting on one weak signal, and records that it did. &lt;code&gt;_assert_consistent&lt;/code&gt; refuses to write a file where &lt;code&gt;sar.file&lt;/code&gt; and &lt;code&gt;FILE_REPORT&lt;/code&gt; disagree, or where a legitimate verdict carries affected transactions.&lt;/p&gt;

&lt;p&gt;Laya (convaiinnovations/laya, Apache 2.0, 421M) is a second opinion by a different route: a non-autoregressive decision model that answers typed questions in one forward pass and generates no text, so there is nothing to parse and nothing to hallucinate. It does not average into the probability; disagreement raises uncertainty, pulling the decision toward VERIFY_WITH_CUSTOMER under R1 and ESCALATE_TO_ANALYST under R8. It loads in 0.6s, is gated behind &lt;code&gt;HHGOA_LAYA&lt;/code&gt;, and the shipped answer files do not use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The split that cost the most thought
&lt;/h2&gt;

&lt;p&gt;The ensemble returns two probabilities, and for a while we reported the wrong one.&lt;/p&gt;

&lt;p&gt;Laya, a 421M open decision model, runs beside the calibrated classifier as an&lt;br&gt;
independent second opinion. Where they disagree the ensemble nudges the number.&lt;br&gt;
That adjusted figure was what the answer files carried as &lt;code&gt;fraud_probability&lt;/code&gt;,&lt;br&gt;
which is the field the brief says is scored for calibration.&lt;/p&gt;

&lt;p&gt;Measured across the 20: mean absolute delta 0.028, maximum &lt;strong&gt;0.130&lt;/strong&gt;, and the&lt;br&gt;
movement was systematically &lt;em&gt;inflating&lt;/em&gt; confident-legitimate cases. Laya's own&lt;br&gt;
checkpoint warns its confidence is uncalibrated, so we were degrading a&lt;br&gt;
calibrated number with an uncalibrated one, in the field being graded on&lt;br&gt;
calibration.&lt;/p&gt;

&lt;p&gt;HHG-010 showed what that costs. The pull moved 0.24 to exactly 0.30, crossing&lt;br&gt;
the legitimate threshold, and that single crossing cascaded into a different&lt;br&gt;
verdict, a populated &lt;code&gt;affected_txn_ids&lt;/code&gt;, $1,000.03 of exposure that did not&lt;br&gt;
exist, and a pattern label. One rounding of one number, four downstream lies.&lt;/p&gt;

&lt;p&gt;The fix is two tracks rather than one. &lt;code&gt;p&lt;/code&gt;, the calibrated probability, is what&lt;br&gt;
we report and what sets the verdict. &lt;code&gt;p_dec&lt;/code&gt;, the disagreement-adjusted one,&lt;br&gt;
only gates whether the agent asks for more evidence. The assessment stays&lt;br&gt;
honest; the caution stays. Evidence requests did not change: still 13.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP, and where we deliberately did not use it
&lt;/h2&gt;

&lt;p&gt;The TigerGraph MCP server exposes 69 tools against the live workspace, and it is&lt;br&gt;
the best thing we touched all week. Asking it for device profiles shared by more&lt;br&gt;
than six customers returns, in one call, a profile carrying 3,648 transactions&lt;br&gt;
and 1,013 cards across 1,011 separate customers. Nobody wrote a screen for that&lt;br&gt;
question.&lt;/p&gt;

&lt;p&gt;The 20 graded cases do not run through it, on purpose. &lt;code&gt;tool_calls&lt;/code&gt; in an answer&lt;br&gt;
file has to be a count, not an estimate, so every call goes through one adapter&lt;br&gt;
contract that counts itself. Generic MCP tools do not map one to one onto that&lt;br&gt;
contract, and a run has to reproduce. MCP is for reaching past the contract;&lt;br&gt;
the contract is for being graded.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent produced, and what is not finished
&lt;/h2&gt;

&lt;p&gt;Across the 20 exam cases: &lt;strong&gt;10 legitimate, 6 uncertain, 4 fraud&lt;/strong&gt;. Patterns: 10&lt;br&gt;
&lt;code&gt;none&lt;/code&gt;, 5 &lt;code&gt;card_not_present_new_device&lt;/code&gt;, 3 &lt;code&gt;undocumented&lt;/code&gt;, 1&lt;br&gt;
&lt;code&gt;card_not_present_fraud&lt;/code&gt;, 1 &lt;code&gt;account_takeover&lt;/code&gt;. Two SARs, the right&lt;br&gt;
neighbourhood given that only 397 of 5,565 closed cases filed one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;978 graph and retrieval calls&lt;/strong&gt;, averaging 48.9 a case and varying with the&lt;br&gt;
case. &lt;strong&gt;254,307 model tokens.&lt;/strong&gt; 876 seconds of wall clock across the twenty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;13 of the 20 asked for evidence before deciding, and in 9 of them the&lt;br&gt;
recommendation changed once the answer came back.&lt;/strong&gt; That number is the one we&lt;br&gt;
would most like read, because it is the difference between an agent and a&lt;br&gt;
scorer, and it is visible in the interface rather than asserted: each case shows&lt;br&gt;
its initial and final action lists side by side, with additions tinted and&lt;br&gt;
dropped actions struck through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The validator runs 3,402 assertions across the 20 files and all of them&lt;br&gt;
pass.&lt;/strong&gt; Every id is checked for membership in the dataset rather than against a&lt;br&gt;
regex, exposure is recomputed from the transactions each file claims, the L1&lt;br&gt;
versus L2 routing boundary is checked at $2,500, and a SAR flag that disagrees&lt;br&gt;
with its own action list is an error. An earlier run had 20 failures, all&lt;br&gt;
&lt;code&gt;tokens is 0&lt;/code&gt;, because the API key hit its spend cap mid-run and 16 files&lt;br&gt;
shipped with deterministic summaries instead of prose. That is fixed: every file&lt;br&gt;
now carries real model output.&lt;/p&gt;

&lt;p&gt;Both undocumented typologies are found. HHG-014 is a shared-device ring across&lt;br&gt;
nineteen cards. HHG-006 is threshold structuring, four charges inside thirty&lt;br&gt;
minutes at $478.95, $456.96, $488.04 and $482.12, totalling $1,906.07, and it&lt;br&gt;
retrieves all five structuring precedents from the closed-case history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is still not finished.&lt;/strong&gt; Embeddings remain unavailable: no Anthropic&lt;br&gt;
embeddings endpoint, no Voyage key, no sentence-transformers in the venv, so&lt;br&gt;
every retrieval result is labelled &lt;code&gt;mode="bm25+structured"&lt;/code&gt; rather than passed&lt;br&gt;
off as semantic search. &lt;code&gt;undocumented&lt;/code&gt; has support 0 in every test split, since&lt;br&gt;
only 9 exist in 5,565, so that detection stays rule-based and the case files say&lt;br&gt;
so. And &lt;code&gt;written_cases_for_card()&lt;/code&gt; exists on both graph adapters with zero&lt;br&gt;
callers, so cases are written to the graph and never read back: the memory loop&lt;br&gt;
is built and not closed.&lt;/p&gt;

&lt;p&gt;The pattern across all of it: the first plausible signal was wrong every time.&lt;br&gt;
An SM-G935F looked like a ring and spanned four patterns and a cleared alert. A&lt;br&gt;
vivid phrase looked like a query and was a class label. A high AUC looked like&lt;br&gt;
skill and was a calendar. An ensemble looked like caution and was decalibration.&lt;br&gt;
Each was caught by running a count against the graph, not by reading the code&lt;br&gt;
again.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>devchallenge</category>
      <category>python</category>
      <category>rag</category>
    </item>
  </channel>
</rss>
