<?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: Mihir Rabari</title>
    <description>The latest articles on DEV Community by Mihir Rabari (@mihir-rabari).</description>
    <link>https://dev.to/mihir-rabari</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%2F4171677%2F57832f19-60cd-44b1-9993-93dfdfd0e131.jpg</url>
      <title>DEV Community: Mihir Rabari</title>
      <link>https://dev.to/mihir-rabari</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mihir-rabari"/>
    <language>en</language>
    <item>
      <title>I Gave a Fraud Agent No Vector Store and No Cypher. It Remembered Anyway.</title>
      <dc:creator>Mihir Rabari</dc:creator>
      <pubDate>Sun, 18 Oct 2026 19:35:16 +0000</pubDate>
      <link>https://dev.to/mihir-rabari/i-gave-a-fraud-agent-no-vector-store-and-no-cypher-it-remembered-anyway-3e80</link>
      <guid>https://dev.to/mihir-rabari/i-gave-a-fraud-agent-no-vector-store-and-no-cypher-it-remembered-anyway-3e80</guid>
      <description>&lt;h1&gt;
  
  
  I Gave a Fraud Agent No Vector Store and No Cypher. It Remembered Anyway.
&lt;/h1&gt;

&lt;p&gt;LedgerLens is three days old, and it already has a history. The seed data includes a closed case dated eight days back: a ring of accounts sharing device fingerprints. Today a shared-device alert lands in the queue, and before the agent touches a single transaction, it searches its own memory. It finds the seeded case, sees the overlap, and tells the investigator this isn't a new ring, it's a recurrence. It cites the case with the date it was learned and takes a different path through the investigation.&lt;/p&gt;

&lt;p&gt;The history is planted so the tests can check the agent finds exactly what I put there. What matters is how it found it: a fulltext query over &lt;code&gt;Finding&lt;/code&gt; and &lt;code&gt;Case&lt;/code&gt; nodes, in the same FalkorDB graph that holds the bank's accounts. No vector store. No sidecar database. No retrieval layer bolted onto a chatbot.&lt;/p&gt;

&lt;p&gt;This post covers the design, three rules that keep the agent honest, and the one bug that taught me that restraint also has to be remembered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The stack:&lt;/strong&gt; FalkorDB v6.0.2 (on Redis 8.10.2), FastAPI, glm-5.3 as the model. Three synthetic banks (&lt;code&gt;bank_aurora&lt;/code&gt;, &lt;code&gt;bank_nimbus&lt;/code&gt;, &lt;code&gt;bank_verdant&lt;/code&gt;), each about 192 persons, 280-286 accounts, and 1,133 transactions, with two planted fraud rings and one planted negative control per bank.&lt;/p&gt;

&lt;h2&gt;
  
  
  One graph per bank, and nothing else
&lt;/h2&gt;

&lt;p&gt;Fraud questions are rarely lookups. "Which accounts are linked to this flagged transaction?" is a walk through shared devices, addresses, owners, and money edges. Text retrieval guesses relevance from fragments. A graph follows connections that were actually recorded, and when an agent freezes an account, the path it walked is the explanation it owes compliance.&lt;/p&gt;

&lt;p&gt;So the graph is the whole context layer: data, memory, SOPs, and audit trail. One FalkorDB instance hosts one graph per bank. Isolation is the graph key, since every handle is &lt;code&gt;select_graph("bank_&amp;lt;slug&amp;gt;")&lt;/code&gt; with the slug validated against a strict pattern. A query can't cross tenants because there's no way to name another one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: the model never writes Cypher
&lt;/h2&gt;

&lt;p&gt;If the agent writes its own queries, its answers depend on code nobody reviewed. So it gets a fixed menu of 26 tools, and a human wrote the query inside each one. &lt;code&gt;CYPHER.md&lt;/code&gt; is generated from the live tool registry, so every query the product depends on is published for review.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ids are checked before anything runs.&lt;/strong&gt; &lt;code&gt;validate_entities&lt;/code&gt; verifies arguments against the graph. A wrong id comes back as &lt;code&gt;unknown_entity&lt;/code&gt; rather than a confident guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reads can't write.&lt;/strong&gt; Every read tool goes through &lt;code&gt;GRAPH.RO_QUERY&lt;/code&gt;, enforced in the runner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writes leave a trail.&lt;/strong&gt; &lt;code&gt;freeze_account&lt;/code&gt; and &lt;code&gt;escalate_case&lt;/code&gt; create an &lt;code&gt;Action&lt;/code&gt; node with actor, reason, and timestamp, linked to the case.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Compliance policy lives in the graph too, chunked and fulltext-searchable (&lt;code&gt;POL-14&lt;/code&gt;, &lt;code&gt;POL-22&lt;/code&gt;, &lt;code&gt;POL-31&lt;/code&gt;...). The agent is instructed to cite section ids in freeze reasons, fetching exact chunks like &lt;code&gt;POL-31-C01&lt;/code&gt;, so every action points at text a reviewer can open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: every answer declares its evidence
&lt;/h2&gt;

&lt;p&gt;Every session ends with a JSON verdict naming one of five answer shapes, and the tool layer rejects anything else.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;What it tells the reviewer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;with_path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The graph held the relationship; here are the hops&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;from_memory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A past session established this; here's when&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;not_in_graph&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The entities exist, but nothing connects them the way the question assumed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unknown_entity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The id isn't in the graph, so no query ran&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;action&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A deliberate, audited write&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;not_in_graph&lt;/code&gt; blocks "I couldn't find a link, so it's probably fine." In fraud work a missing relationship is itself a finding. &lt;code&gt;unknown_entity&lt;/code&gt; blocks invented ids: no id in the graph means no query executes. Shapes don't make the reasoning correct. They make every claim say what kind of evidence it stands on, so a human knows what to check first.&lt;/p&gt;

&lt;p&gt;Here's a real verdict from a fresh session asked about alert &lt;code&gt;AUR-ALR-004&lt;/code&gt;. It took three turns (&lt;code&gt;get_alert&lt;/code&gt;, &lt;code&gt;search_memory&lt;/code&gt;, answer) and found 160 fulltext matches across findings, cases, and past alerts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"shape"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"from_memory"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Investigation of AUR-ALR-004 concluded no_case (learned 2026-10-16T11:01:41+00:00): a single 12,400 transfer from Vera Petrovna (AUR-ACC-0940) to new beneficiary Zaid Reyes (AUR-ACC-0003) with no associations, no ring links, and no other suspicious flows; the alert remains open with no case attached."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Names are synthetic. This verdict exists because of the bug in the next section.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: the account it must not touch
&lt;/h2&gt;

&lt;p&gt;Every seeded bank contains one high-value, legitimate, completely isolated account, with a fresh device, a fresh address, and no ring links. It's the exact balance an over-eager agent freezes just to look productive. The investigator should examine it, find nothing, and return &lt;code&gt;no_case&lt;/code&gt;. The live-LLM tests assert it stays untouched on every run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: the agent forgot every time it was right
&lt;/h2&gt;

&lt;p&gt;While prepping the demo, I found a hole in the memory design. Every case the agent opened persisted as graph memory: findings, links, timestamps. But when the investigator correctly refused to act on the negative control (a legitimate 12,400 transfer), that decision left no trace in the graph. The graph only remembered cases we opened, never the ones we rightly refused. The next session would re-investigate the same innocent alert from scratch.&lt;/p&gt;

&lt;p&gt;Discipline wasn't memory. An agent that has to re-prove an account's innocence every time is an agent that eventually gets it wrong once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt; (commit &lt;code&gt;e93909f&lt;/code&gt;): clearances now persist as &lt;code&gt;Finding&lt;/code&gt; nodes, linked to the session that produced them and the account they're about. It was 38 lines in &lt;code&gt;agents.py&lt;/code&gt; plus a 27-line regression test. A brand-new session now recalls the refusal with the finding id and timestamp at confidence 0.9, which is the verdict above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Algorithms are just more tools
&lt;/h2&gt;

&lt;p&gt;FalkorDB's graph algorithms sit on the same menu:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;algo.WCC&lt;/code&gt; for ring components&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;algo.pageRank&lt;/code&gt; and &lt;code&gt;algo.betweenness&lt;/code&gt; for hubs and brokers inside a component&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;algo.SPpaths&lt;/code&gt; for up to three shortest money routes, with leg amounts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;algo.maxFlow&lt;/code&gt; for funnel capacity between two accounts
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;a:&lt;/span&gt;&lt;span class="n"&gt;Account&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;$from&lt;/span&gt;&lt;span class="ss"&gt;}),&lt;/span&gt; &lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;b:&lt;/span&gt;&lt;span class="n"&gt;Account&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;$to&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="n"&gt;algo.maxFlow&lt;/span&gt;&lt;span class="ss"&gt;({&lt;/span&gt;&lt;span class="py"&gt;sourceNodes:&lt;/span&gt; &lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="ss"&gt;],&lt;/span&gt; &lt;span class="py"&gt;targetNodes:&lt;/span&gt; &lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="ss"&gt;],&lt;/span&gt;
                   &lt;span class="py"&gt;relationshipTypes:&lt;/span&gt; &lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'TRANSFER'&lt;/span&gt;&lt;span class="ss"&gt;],&lt;/span&gt; &lt;span class="py"&gt;capacityProperty:&lt;/span&gt; &lt;span class="s1"&gt;'amount'&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;YIELD&lt;/span&gt; &lt;span class="n"&gt;maxFlow&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;maxFlow&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These algorithms want direct node-to-node edges, but I model every transfer as a &lt;code&gt;Transaction&lt;/code&gt; node because the audit trail matters. So the graph keeps two projections alongside the real model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;TRANSFER&lt;/code&gt; aggregates transactions per ordered account pair (amount summed, count kept). One edge per pair also satisfies &lt;code&gt;algo.maxFlow&lt;/code&gt;'s simple-adjacency requirement.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LINKED_TO&lt;/code&gt; records that two accounts share a device, an address, or an owner.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rings come from &lt;code&gt;LINKED_TO&lt;/code&gt; plus internal money density, because a household sharing a laptop is a connected component too. Size and internal flow separate a ring from a family.&lt;/p&gt;

&lt;p&gt;A v6 gotcha: the algorithm API isn't uniform. Most procedures take a config map (&lt;code&gt;algo.WCC({nodeLabels: ['Account'], relationshipTypes: ['LINKED_TO']})&lt;/code&gt;), but &lt;code&gt;algo.pageRank&lt;/code&gt; still takes positional arguments (&lt;code&gt;algo.pageRank('Account', 'TRANSFER')&lt;/code&gt;). I mapped the signatures against the running server instead of trusting the docs, and the working forms are in &lt;code&gt;CYPHER.md&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory is just more graph
&lt;/h2&gt;

&lt;p&gt;All three kinds of memory are nodes and edges.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Episodic:&lt;/strong&gt; &lt;code&gt;Session&lt;/code&gt; nodes with persisted &lt;code&gt;Message&lt;/code&gt; chains (&lt;code&gt;SAID_IN&lt;/code&gt;, &lt;code&gt;NEXT&lt;/code&gt;). Each case gets its own session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantic:&lt;/strong&gt; &lt;code&gt;Finding&lt;/code&gt; nodes with confidence and timestamps, linked to the entities they describe and fulltext-indexed next to case titles and alert summaries. &lt;code&gt;search_memory&lt;/code&gt; is how both the opening scene and the clearance recall work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Procedural:&lt;/strong&gt; &lt;code&gt;Playbook&lt;/code&gt; and &lt;code&gt;PlaybookStep&lt;/code&gt; nodes hold the SOP retrieved before acting on each alert kind. Closing a case can call &lt;code&gt;learn_lesson&lt;/code&gt;, which appends a step to the playbook, sourced to the case that taught it. Every later investigation follows it, and every change has a paper trail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Recalling a prior session is just a query. There's no second store to keep in sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handoff as an edge
&lt;/h2&gt;

&lt;p&gt;Two agents run the same loop with different privileges. The intake analyst triages, reads anything, and opens cases, but can't freeze. The case investigator can freeze, escalate, and close, but only after a handoff. &lt;code&gt;open_case&lt;/code&gt; triggers it by writing a &lt;code&gt;:HANDS_OFF&lt;/code&gt; edge from the analyst's session to a fresh investigator session. Coordination becomes something you can query, not a function call that vanishes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;I ran four investigations in parallel against the live stack:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Alert&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Tool calls&lt;/th&gt;
&lt;th&gt;Wall time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AUR-ALR-001&lt;/td&gt;
&lt;td&gt;no_case (refused)&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;120.3s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NMB-ALR-001&lt;/td&gt;
&lt;td&gt;froze_and_escalated&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;221.4s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NMB-ALR-003&lt;/td&gt;
&lt;td&gt;froze_and_escalated&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;td&gt;265.1s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AUR-ALR-003&lt;/td&gt;
&lt;td&gt;froze_and_escalated&lt;/td&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;td&gt;408.6s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Wall time includes LLM inference. So it takes about 11 tool calls to refuse and 37-62 to take down a ring.&lt;/p&gt;

&lt;p&gt;Query latency, median of 25 runs against the live FalkorDB:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;search_memory&lt;/code&gt; (fulltext)&lt;/td&gt;
&lt;td&gt;0.37 ms&lt;/td&gt;
&lt;td&gt;0.52 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3-hop money-path traversal&lt;/td&gt;
&lt;td&gt;0.53 ms&lt;/td&gt;
&lt;td&gt;0.70 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1-hop association links&lt;/td&gt;
&lt;td&gt;0.58 ms&lt;/td&gt;
&lt;td&gt;0.72 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;algo.pageRank&lt;/code&gt; over TRANSFER&lt;/td&gt;
&lt;td&gt;1.94 ms&lt;/td&gt;
&lt;td&gt;2.85 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;algo.maxFlow&lt;/code&gt; funnel capacity&lt;/td&gt;
&lt;td&gt;46.9 ms&lt;/td&gt;
&lt;td&gt;57.8 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;algo.WCC&lt;/code&gt; (whole graph)&lt;/td&gt;
&lt;td&gt;397.5 ms&lt;/td&gt;
&lt;td&gt;498.2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Memory recall is a sub-millisecond query, so it can run on every step of the loop at effectively no cost. The model is the bottleneck, not the graph. WCC is the heaviest because it labels the entire association projection, which is why it runs once per investigation and not per step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests
&lt;/h2&gt;

&lt;p&gt;44 pytest tests pass, including 3 live-LLM agent tests and 9 auth tests. Each bank has planted ground truth in &lt;code&gt;tests/fixtures/planted_&amp;lt;tenant&amp;gt;.json&lt;/code&gt;, and the tests assert the tools find exactly what was planted and nothing more. For concurrency, the suite runs 24 parallel reads plus concurrent case and finding writes alongside the 4-way parallel investigations. I saw zero cross-talk and used no application-level locks. The MCP server exposes the same tool registry over stdio, so any MCP client can drive it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Temporal edges, because recurrence needs time and not just pattern, and feeding algorithm output into alert prioritization.&lt;/p&gt;

&lt;p&gt;Code: &lt;a href="https://github.com/Mihir-Rabari/ledgerlens" rel="noopener noreferrer"&gt;github.com/Mihir-Rabari/ledgerlens&lt;/a&gt;. Clone it, start FalkorDB, run &lt;code&gt;python seed.py --wipe&lt;/code&gt;, and the console walks an alert from triage to verdict with the hops behind every conclusion on screen.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>llm</category>
      <category>rag</category>
    </item>
    <item>
      <title>How Not To Rank a Hackathon</title>
      <dc:creator>Mihir Rabari</dc:creator>
      <pubDate>Thu, 08 Oct 2026 16:11:06 +0000</pubDate>
      <link>https://dev.to/mihir-rabari/how-not-to-rank-a-hackathon-4p30</link>
      <guid>https://dev.to/mihir-rabari/how-not-to-rank-a-hackathon-4p30</guid>
      <description>&lt;p&gt;Every hackathon I've entered, somebody else decided my fate. This one made me build the judge first.&lt;/p&gt;

&lt;p&gt;DogFood Hackatho, run by Hackathon Raptors, gives every team the same 72-hour job: build a submission-and-judging platform against one published spec, one anonymized dataset, and one acceptance suite. Then the twist, straight from their brief: "the winning project is the one we run." Your code accepts your own submission, scores it, proves the score wasn't forged, and prints your certificate. The platform that judges you is the thing you shipped.&lt;/p&gt;

&lt;p&gt;We were two people: Manas on backend, data model, and the judging math. Me on frontend, voting, the embeddable widget, the threat model, and the demo video. At the deadline we had all four spec tiers plus all four bonuses, a single &lt;code&gt;docker compose up&lt;/code&gt; to a seeded portal, 548 passing tests, a 7/7 acceptance report, and a collection of scars worth more than any of it. The best ones:&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The architecture decision was deleting most of the architecture
&lt;/h2&gt;

&lt;p&gt;Day one we drew the stack that production instinct tells you to draw: seven Docker services. Postgres, Django, Next.js, nginx, then Prometheus, Alertmanager and Grafana on top. That's what a mature system looks like, right?&lt;/p&gt;

&lt;p&gt;Then we reread the acceptance checker. It's vendored byte-for-byte from the spec's appendix, we diffed it to be sure (8,855 characters, identical), and it does exactly seven HTTP checks against a portal that must be up in 30 seconds. Grafana is not involved. Nobody asks for your dashboard. No panel has ever mattered less.&lt;/p&gt;

&lt;p&gt;Mature systems have observability. Acceptance judges don't. We deleted the observability layer entirely, then went further and folded the remaining four services, Postgres, Django, Next.js and nginx, into one container with supervisord as PID 1.&lt;/p&gt;

&lt;p&gt;If you self-host this in production, split it back out. That's not what it's for. It's for the organizer who clones the repo on a laptop five minutes before judging and needs a seeded portal, not an orchestration lesson. We traded some debuggability for an install story with zero moving parts. The customer is a stranger with a timer. Design for the stranger.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The scoring algorithm we almost shipped
&lt;/h2&gt;

&lt;p&gt;The spec says judges score projects against a weighted rubric, then asks how you rank the results. The obvious implementation is the arithmetic mean. One line. We had it working.&lt;/p&gt;

&lt;p&gt;Then Manas asked the question that killed it: what happens when one judge is systematically generous and another grades like a machine? Raw averages mix project quality with judge calibration, and the lenient judge drags every project they touched up the table. "We averaged the scores" is an answer. It rewards whoever was kindest.&lt;/p&gt;

&lt;p&gt;So we fit a two-way additive model instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;y_ij = μ + b_j + q_i + ε_ij
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;b_j&lt;/code&gt; is judge j's bias, &lt;code&gt;q_i&lt;/code&gt; is project i's quality. Fitted by alternating means, Gauss-Seidel style: start with every bias at zero, compute qualities as the mean of de-biased scores, compute biases as the mean of de-qualified scores, recenter so the biases sum to zero, repeat until it settles. Every mean is taken only over cells that actually exist, because real score matrices are full of holes.&lt;/p&gt;

&lt;p&gt;The edge cases were the real work. A judge who scores everything identically carries zero information: their bias reenters as zero and we flag their leverage instead of silently dropping them. A disconnected score graph (judges A saw projects 1-3, judges B saw 4-6, no overlap) gets &lt;code&gt;is_connected: false&lt;/code&gt; and the API refuses to publish a ranking, 422, rather than invent one. Refusing felt like failure for about an hour. Then we realized you can't un-publish a winners list.&lt;/p&gt;

&lt;p&gt;And we committed the proof: &lt;code&gt;normalization-proof.txt&lt;/code&gt; in the repo, a rank-movement table on the fixture dataset showing bias shrinks and winners don't shuffle. For the pairwise bonus, a Bradley-Terry MM estimator with a phantom prior and a recovered-ranking test. Here's the industry punchline: Devfolio advertises "automatic score normalization" as a headline feature and publishes nothing about how it works. If the math decides who wins, the math is the product. Show it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Three Docker gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Postgres identifiers and the rebrand.&lt;/strong&gt; We renamed the project mid-build, and a bulk rename turned &lt;code&gt;POSTGRES_USER=dogfood&lt;/code&gt; into &lt;code&gt;POSTGRES_USER=hack-hamster&lt;/code&gt;. Postgres refuses: SQL identifiers can't have hyphens. Identifiers stayed underscored (&lt;code&gt;hack_hamster&lt;/code&gt;), the brand stayed hyphenated. SQL syntax outlives brand decisions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The nginx Host header.&lt;/strong&gt; With &lt;code&gt;proxy_pass http://django_backend&lt;/code&gt;, nginx forwards the upstream name as the Host header by default. Django's &lt;code&gt;ALLOWED_HOSTS&lt;/code&gt; check rejected &lt;code&gt;Host: django_backend&lt;/code&gt; and the entire API 400'd behind the proxy while working perfectly when hit directly. One &lt;code&gt;proxy_set_header Host $host;&lt;/code&gt; per location. Defaults are upstream-name-shaped, not request-shaped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhook delivery threads.&lt;/strong&gt; First version delivered webhooks from a hidden thread pool: a queue, two daemon workers, visible to no one. Worked great in dev. But a self-hoster inherits a worker process they didn't ask for and can't see, and "it works on my machine" is not a delivery guarantee. We replaced it with a synchronous single attempt plus a &lt;code&gt;manage.py flush_webhooks&lt;/code&gt; batch retry. Explicit beats invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The spec sentence that wasn't simple
&lt;/h2&gt;

&lt;p&gt;"CSV export at every stage." We read it as one endpoint with an &lt;code&gt;event_slug&lt;/code&gt; query parameter, built it, moved on. The spec means one CSV per stage: we ended up at six endpoints (scores, roster, assignments, submissions, audit, rankings). Annoying at the time, correct in hindsight. Six is more cacheable, more testable, and more honest about what an organizer is actually downloading.&lt;/p&gt;

&lt;p&gt;The sneakier trap was the demo config. Our first seeding flow printed random session tokens and told you to paste them into the platform config file. Every fresh Docker volume invalidated it, which means the config we shipped worked on exactly one machine on Earth: ours. The fix was deterministic demo tokens, &lt;code&gt;HMAC-SHA256(DJANGO_SECRET_KEY, "{label}:{email}")&lt;/code&gt;, derived from things that don't change across volumes. Never a database primary key; fresh volumes renumber those. New rule we live by: never bake runtime-ephemeral values into committed config, and never derive committed values from row order.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Role isolation is a backend property
&lt;/h2&gt;

&lt;p&gt;Three roles plus anonymous, and it's very easy to enforce the boundaries in the UI and feel done. We treated it as a backend property and walked the whole thing over raw HTTP: 48 cells of role-by-endpoint matrix. Organizer hitting judge endpoints: 403. A judge asking for another judge's scores: 403 (that's the graded cell in the spec, peer scores stay sealed). Participants can't touch the CSV export. Anonymous: 401 everywhere except the public gallery. Zero mismatches, committed as a generated matrix file so it can't rot. Sessions are HMAC-signed, so a tampered cookie fails verification instead of failing open. Enforcement lives in the permission layer, not in components. If it's enforced in the UI, it's a suggestion.&lt;/p&gt;

&lt;p&gt;One bug from the final hours belongs to the same family: demo account password hashes were getting clobbered by the boot-time fixture re-import, so the committed demo logins died on any fresh volume. Anything that must survive a fresh volume can't depend on insertion order.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. How the endgame actually ran
&lt;/h2&gt;

&lt;p&gt;The rules say AI tools are expected, so here's the honest version. The last night we ran about ten coding agents in parallel against the issue tracker: small, single-purpose issues, one branch each, a human merge queue into &lt;code&gt;dev&lt;/code&gt;, no agent ever merged its own PR. They were good at exactly what you'd hope (pinning tests, docs conformance, port mismatches, small endpoint bugs) and useless at spec judgment, which stayed with us.&lt;/p&gt;

&lt;p&gt;The part that made it survivable had nothing to do with AI. From hour zero we ran a conflict-zero contract: no file has two owners. I never touched &lt;code&gt;config/&lt;/code&gt;, Manas never touched &lt;code&gt;web/&lt;/code&gt;. Disjoint edits, zero merge conflicts, which means no tired person at hour 60 resolving a conflict badly and quietly breaking something that was green an hour earlier. We also froze the seam, the &lt;code&gt;.hack-hamster.toml&lt;/code&gt; file the acceptance checker reads, at hour 20. After that, neither half of the team could break the other.&lt;/p&gt;

&lt;p&gt;At hour 68 I was recording the demo video while the last fixes landed behind me. One full event lifecycle, submission to signed certificate, 2 minutes 27 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The receipts
&lt;/h2&gt;

&lt;p&gt;My favorite line in the repo is in &lt;code&gt;acceptance-report.txt&lt;/code&gt;. The spec's seven checks only machine-verify tiers 1 and 2. We built all four tiers. So the report we committed for judging reads, in full honesty: &lt;code&gt;claimed but not verified: T3 T4&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We could have written "all tiers verified." Almost nobody would have checked. The organizers' own rule 06 is "claim your tiers honestly."&lt;/p&gt;

&lt;p&gt;Here's what that line bought us: once a report admits what it can't prove, everything else in the repo reads differently. The 548 tests. The role matrix. The normalization proof. The conformance check that refuses to ship if a single route is undocumented. A repo that admits one gap is a repo you can trust on everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to check our work
&lt;/h2&gt;

&lt;p&gt;Everything is in the open repo: &lt;a href="https://github.com/choksi2212/dogfood-hackathon" rel="noopener noreferrer"&gt;choksi2212/dogfood-hackathon&lt;/a&gt;. Ten-minute reading order: &lt;code&gt;.hack-hamster.toml&lt;/code&gt; (what we claim), &lt;code&gt;acceptance-report.txt&lt;/code&gt; (what the checker verified), &lt;code&gt;ARCHITECTURE.md&lt;/code&gt; (why the shape is what it is), &lt;code&gt;JUDGING.md&lt;/code&gt; (the math, defended), &lt;code&gt;THREAT-MODEL.md&lt;/code&gt; (the attacks we assumed).&lt;/p&gt;

&lt;p&gt;The platform that judged us forced a kind of honesty a normal side project never demands: you can't bluff code that has to run against itself. If somebody forks it and runs a real event on it, that's the actual win.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built at DogFood 2026 by Hackathon Raptors. Team Hack Hamster: Manas Choksi (backend, data model, judging math) and me, Mihir Rabari (frontend, voting, widget, threat model, demo video).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>hackathon</category>
      <category>django</category>
      <category>docker</category>
      <category>nextjs</category>
    </item>
  </channel>
</rss>
