<?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: StarKnight</title>
    <description>The latest articles on DEV Community by StarKnight (@starknight).</description>
    <link>https://dev.to/starknight</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%2F908158%2Fe2dfd532-53d7-4ee5-8d00-7b53d2be96b3.jpg</url>
      <title>DEV Community: StarKnight</title>
      <link>https://dev.to/starknight</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/starknight"/>
    <language>en</language>
    <item>
      <title>A company brain that shows its work: multi-hop GraphRAG on FalkorDB, with every hop replayed in 3D</title>
      <dc:creator>StarKnight</dc:creator>
      <pubDate>Fri, 16 Oct 2026 18:26:06 +0000</pubDate>
      <link>https://dev.to/starknight/a-company-brain-that-shows-its-work-multi-hop-graphrag-on-falkordb-with-every-hop-replayed-in-3d-141f</link>
      <guid>https://dev.to/starknight/a-company-brain-that-shows-its-work-multi-hop-graphrag-on-falkordb-with-every-hop-replayed-in-3d-141f</guid>
      <description>&lt;p&gt;Some questions about a software company are not answered by any single document. "Who should review a change to the vector index code?" "Which pull request fixed this issue, and which release shipped it?" "What is affected if we change this directory?" The answer lives in how people, pull requests, files and releases connect, and text retrieval is a poor tool for following connections.&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://www.wemakedevs.org/hackathons/falkordb" rel="noopener noreferrer"&gt;WeMakeDevs Graph Hacks&lt;/a&gt; I built &lt;strong&gt;Threadline&lt;/strong&gt;, a company brain for an open-source company, with FalkorDB as its only database. An agent answers by walking the graph with typed tools, every hop is replayed in 3D with the exact Cypher it ran, and a second agent checks each citation against a real path in the graph before the answer is marked verified.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://threadline-graph.vercel.app" rel="noopener noreferrer"&gt;https://threadline-graph.vercel.app&lt;/a&gt; (six recorded runs replay instantly with no model call)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code (MIT):&lt;/strong&gt; &lt;a href="https://github.com/StarKnightt/threadline" rel="noopener noreferrer"&gt;https://github.com/StarKnightt/threadline&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demo video:&lt;/strong&gt; &lt;a href="https://vimeo.com/1236637540" rel="noopener noreferrer"&gt;https://vimeo.com/1236637540&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;iframe src="https://player.vimeo.com/video/1236637540" width="710" height="399"&gt;
&lt;/iframe&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%2Fmcrjav5jlnig3rhd7jnv.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%2Fmcrjav5jlnig3rhd7jnv.png" alt="Graph agent versus vector RAG on the same question. Left: eight retrieved chunks with no connections between them. Right: the graph path from the vector-index concept to the PRs that mention it and the people who wrote and reviewed them, with the verified path in gold." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post covers the graph model, the Cypher that does the work, how the verifier uses the graph, how I measured whether the graph helps, and what did not work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data: an open-source organisation as a company
&lt;/h2&gt;

&lt;p&gt;I used the public GitHub history of the FalkorDB organisation: the core database, the browser, QueryWeaver, GraphRAG-SDK, the TypeScript client, the MCP server and the docs. It is public, it has real organisational structure (reviews, closing references, releases, cross-repository mentions), and anyone can check the answers against GitHub.&lt;/p&gt;

&lt;p&gt;Two constraints shaped the scope:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy.&lt;/strong&gt; People are stored by GitHub login and avatar URL only. No names, emails or locations. Bots are flagged and kept out of expertise rankings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The free tier's 100 MB.&lt;/strong&gt; FalkorDB Cloud's free instance holds about 100 MB in memory. I kept items updated since January 2023 and used 256-dimension embeddings. The result is about 27,000 nodes and 136,000 relationships, and &lt;code&gt;GRAPH.MEMORY USAGE&lt;/code&gt; reports 41 MB, of which text chunks with their vectors take 19 MB.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The graph model
&lt;/h2&gt;

&lt;p&gt;Every brain node carries an &lt;code&gt;:Entity&lt;/code&gt; label and a unique &lt;code&gt;key&lt;/code&gt; such as &lt;code&gt;pr:FalkorDB/FalkorDB#1461&lt;/code&gt; or &lt;code&gt;person:swilly22&lt;/code&gt;. One index on &lt;code&gt;Entity.key&lt;/code&gt; lets any tool address any node, and the same keys double as citations in answers, which is what makes them checkable later.&lt;/p&gt;

&lt;p&gt;The core relationships come straight from GitHub: &lt;code&gt;AUTHORED&lt;/code&gt;, &lt;code&gt;REVIEWED&lt;/code&gt;, &lt;code&gt;MERGED&lt;/code&gt;, &lt;code&gt;COMMENTED_ON&lt;/code&gt;, &lt;code&gt;TOUCHED&lt;/code&gt; (PR to file), &lt;code&gt;IN_DIR&lt;/code&gt; (file to directory to parent directory), &lt;code&gt;CLOSES&lt;/code&gt;, &lt;code&gt;REFERENCES&lt;/code&gt; (with a &lt;code&gt;crossRepo&lt;/code&gt; flag), &lt;code&gt;SHIPPED_IN&lt;/code&gt; and &lt;code&gt;MENTIONS&lt;/code&gt; (to a fixed dictionary of about 50 concepts such as &lt;code&gt;vector-index&lt;/code&gt; or &lt;code&gt;pagerank&lt;/code&gt;, matched without an LLM).&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%2Fii34gdmr3nguosnrryah.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%2Fii34gdmr3nguosnrryah.png" alt="The data model as rendered on the app's How it works page, generated from the same constants the server runs." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The most useful part turned out to be the &lt;strong&gt;derived edges&lt;/strong&gt;, computed in Cypher after each load and again by a daily sync:&lt;br&gt;
&lt;/p&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;p:&lt;/span&gt;&lt;span class="n"&gt;Person&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:AUTHORED&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;pr:&lt;/span&gt;&lt;span class="n"&gt;PullRequest&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:TOUCHED&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:File&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:IN_DIR&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="m"&gt;6&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;d:&lt;/span&gt;&lt;span class="n"&gt;Directory&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p.isBot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt; &lt;span class="ow"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;pr.mergedAt&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="ow"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;authored&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pr.mergedAt&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;
&lt;span class="k"&gt;MERGE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="py"&gt;k:&lt;/span&gt;&lt;span class="n"&gt;KNOWS&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;k.authored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;authored&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k.authoredLastAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;KNOWS&lt;/code&gt; turns thousands of file touches into one weighted edge per person and directory, so "who knows this area" becomes a one-hop question at query time. &lt;code&gt;REVIEWS_FOR&lt;/code&gt; (reviewer to author) feeds PageRank, &lt;code&gt;CO_CHANGES&lt;/code&gt; (directories changed by the same PRs) feeds label propagation, and &lt;code&gt;CONTRIBUTES_TO&lt;/code&gt; feeds betweenness. Precomputing these is the graph equivalent of an index: it moves the expensive walk to load time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cypher that does the work
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Who knows an area, now.&lt;/strong&gt; Expertise decays: someone who touched the index code three years ago is a weaker answer than someone who reviewed it last week.&lt;br&gt;
&lt;/p&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;p:&lt;/span&gt;&lt;span class="n"&gt;Person&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="py"&gt;k:&lt;/span&gt;&lt;span class="n"&gt;KNOWS&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;d:&lt;/span&gt;&lt;span class="n"&gt;Directory&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;k.authored&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;k.reviewed&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;$now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;k.lastAt&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="err"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;86400.0&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;ageDays&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;p.login&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k.authored&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k.reviewed&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;ageDays&lt;/span&gt; &lt;span class="err"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;180.0&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Decision trace.&lt;/strong&gt; From an issue to the PR that closed it, to the release that shipped it, with the source of each link recorded on the edge:&lt;br&gt;
&lt;/p&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;i:&lt;/span&gt;&lt;span class="n"&gt;Issue&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;key:&lt;/span&gt; &lt;span class="n"&gt;$key&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;OPTIONAL&lt;/span&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;fix:&lt;/span&gt;&lt;span class="n"&gt;PullRequest&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="py"&gt;c:&lt;/span&gt;&lt;span class="n"&gt;CLOSES&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;OPTIONAL&lt;/span&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="n"&gt;fix&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:SHIPPED_IN&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;rel:&lt;/span&gt;&lt;span class="n"&gt;Release&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;OPTIONAL&lt;/span&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;author:&lt;/span&gt;&lt;span class="n"&gt;Person&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:AUTHORED&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;i.title&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fix.key&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;author.login&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rel.tag&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c.source&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2F31reerkc1khsky1whj89.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%2F31reerkc1khsky1whj89.png" alt="A decision trace replayed: issue to closing PR to release, with the step cards and their Cypher on the left." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Impact.&lt;/strong&gt; A variable-length walk down the directory tree to open pull requests:&lt;br&gt;
&lt;/p&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="n"&gt;d&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:IN_DIR&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="m"&gt;6&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;f:&lt;/span&gt;&lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:TOUCHED&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;pr:&lt;/span&gt;&lt;span class="n"&gt;PullRequest&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;pr.state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'OPEN'&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;pr.key&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pr.title&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Graph algorithms as tools.&lt;/strong&gt; &lt;code&gt;algo.pageRank&lt;/code&gt; over &lt;code&gt;REVIEWS_FOR&lt;/code&gt; finds key reviewers; &lt;code&gt;algo.betweenness&lt;/code&gt; over people and repositories finds who bridges teams; &lt;code&gt;algo.labelPropagation&lt;/code&gt; over &lt;code&gt;CO_CHANGES&lt;/code&gt; finds the subsystems that actually change together, which do not always match the directory layout.&lt;/p&gt;

&lt;p&gt;The full catalogue, 27 queries with parameters and measured timings, is generated into &lt;a href="https://github.com/StarKnightt/threadline/blob/main/docs/CYPHER.md" rel="noopener noreferrer"&gt;docs/CYPHER.md&lt;/a&gt; from the same constants the server runs. &lt;code&gt;pnpm verify:cypher&lt;/code&gt; runs every one against the live instance; most return in 65 to 160 ms including the round trip from India to the Mumbai region.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three FalkorDB details I learned the hard way
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;shortestPath()&lt;/code&gt; lives in &lt;code&gt;WITH&lt;/code&gt; or &lt;code&gt;RETURN&lt;/code&gt;, and it is directed only.&lt;/strong&gt; My first verifier query put it in &lt;code&gt;MATCH&lt;/code&gt; and failed; moving it to &lt;code&gt;WITH&lt;/code&gt; then failed on the undirected pattern. &lt;code&gt;allShortestPaths()&lt;/code&gt; in &lt;code&gt;MATCH&lt;/code&gt; accepts undirected patterns, so the verifier uses that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Betweenness follows edge direction.&lt;/strong&gt; People only pointed at repositories, so no shortest path ran through a person and every score was zero. A derived reverse edge (&lt;code&gt;HAS_CONTRIBUTOR&lt;/code&gt;) fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vector &lt;code&gt;score&lt;/code&gt; is a distance.&lt;/strong&gt; &lt;code&gt;db.idx.vector.queryNodes&lt;/code&gt; yields cosine distance, where lower is closer. I had sorted it descending, so entity resolution quietly picked the farthest of the nearest neighbours. A title-only query exposed it: the right chunk came back with a "score" of 0.18 while worse matches scored 0.36. Every vector query now reports &lt;code&gt;1 - score&lt;/code&gt; as similarity.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Typed tools first, free-form Cypher last
&lt;/h2&gt;

&lt;p&gt;The Investigator agent (Vercel AI SDK, temperature 0, at most six steps) gets typed tools: &lt;code&gt;find_entities&lt;/code&gt;, &lt;code&gt;who_knows&lt;/code&gt;, &lt;code&gt;trace&lt;/code&gt;, &lt;code&gt;connect&lt;/code&gt;, &lt;code&gt;impact&lt;/code&gt;, &lt;code&gt;activity&lt;/code&gt;, &lt;code&gt;rank&lt;/code&gt;, &lt;code&gt;expand&lt;/code&gt;, &lt;code&gt;details&lt;/code&gt;, &lt;code&gt;draft_triage&lt;/code&gt;, and finally &lt;code&gt;cypher_readonly&lt;/code&gt; for questions nothing else fits. Typed tools are easier for a model to call correctly than raw Cypher, and each one is a query I wrote and tested by hand.&lt;/p&gt;

&lt;p&gt;The free-form tool runs through &lt;code&gt;GRAPH.RO_QUERY&lt;/code&gt; as a separate read-only database user (FalkorDB Cloud's Read role), rejects write clauses and enforces a &lt;code&gt;LIMIT&lt;/code&gt; and a 5 second timeout. I checked both guards directly: &lt;code&gt;GRAPH.RO_QUERY&lt;/code&gt; refuses writes, and the read-only user cannot call &lt;code&gt;GRAPH.QUERY&lt;/code&gt; at all.&lt;/p&gt;

&lt;p&gt;Each tool returns rows plus the node keys it touched, the edges it traversed and its Cypher. Those stream to the browser over server-sent events and drive the 3D view, so the visualisation is a direct rendering of what the agent actually did rather than an illustration of it.&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%2F8lznw10qex3rumwpk6k1.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%2F8lznw10qex3rumwpk6k1.png" alt="A step card with its Cypher expanded, next to the traversal it produced." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A verifier that uses the graph
&lt;/h2&gt;

&lt;p&gt;Language models cite things they never saw. So a Verifier agent, deterministic with no LLM in its check, inspects every citation in an answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the cited key must appear in some tool result, and&lt;/li&gt;
&lt;li&gt;it must be reachable from the question's entry nodes within four hops over work relationships. Hub edges such as &lt;code&gt;IN_REPO&lt;/code&gt; are excluded; otherwise everything is two hops from everything and the check proves nothing.
&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;Entity&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;key:&lt;/span&gt; &lt;span class="n"&gt;$entry&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;Entity&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;key:&lt;/span&gt; &lt;span class="n"&gt;$cited&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;
&lt;span class="k"&gt;MATCH&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;allShortestPaths&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="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:AUTHORED&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;REVIEWED&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;MERGED&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;TOUCHED&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;IN_DIR&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;CLOSES&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;REFERENCES&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;SHIPPED_IN&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;MENTIONS&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;KNOWS&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;..4&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&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="k"&gt;RETURN&lt;/span&gt; &lt;span class="nf"&gt;length&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;hops&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;IN&lt;/span&gt; &lt;span class="nf"&gt;nodes&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;n.key&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a citation fails, the Verifier hands the claim back to the Investigator once, with the reason. Claims, verdicts (&lt;code&gt;CHECKED_BY {verdict, pathLength}&lt;/code&gt;) and handoffs (&lt;code&gt;HANDED_OFF&lt;/code&gt;) are written to a shared FalkorDB graph, &lt;code&gt;tl_shared&lt;/code&gt;, which is the coordination layer between the two agents.&lt;/p&gt;

&lt;p&gt;Here is a real example from a production run during final testing: "Which pull request fixed issue #1003 in the falkordb-browser repository, and in which release did the fix ship?" The first answer cited &lt;code&gt;release:v1.4.0&lt;/code&gt;, an abbreviated key that matches no node. The Verifier rejected it, the Investigator rewrote the citation as &lt;code&gt;release:FalkorDB/falkordb-browser@v1.4.0&lt;/code&gt;, and the second check verified 2 of 2. The whole exchange took 2.9 seconds and is &lt;a href="https://threadline-graph.vercel.app/t/c35f0b88-340" rel="noopener noreferrer"&gt;replayable here&lt;/a&gt;. A direct Cypher query agrees: PR #1007 closes the issue through a closing reference and shipped in v1.4.0 according to the release notes.&lt;/p&gt;

&lt;p&gt;Building the verifier changed the rest of the system. It caught abbreviated keys like &lt;code&gt;pr:#2527&lt;/code&gt;, keys glued to full-width brackets, and answers about org-wide rankings that had no entry node at all. Each case changed how citations are resolved and anchored.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory as graphs, one per visitor
&lt;/h2&gt;

&lt;p&gt;FalkorDB holds many isolated graphs on one instance, so each visitor gets their own: &lt;code&gt;tl_mem_&amp;lt;id&amp;gt;&lt;/code&gt;. A turn is a node chained to the previous one with &lt;code&gt;NEXT&lt;/code&gt;; its tool calls hang off it with edges to the brain nodes they touched, so any past answer can be replayed. Facts come only from explicit statements ("I work on the TypeScript client") and keep a &lt;code&gt;LEARNED_FROM&lt;/code&gt; edge to the turn they came from, so the app can show why it believes something. Follow-ups such as "same question, but for QueryWeaver" resolve against the previous turn. Verified tool sequences become shared procedural playbooks with their own vector index, and the next similar question starts from that plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drawing the agent's reasoning
&lt;/h2&gt;

&lt;p&gt;The 3D view is React Three Fiber. The background is a constellation of repositories, active directories, people and concepts, laid out once with &lt;code&gt;d3-force-3d&lt;/code&gt; and stored as &lt;code&gt;x, y, z&lt;/code&gt; properties on the nodes, so even the layout comes from FalkorDB. When a step arrives, its nodes ignite in their label colour, traversed edges carry a light pulse, the camera eases to the new frontier, and the verifier's evidence path turns gold. Labels are one DOM layer projected every frame with simple collision avoidance, which avoided creating a React root per label.&lt;/p&gt;

&lt;p&gt;There is also a light theme. Bloom only works when colours exceed 1.0 against a dark background, so the light stage drops it: nodes switch to darker, saturated variants, faded context fades toward the pale background instead of toward black, and the verified path becomes a deeper gold drawn thicker. An axe-core check passes WCAG AA contrast on every page in both themes.&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%2Fh9clwjmvtj1fu7n4zyag.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%2Fh9clwjmvtj1fu7n4zyag.png" alt="The same comparison in the light theme." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Acting, not only answering
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;draft_triage&lt;/code&gt; turns an open issue into a ready-to-paste triage comment: likely duplicates by vector similarity, suggested reviewers from the people who changed the same code when similar issues were fixed, and suggested labels. It never posts anything. The same typed tools are served over MCP at &lt;code&gt;/api/mcp&lt;/code&gt;, so any MCP client can use the graph directly.&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%2Fuhfi77tf03bx4v1l52w9.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%2Fuhfi77tf03bx4v1l52w9.png" alt="A drafted triage comment for falkordb-browser issue #1802, with the evidence path behind each suggestion." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Did the graph actually help?
&lt;/h2&gt;

&lt;p&gt;I wanted a number I could defend, so the evaluation was designed so the graph cannot grade itself. Sixty questions in six categories are generated from templates over the raw GitHub JSON, and their gold answers are computed by a separate script from that JSON, without FalkorDB. Both systems use the same model on a given question, the same 256-dimension embeddings and the same FalkorDB vector index; the vector baseline takes the eight nearest chunks with their document metadata. Scores are set-based F1 over each answer's final &lt;code&gt;ANSWER:&lt;/code&gt; line.&lt;/p&gt;

&lt;p&gt;Groq's free tier caps tokens per model per day, so questions rotated across three Groq models, and the 29 questions those caps left unfinished were completed on OpenAI gpt-5.6-luna with reasoning off. Every comparison is still paired within a question.&lt;/p&gt;

&lt;p&gt;All 60 questions were answered by both systems. Models: qwen/qwen3.8-27b (Groq) on 7 questions, openai/gpt-oss-120b (Groq) on 24 questions, gpt-5.6-luna (OpenAI, reasoning off) on 29 questions.&lt;/p&gt;

&lt;p&gt;The questions were split across models: Groq's free daily token caps stopped the rotation part way, so the questions without a finished pair were completed with OpenAI gpt-5.6-luna. Each question was answered by one model, and its graph and vector runs always used that same model, so every comparison is paired within a question. The models received different mixes of categories, so the per-model rows are not a ranking of the models. Which model answered each question is listed in &lt;a href="https://github.com/StarKnightt/threadline/blob/main/eval/summary.json" rel="noopener noreferrer"&gt;&lt;code&gt;eval/summary.json&lt;/code&gt;&lt;/a&gt; and on every question at &lt;a href="https://threadline-graph.vercel.app/eval" rel="noopener noreferrer"&gt;/eval&lt;/a&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Pairs&lt;/th&gt;
&lt;th&gt;Graph agent F1&lt;/th&gt;
&lt;th&gt;Vector RAG F1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single-hop lookup (control)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expertise (PR to file to directory)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0.83&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decision trace (issue to PR to release)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0.85&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-repo connection&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0.88&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Impact (open PRs under a directory)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0.30&lt;/td&gt;
&lt;td&gt;0.10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aggregation (top reviewers or openers)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;All&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;60&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.81&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.12&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Questions&lt;/th&gt;
&lt;th&gt;Graph agent F1&lt;/th&gt;
&lt;th&gt;Vector RAG F1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;qwen/qwen3.8-27b (Groq)&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;openai/gpt-oss-120b (Groq)&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;0.81&lt;/td&gt;
&lt;td&gt;0.04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.6-luna (OpenAI, reasoning off)&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;0.77&lt;/td&gt;
&lt;td&gt;0.14&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The graph agent used about 6,190 tokens per answer against 1,479 for the baseline. 95 percent of the graph agent's citations were verified by a graph path. Median latency in the eval: 54.4 s graph and 9.4 s vector on qwen/qwen3.8-27b, 19.5 s graph and 1.5 s vector on openai/gpt-oss-120b, 7.2 s graph and 2.4 s vector on gpt-5.6-luna. The Groq runs went back to back under an 8,000 tokens per minute limit, so their graph latencies include waiting for that limit; single live questions took 1.6 to 15 s in testing. The vector baseline does best on single-hop lookup questions (F1 0.60), the control category where the answer sits in one document. Widest gap: aggregation. Every question, gold answer, raw answer and score is in &lt;a href="https://github.com/StarKnightt/threadline/tree/main/eval" rel="noopener noreferrer"&gt;&lt;code&gt;eval/&lt;/code&gt;&lt;/a&gt; and on &lt;a href="https://threadline-graph.vercel.app/eval" rel="noopener noreferrer"&gt;/eval&lt;/a&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%2F2nw8952qh6tx8unby902.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%2F2nw8952qh6tx8unby902.png" alt="The /eval page: paired results per category and per model, with every question, gold answer and raw answer." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;How I read these numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The control category behaves as it should.&lt;/strong&gt; When the answer sits in one document, vector retrieval does reasonably well (0.60). That is the point of a control: the gap on the other categories is about structure, not a crippled baseline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I gave the baseline a fair chance, and it mattered.&lt;/strong&gt; For the lookup questions, the right chunk was in the top eight for 1 of 10 questions with a question-first phrasing, 6 of 10 with the title first, and 8 of 10 with the bare title. I used the title-first phrasing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aggregation is where retrieval cannot win.&lt;/strong&gt; "Who reviewed the most PRs in 2024?" is a count over hundreds of edges. No chunk contains it, so the baseline either refuses or guesses; the graph answers with one &lt;code&gt;count()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The graph costs more tokens.&lt;/strong&gt; About 4 times the baseline per answer, because tool results carry rows and keys. For these questions that trade is worth it, but it is a real cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The impact category, and a bug the eval found
&lt;/h3&gt;

&lt;p&gt;Impact scored only 0.30, and the reason is instructive. The graph agent missed seven of its ten questions, and in six of those it answered "none". Checking the misses with Cypher showed the data was there: PR #272 is open and touches files under &lt;code&gt;clients/python/queryweaver_client&lt;/code&gt;, exactly as the gold answer says. The problem was the tool. The questions write paths with a trailing slash (&lt;code&gt;clients/python/queryweaver_client/&lt;/code&gt;), the &lt;code&gt;impact&lt;/code&gt; tool matched them with &lt;code&gt;CONTAINS&lt;/code&gt; against directory paths stored without one, and so the query returned nothing. The agent then reported, truthfully, that its tool found no open PRs. Another tool in the same file already stripped slashes; &lt;code&gt;impact&lt;/code&gt; did not.&lt;/p&gt;

&lt;p&gt;The fix is one line. After it, the impact query itself returns the gold PRs for 9 of the 10 impact questions; the tenth sits under &lt;code&gt;.claude/&lt;/code&gt;, which the tool deliberately skips. On production, the exact question the agent missed first now comes back as PR #272 with 1 of 1 citations verified (&lt;a href="https://threadline-graph.vercel.app/t/4893d6b2-b5e" rel="noopener noreferrer"&gt;trace&lt;/a&gt;). That is one spot check, not an evaluation: I have &lt;strong&gt;not&lt;/strong&gt; rerun the agent evaluation, so the published numbers above are the measured, pre-fix ones. The lesson for me is that an honest eval is mostly useful for what it tells you about your own tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running on free tiers, and what it costs
&lt;/h2&gt;

&lt;p&gt;The whole project runs on free tiers plus a few cents of OpenAI usage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FalkorDB Cloud free tier&lt;/strong&gt; (AWS ap-south-1). It has no persistence and stops after a day without use, so a daily Vercel Cron sync keeps it in use, and &lt;code&gt;pnpm seed&lt;/code&gt; rebuilds the graph from a published snapshot in about two minutes. The free tier also has no TLS, so only public GitHub data and least-privilege credentials travel over that connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Groq's free tier&lt;/strong&gt; for the agent (gpt-oss-120b first, then gpt-oss-20b and qwen3.8-27b when rate limited). The caps are per model per day and shared with the evaluation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A capped OpenAI fallback for live questions only.&lt;/strong&gt; When every Groq model is capped, a live question continues on OpenAI gpt-5.4-mini, with at most 4 calls of up to 2,000 output tokens per run, 10 runs a day and 25 runs a month across all visitors. At Standard prices ($0.75 input, $0.075 cached input, $4.50 output per million tokens), the token counts of real fallback runs cost about $0.001 to $0.0035 each, a worst-case run is about $0.07, and the monthly cap bounds the fallback at under $2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spend so far:&lt;/strong&gt; embedding the corpus cost about four US cents, and the 29 OpenAI evaluation questions cost about $0.024.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Recorded showcase runs replay from the stored traces in FalkorDB with no model call at all, which is also what makes the demo reliable during judging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The eval pools models.&lt;/strong&gt; Questions were split across three models because of free-tier caps. Comparisons are paired within each question, but the per-model rows are not a ranking, and a single-model run would be cleaner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The impact numbers are pre-fix&lt;/strong&gt;, as described above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expertise is inferred from authored and reviewed PRs.&lt;/strong&gt; Design discussions in chat, calls or other channels are invisible to the graph.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Release attribution falls back to "first release after the merge"&lt;/strong&gt; when release notes do not list a PR. The source is recorded on each &lt;code&gt;SHIPPED_IN&lt;/code&gt; edge, but the fallback can be wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concept matching uses a fixed dictionary&lt;/strong&gt; of about 50 FalkorDB terms. It is precise but does not generalise to another organisation without editing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrent live questions slow down&lt;/strong&gt; under Groq's 8,000 tokens per minute limit, and past the daily caps the app falls back, then stops and points to the recorded runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decision nodes extracted by an LLM&lt;/strong&gt;, linked to the comments that justify them, were planned and not built.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would do next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Rerun the full evaluation on one model, after the impact fix.&lt;/li&gt;
&lt;li&gt;LLM-extracted &lt;code&gt;Decision&lt;/code&gt; nodes with &lt;code&gt;JUSTIFIED_BY&lt;/code&gt; edges to the comments behind them.&lt;/li&gt;
&lt;li&gt;Point it at a second organisation; ingestion only needs an org name, but the concept dictionary needs work.&lt;/li&gt;
&lt;li&gt;Persistence beyond the free tier, and a fine-grained token for the daily sync.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI disclosure
&lt;/h2&gt;

&lt;p&gt;I planned the concept, graph model, query catalogue and evaluation method before the event as notes, which the rules allow. During the event, most of the implementation code was written by an AI coding agent in Cursor (Anthropic Claude) working from that plan under my direction, and this post was drafted with its help and edited by me. Every Cypher query was run and checked against the live FalkorDB instance before it went into the catalogue, and the evaluation's gold answers are computed independently of FalkorDB. At runtime the app uses Groq-hosted open-weight models with a capped OpenAI fallback, plus OpenAI embeddings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Live app: &lt;a href="https://threadline-graph.vercel.app" rel="noopener noreferrer"&gt;https://threadline-graph.vercel.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source code and setup: &lt;a href="https://github.com/StarKnightt/threadline" rel="noopener noreferrer"&gt;https://github.com/StarKnightt/threadline&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cypher catalogue: &lt;a href="https://github.com/StarKnightt/threadline/blob/main/docs/CYPHER.md" rel="noopener noreferrer"&gt;https://github.com/StarKnightt/threadline/blob/main/docs/CYPHER.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Evaluation data: &lt;a href="https://github.com/StarKnightt/threadline/tree/main/eval" rel="noopener noreferrer"&gt;https://github.com/StarKnightt/threadline/tree/main/eval&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Demo video: &lt;a href="https://vimeo.com/1236637540" rel="noopener noreferrer"&gt;https://vimeo.com/1236637540&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>falkordb</category>
      <category>graphrag</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Cashcast: which invoices will be paid late? TabPFN on a laptop, checked month by month</title>
      <dc:creator>StarKnight</dc:creator>
      <pubDate>Mon, 12 Oct 2026 19:37:26 +0000</pubDate>
      <link>https://dev.to/starknight/cashcast-which-invoices-will-be-paid-late-tabpfn-on-a-laptop-checked-month-by-month-2d21</link>
      <guid>https://dev.to/starknight/cashcast-which-invoices-will-be-paid-late-tabpfn-on-a-laptop-checked-month-by-month-2d21</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Cashcast&lt;/strong&gt; tells a small business which unpaid invoices are likely to be paid late, when each payment is likely to arrive, and how much cash to expect over the next eight weeks. It runs on your own laptop, so your client list and amounts stay with you.&lt;/p&gt;

&lt;p&gt;It is built for people who send invoices and then wait: freelancers, small agencies, and the bookkeepers who manage receivables for several small clients. For them, late payment is not an abstract metric. It decides whether rent goes out on time and how much of the week goes into writing reminder emails.&lt;/p&gt;

&lt;p&gt;Cashcast reads an invoice history (one CSV row per invoice: client, issue date, due date, amount, paid date) and, for every invoice still open, shows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the chance it will be paid late,&lt;/li&gt;
&lt;li&gt;a likely payment date with an 80% range, updated for how long the invoice has already been open,&lt;/li&gt;
&lt;li&gt;a suggested next step: contact today, send a reminder now, remind on the due date, or do nothing,&lt;/li&gt;
&lt;li&gt;the weekly cash you can expect from invoices already sent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Who would pay for it, and why.&lt;/strong&gt; Cloud accounting suites offer payment reminders and cash-flow views, but they expect the whole ledger in their cloud and a monthly subscription. Cashcast is a single-purpose tool that works on the CSV export people already have. A one-time license for freelancers, or a per-client fee for bookkeepers, is a plausible model, because the value is easy to state: fewer surprises in cash flow and less time spent chasing the clients who were going to pay anyway. Running the model locally is what makes a small one-time price workable, since there is no per-prediction API bill behind it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj45qvd2d843iwvlsupic.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj45qvd2d843iwvlsupic.gif" alt="Cashcast demo: pick a date, see the riskiest invoices, reveal what happened, then the backtest" width="600" height="375"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live demo (no install):&lt;/strong&gt; &lt;a href="https://starknightt.github.io/cashcast/" rel="noopener noreferrer"&gt;https://starknightt.github.io/cashcast/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The live page is a &lt;strong&gt;replay&lt;/strong&gt;. It uses IBM's public accounts receivable sample: 2,466 invoices from 100 business clients, 2012 to 2013, 36% of them paid late. Pick a date and you see what Cashcast would have told you that morning, using only invoices that had been paid before that date. Then tick &lt;strong&gt;Reveal what happened&lt;/strong&gt; to put the real payment dates next to the predictions.&lt;/p&gt;

&lt;p&gt;Things to try:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Sep 1, 2013&lt;/strong&gt;. 78 invoices are unpaid. Cashcast expects 4,327 to come in over the next four weeks (the sample has no currency). Tick &lt;strong&gt;Reveal what happened&lt;/strong&gt;: 4,241 actually came in.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Needs action&lt;/strong&gt; to see only the invoices it suggests chasing, each with a one-line history computed from the data, for example "Paid 13 of 15 past invoices late, 36 days to pay on average."&lt;/li&gt;
&lt;li&gt;Scroll to &lt;strong&gt;Does it work?&lt;/strong&gt; for the month-by-month backtest, including the result where TabPFN did not win.&lt;/li&gt;
&lt;/ol&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%2Fi50xocqotjukknmn8i5b.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%2Fi50xocqotjukknmn8i5b.png" alt="Replay of Sep 1, 2013 with outcomes revealed" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Using it on your own invoices&lt;/strong&gt; needs Python 3.10 or newer and works on a CPU. &lt;code&gt;python -m cashcast.serve&lt;/code&gt; opens the same page in local mode with a file picker. The file goes from the browser to the Python process on &lt;code&gt;127.0.0.1&lt;/code&gt; and nowhere else. On the bundled 404-row sample ledger, scoring takes about 7 to 9 seconds on my laptop CPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/StarKnightt" rel="noopener noreferrer"&gt;
        StarKnightt
      &lt;/a&gt; / &lt;a href="https://github.com/StarKnightt/cashcast" rel="noopener noreferrer"&gt;
        cashcast
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Know which invoices will be paid late, and when the money will arrive. TabPFN on your laptop, client data stays local.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Cashcast&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Know which invoices will be paid late, and when the money will arrive, before you chase anyone.&lt;/p&gt;
&lt;p&gt;Cashcast is a small receivables tool for freelancers, small agencies and the bookkeepers who manage invoices for them. You give it your invoice history as a CSV. &lt;a href="https://github.com/PriorLabs/TabPFN" rel="noopener noreferrer"&gt;TabPFN&lt;/a&gt;, an open tabular foundation model, runs on your own laptop and tells you, for every unpaid invoice:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the chance it will be paid late,&lt;/li&gt;
&lt;li&gt;a likely payment date with an 80% range, updated for how long the invoice has already been open,&lt;/li&gt;
&lt;li&gt;a suggested next step (contact today, send a reminder now, remind on the due date, or nothing),&lt;/li&gt;
&lt;li&gt;how much cash to expect in each of the next eight weeks.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Your client list and amounts never leave your computer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://starknightt.github.io/cashcast/" rel="nofollow noopener noreferrer"&gt;https://starknightt.github.io/cashcast/&lt;/a&gt; (demo mode, no install)&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/StarKnightt/cashcast/docs/demo.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2FStarKnightt%2Fcashcast%2FHEAD%2Fdocs%2Fdemo.gif" alt="Cashcast demo: pick a date, see the riskiest invoices, reveal what happened, then the backtest"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Demo mode&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;The live page replays IBM's public accounts receivable sample (2,466 invoices from 100 business…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/StarKnightt/cashcast" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;MIT licensed. The pipeline is about 660 lines of Python plus a static page with no build step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cashcast/data.py       load the IBM sample or any CSV into one schema
cashcast/features.py   what was known on the day each invoice was sent
cashcast/model.py      TabPFN v2 classifier and regressor
cashcast/forecast.py   late chance, payment range, 8-week cash simulation
cashcast/backtest.py   month-by-month test against four baselines
cashcast/demo.py       replay data for the live page, scored against reality
cashcast/serve.py      local mode (standard library HTTP server)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  TabPFN does the predicting
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/PriorLabs/TabPFN" rel="noopener noreferrer"&gt;TabPFN&lt;/a&gt; is a tabular foundation model from Prior Labs. It was pre-trained on millions of synthetic tables, so instead of training a model on your data, you hand it your table and it predicts in a single forward pass. For a small business with a few hundred or a few thousand invoices, that matters: there is no training loop to tune and nothing to babysit.&lt;/p&gt;

&lt;p&gt;Cashcast uses two TabPFN v2 models on the same features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;classifier&lt;/strong&gt; for "will this invoice be paid after its due date?",&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;regressor&lt;/strong&gt; for "how many days until it is paid?", asked for 49 quantiles so it returns a full distribution per invoice rather than one number.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Features from the day the invoice was sent
&lt;/h3&gt;

&lt;p&gt;The easiest way to fool yourself with this kind of data is leakage. The IBM sample includes columns such as &lt;code&gt;DaysToSettle&lt;/code&gt; and &lt;code&gt;Disputed&lt;/code&gt; that are only known after an invoice is settled, and using them makes the task look far easier than it is.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;features.py&lt;/code&gt; builds every row from what you knew on the day the invoice went out: how many of that client's invoices had already been paid, how often they were late, their average and most recent days to pay, how many of their invoices were still open, and how this amount compares with their usual. Invoices paid after the issue date are invisible to that row.&lt;/p&gt;

&lt;h3&gt;
  
  
  From a distribution to a decision
&lt;/h3&gt;

&lt;p&gt;The regressor's quantiles are the useful part. An invoice that is still unpaid after 25 days is a different bet from one sent yesterday, so &lt;code&gt;forecast.py&lt;/code&gt; conditions each distribution on the invoice's current age. From that it reads:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the late chance, as P(paid after the due date | still unpaid today),&lt;/li&gt;
&lt;li&gt;the 10th, 50th and 90th percentile payment dates,&lt;/li&gt;
&lt;li&gt;2,000 simulated payment schedules per date, summed per week, for the cash forecast.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next-step labels are plain rules on top (overdue: contact today; 50% or more: reminder now; 30% or more: remind on the due date). The history line beside each invoice is computed, not generated, so it cannot state something the data does not support.&lt;/p&gt;

&lt;h3&gt;
  
  
  Did it work? A month-by-month backtest
&lt;/h3&gt;

&lt;p&gt;For each month from January to October 2013, every model was trained only on invoices paid before that month began, then predicted the invoices sent during the month: 1,075 predictions in total. TabPFN on a CPU accepts up to 1,000 training rows by default, so each month uses the 1,000 most recent paid invoices.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;AUC&lt;/th&gt;
&lt;th&gt;Brier&lt;/th&gt;
&lt;th&gt;Chase recall&lt;/th&gt;
&lt;th&gt;Pay day error&lt;/th&gt;
&lt;th&gt;80% range hit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TabPFN v2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.882&lt;/td&gt;
&lt;td&gt;0.127&lt;/td&gt;
&lt;td&gt;51.0%&lt;/td&gt;
&lt;td&gt;5.92 days&lt;/td&gt;
&lt;td&gt;79.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logistic / linear regression&lt;/td&gt;
&lt;td&gt;0.890&lt;/td&gt;
&lt;td&gt;0.122&lt;/td&gt;
&lt;td&gt;50.4%&lt;/td&gt;
&lt;td&gt;5.81 days&lt;/td&gt;
&lt;td&gt;79.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client's own history&lt;/td&gt;
&lt;td&gt;0.870&lt;/td&gt;
&lt;td&gt;0.135&lt;/td&gt;
&lt;td&gt;50.7%&lt;/td&gt;
&lt;td&gt;6.54 days&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gradient-boosted trees&lt;/td&gt;
&lt;td&gt;0.864&lt;/td&gt;
&lt;td&gt;0.154&lt;/td&gt;
&lt;td&gt;49.9%&lt;/td&gt;
&lt;td&gt;6.23 days&lt;/td&gt;
&lt;td&gt;57.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Base rate&lt;/td&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;0.218&lt;/td&gt;
&lt;td&gt;18.4%&lt;/td&gt;
&lt;td&gt;10.11 days&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Chase recall" answers the business question: if you only chase the riskiest 20% of each month's invoices, what share of the late ones do you catch? Lower Brier and pay day error are better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest reading:&lt;/strong&gt; on this dataset, a plain logistic and linear regression is as accurate as TabPFN. I expected TabPFN to win clearly, and it did not. It does clearly beat gradient-boosted trees trained on the same rows, whose 80% ranges caught the real payment date only 57.5% of the time.&lt;/p&gt;

&lt;p&gt;The probabilities are well calibrated. Of the 586 invoices TabPFN put below 20%, 8.7% were paid late (it predicted 6.2% on average). Of the 116 it put above 80%, 90.5% were (it predicted 87.4%).&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%2Fsi8q8jsou1xvapvac4s3.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%2Fsi8q8jsou1xvapvac4s3.png" alt="Backtest table, range adaptivity and calibration chart from the live page" width="800" height="718"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Where TabPFN earns its place
&lt;/h3&gt;

&lt;p&gt;A linear model with a residual band gives every invoice the same width of uncertainty. TabPFN gives each invoice its own distribution. To test whether that matters, I split clients into thirds by how much their past payment times varied:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;80% range&lt;/th&gt;
&lt;th&gt;Steady clients&lt;/th&gt;
&lt;th&gt;Middle&lt;/th&gt;
&lt;th&gt;Erratic clients&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TabPFN&lt;/td&gt;
&lt;td&gt;80.5% hit, 16.0 days wide&lt;/td&gt;
&lt;td&gt;83.3%, 17.7 days&lt;/td&gt;
&lt;td&gt;73.3%, 19.9 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linear&lt;/td&gt;
&lt;td&gt;83.8% hit, 18.4 days wide&lt;/td&gt;
&lt;td&gt;84.2%, 18.5 days&lt;/td&gt;
&lt;td&gt;69.7%, 18.4 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;TabPFN narrows the range for reliable payers and widens it for erratic ones, which is what you want, but the effect is modest and both under-cover the erratic third. The distribution is also what lets Cashcast answer "this invoice is 20 days old and still unpaid, now what?" without a separate model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checking the replay itself
&lt;/h3&gt;

&lt;p&gt;The numbers on the live page were scored against what really happened (10 dates, 905 unpaid-invoice rows):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Of 296 "send a reminder now" flags, &lt;strong&gt;78% were in fact paid late&lt;/strong&gt;. Among all not-yet-due invoices on those dates, 43% were.&lt;/li&gt;
&lt;li&gt;The real payment date fell inside the 80% range 77.1% of the time, with a typical miss of 5.78 days.&lt;/li&gt;
&lt;li&gt;The weekly cash forecast is the weak spot: the actual amount landed inside its 80% range in only 67.5% of 80 weeks. The simulation treats invoices as independent, so the range is too narrow.&lt;/li&gt;
&lt;li&gt;Each date takes about 40 seconds on a laptop CPU (classifier plus regressor on 1,000 rows).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What it still cannot do
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The sample contains only invoices that were eventually paid, so Cashcast has never seen an invoice that is written off.&lt;/li&gt;
&lt;li&gt;Every invoice in the sample has 30-day terms, so the effect of other terms is untested.&lt;/li&gt;
&lt;li&gt;It suggests whom to contact; it does not send anything.&lt;/li&gt;
&lt;li&gt;The live page is a replay of a public dataset. Before trusting it on your own ledger, run the same backtest on your history (&lt;code&gt;python -m cashcast.backtest&lt;/code&gt; shows how).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It keeps the client list at home.&lt;/strong&gt; An invoice ledger is a list of who pays you, how much and how reliably. With open weights, Cashcast runs on the laptop that already holds that file. The local server binds to &lt;code&gt;127.0.0.1&lt;/code&gt;, and after the one-time weight download, scoring still works with all network traffic blocked (I tested it by routing every request to a dead proxy).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It makes a small, cheap product possible.&lt;/strong&gt; A one-time-license tool cannot carry a per-prediction API bill. Open weights mean the marginal cost of a forecast is a few seconds of CPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The license has to allow selling it.&lt;/strong&gt; Prior Labs recommends its newest model, TabPFN-3.5, through its API under a free non-commercial license. That is a fine choice for research, but a side hustle is commercial by definition. TabPFN v2 weights are published under the Prior Labs License 1.1 (Apache 2.0 with an attribution requirement, which is why the page says "Built with PriorLabs-TabPFN"), download from Hugging Face without an account, and run offline. For this product, the openness of the license mattered as much as the openness of the weights. The trade-off is that I may be leaving accuracy on the table by not using the newest model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Openness made the evaluation possible.&lt;/strong&gt; Because the model, the data and the code are all inspectable, I could run every baseline on exactly the same rows and publish the result where TabPFN did not win. A black-box API would have given me a number to trust instead of a number to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of TabPFN.&lt;/strong&gt; TabPFN does all of the predicting in Cashcast. A TabPFN v2 classifier estimates the chance each invoice is paid late, and a TabPFN v2 regressor returns a 49-quantile distribution of days to payment, which Cashcast conditions on invoice age to produce payment ranges, reminder decisions and an eight-week cash simulation. It is tested month by month against four baselines on 1,075 invoices, with calibration and per-client range checks, and the results, including where a linear model matched it, are published in the repository and on the live page.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>machinelearning</category>
      <category>python</category>
    </item>
    <item>
      <title>PageTwins: I Planted One Defect on 100 Landing Pages. Which AI Graders Noticed?</title>
      <dc:creator>StarKnight</dc:creator>
      <pubDate>Sun, 11 Oct 2026 06:51:47 +0000</pubDate>
      <link>https://dev.to/starknight/pagetwins-i-planted-one-defect-on-100-landing-pages-which-ai-graders-noticed-57b0</link>
      <guid>https://dev.to/starknight/pagetwins-i-planted-one-defect-on-100-landing-pages-which-ai-graders-noticed-57b0</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/kaggle-2026-09-23"&gt;Kaggle Benchmarking Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;I build Shipgrade, a tool that grades landing pages. It reads a page the way a script does: the headline, the buttons, the prices. It never looks at the page. That left me with two questions I had never measured. What does a grader miss when it only reads the page? And when a model does get a screenshot, does it notice what is actually broken, or does it give a confident score either way?&lt;/p&gt;

&lt;p&gt;To find out, I built &lt;strong&gt;PageTwins&lt;/strong&gt;: 10 original landing pages and 100 "evil twins". Each twin is identical to its original except for one planted defect.&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%2Fife0krntg57739fg13ra.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%2Fife0krntg57739fg13ra.png" alt="Six of the ten defects, each next to the original page" width="800" height="1522"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Defect&lt;/th&gt;
&lt;th&gt;What changed in the twin&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Missing button&lt;/td&gt;
&lt;td&gt;every sign-up, buy or download button removed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hidden price&lt;/td&gt;
&lt;td&gt;paid prices replaced with "Contact us"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contradiction&lt;/td&gt;
&lt;td&gt;one claim changed so two parts of the page disagree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vague headline&lt;/td&gt;
&lt;td&gt;the headline no longer says what the product is&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typos&lt;/td&gt;
&lt;td&gt;three misspelled words in prominent text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Placeholder text&lt;/td&gt;
&lt;td&gt;"Lorem ipsum" or a &lt;code&gt;{{template_variable}}&lt;/code&gt; left in the copy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blocking popup&lt;/td&gt;
&lt;td&gt;a signup modal covers the first screen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Low contrast&lt;/td&gt;
&lt;td&gt;headline faded to about 1.25:1 against its background&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Broken image&lt;/td&gt;
&lt;td&gt;the hero image fails to load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cut-off text&lt;/td&gt;
&lt;td&gt;the headline box is clipped partway through the first line&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All brands, copy and prices are fictional, and the art is SVG drawn by code. Every twin was rendered in headless Chromium and measured, and kept only if the measurement for its own defect changed (for example, the number of sign-up buttons dropped to zero) while every other measurement matched the original. The last three defects are visual by construction: the twin's text is byte-for-byte identical to the original's, so a grader that only reads the text cannot see them.&lt;/p&gt;

&lt;p&gt;Each original also has two controls that a careful grader should leave alone: an identical copy, and a copy with a harmless change (a brand color swap at the same contrast, reordered feature cards, or a reworded subheadline).&lt;/p&gt;

&lt;p&gt;The benchmark has four tasks, written with the &lt;code&gt;kaggle-benchmarks&lt;/code&gt; SDK and pushed from the Kaggle CLI:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;The model sees&lt;/th&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pairs, screenshots&lt;/td&gt;
&lt;td&gt;two full-page screenshots, shown in both orders&lt;/td&gt;
&lt;td&gt;half for defects picked and named in both orders, half for controls answered "same" in both orders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pairs, text&lt;/td&gt;
&lt;td&gt;the same pairs as visible text with element roles&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single page, screenshot&lt;/td&gt;
&lt;td&gt;one screenshot, no reference&lt;/td&gt;
&lt;td&gt;share of twins where the model flags the defect and does not flag it on the original&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single page, text&lt;/td&gt;
&lt;td&gt;one page as text&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pairs is how a careful reviewer works: compare the new version with the last one. The single-page task is how most graders run in practice, mine included: one page, no reference, "what is wrong with this?". Showing every pair in both orders means a pick only counts if it survives the swap, and it measures position bias for free. In the single-page task a model gets credit only if it flags the defect on the twin and does not flag the same problem on the original, so a model that flags everything scores zero.&lt;/p&gt;

&lt;p&gt;Every model gets the same prompt; the screenshot and text versions differ by one sentence that describes the input. Replies are short JSON, and anything unreadable counts as wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Models Tested
&lt;/h2&gt;

&lt;p&gt;Ten models, 34 scored runs, every one on Kaggle's model proxy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;All four tasks (they accept images):&lt;/strong&gt; Claude Sonnet 5.5 and Claude Haiku 4.5, GPT-6.1 Sol and GPT-6 Luna, Gemini 3.7 Flash and Gemini 3.5 Flash-Lite, and Gemma 4 31B.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The two text tasks (text-only models):&lt;/strong&gt; GLM-5, DeepSeek-R1 and Qwen 3 235B.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The idea was a stronger and a cheaper model from each of the three big vision providers, so I could see whether "smaller" means "slightly worse" or "a different kind of grader", plus open-weight models, because a grading tool like mine could run one locally. Kaggle gives $10 of model credit per day, so the lineup leaves out the most expensive tiers. The 26 runs I could price came to about $21, estimated from each run's token counts and per-token prices (I had no price for Gemma, GLM-5 and Qwen).&lt;/p&gt;

&lt;p&gt;Two planned models are missing, and I would rather say so than quietly drop them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grok 4.6&lt;/strong&gt; is listed by &lt;code&gt;kaggle b t models&lt;/code&gt;, but Kaggle's proxy answered &lt;code&gt;404 model not found&lt;/code&gt; for every xAI model I tried (Grok 4.6, Grok 4.5 and both Grok 4.20 models) from October 7 to October 11, and my runner kept probing until the end. Grok has no results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gpt-oss-120b&lt;/strong&gt; hit Kaggle's notebook time limit on two attempts and was dropped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every other planned run finished and is on the leaderboard.&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%2Fnez6vzlubvmce04ket1h.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%2Fnez6vzlubvmce04ket1h.png" alt="Kaggle task scores for every model" width="800" height="611"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Findings
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The strong models do not need the "before" picture
&lt;/h3&gt;

&lt;p&gt;I expected the reference page to be the big advantage. With screenshots, it barely mattered for the top four: Claude Sonnet 5.5 caught 98% of defects judging a page alone and 100% with the original next to it; GPT-6.1 Sol 98% and 96%; Gemini 3.7 Flash 96% and 98%; Gemma 4 31B 93% and 95%. Sonnet 5.5 got a perfect pairs score: all 100 defects picked and named in both orders, and all 20 controls called "same".&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%2F8l4ubvqfyyun2e74w9tu.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%2F8l4ubvqfyyun2e74w9tu.png" alt="Comparing two versions vs judging one page, screenshots" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The two small models are where the pair changed things, and not always for the better. Gemini 3.5 Flash-Lite went from 63% to 72% caught with a reference, but it failed to call 9 of its 10 harmless pairs "same" in both orders. Claude Haiku 4.5 also passed only 1 of its 10 harmless pairs, and it showed real position bias: it picked the second screenshot 62% of the time and gave the same letter in both orders on 24% of defect pairs, which is exactly what the swap is there to catch. Identical copies were easy for everyone (10 of 10 for every model in every pairs run); a harmless difference is the actual test.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A screenshot is not a superset of the text
&lt;/h3&gt;

&lt;p&gt;This is the result that changed how I think about my own tool. Restricted to the seven defects that are visible in both views, and pooled over the seven models that ran both, the screenshot and the text view tie: 82.9% vs 82.2% caught on the single-page task. But they tie by failing on different things.&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%2F69ranrs7k7wioe2m10gq.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%2F69ranrs7k7wioe2m10gq.png" alt="Twins caught per defect, all models pooled" width="800" height="526"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Typos:&lt;/strong&gt; judging one page, the same seven models caught 69 of 70 typo twins from text and 51 of 70 from screenshots. GPT-6 Luna caught 3 of 10 in both screenshot tasks and 9 of 10 from the text view alone. Reading misspellings off pixels is still unreliable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blocking popup:&lt;/strong&gt; 70 of 70 from screenshots, 40 of 70 from text. The popup is in the text view (as a dialog at the end of the page), so this is not a visibility problem. Gemini 3.7 Flash caught 0 of 10 popups from text alone and 10 of 10 from screenshots. Claude Haiku 4.5 often saw the dialog and still did not flag it; one of its replies reads: "the signup dialog that appears on load could be considered slightly intrusive but is dismissible."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The three visual-only defects&lt;/strong&gt; are 0% from text, as designed, and 70% (low contrast), 80% (broken image) and 90% (cut-off text) from screenshots.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Images help strong models and hurt weak ones
&lt;/h3&gt;

&lt;p&gt;On those same seven text-visible defects, the strongest models did better with a screenshot than with the text: Sonnet 5.5 97% vs 90%, GPT-6.1 Sol 97% vs 87%, Gemini 3.7 Flash 94% vs 80%. The two smallest did worse: Gemini 3.5 Flash-Lite 64% vs 76%, Claude Haiku 4.5 46% vs 64%. For a small model, an image is not extra information; it is a harder input to read the same information from.&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%2F8da6vcdx0jns7uy2uk3k.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%2F8da6vcdx0jns7uy2uk3k.png" alt="Screenshot vs text view on the defects visible in both" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Judging alone is a different skill, and the scores barely move
&lt;/h3&gt;

&lt;p&gt;Without a reference, a vague headline is a matter of standards, and many models had none: Gemini 3.5 Flash-Lite, Claude Haiku 4.5 and Qwen 3 235B caught 0 of 10 vague headlines from text on their own, yet Qwen caught 10 of 10 when it could compare against the original.&lt;/p&gt;

&lt;p&gt;The 1 to 10 "launch readiness" score each model also gives answers my second question. Strong models drop it hard on a broken page: Sonnet 5.5 gave originals 9.0 and twins 5.6 on average from screenshots, GPT-6.1 Sol 10.0 and 7.5. Weak ones barely move: Claude Haiku 4.5 gave 8.9 to originals and 8.6 to twins. A score from a weak grader looks just as confident on a broken page.&lt;/p&gt;

&lt;p&gt;False alarms were rare for most models on the 20 clean pages per run (originals plus harmless variants), with two exceptions on the text view: DeepSeek-R1 flagged 6 of 20 clean pages and GLM-5 4 of 20, most often with an invented "contradiction" (7 of their 10 false labels). They are also the two models that wrote the longest replies (about 1,500 and 2,200 output tokens per page). Two models are not enough to call that a pattern, but it is the next thing I would test.&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%2Fipby6p2wk8zyudi2x7k8.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%2Fipby6p2wk8zyudi2x7k8.png" alt="False alarms on pages with nothing wrong" width="800" height="695"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Price does not predict which cheap model is good
&lt;/h3&gt;

&lt;p&gt;On pairs with screenshots, GPT-6 Luna scored 0.965 for about $0.21 and Gemini 3.7 Flash 0.99 for about $0.71, against 0.98 for about $4.12 (GPT-6.1 Sol) and 1.00 for about $4.44 (Claude Sonnet 5.5). Yet Gemini 3.5 Flash-Lite cost the same $0.21 as GPT-6 Luna and scored 0.635, and Claude Haiku 4.5 cost $0.92 and scored 0.455. "The cheaper model from the same family" ranged from nearly perfect to unreliable as a grader, so for a tool that runs on every deploy, a cheap model has to be tested on the actual task rather than picked by tier.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this means for Shipgrade
&lt;/h3&gt;

&lt;p&gt;A text-only grader is blind to three of these ten defects by construction, and that is the obvious part. The less obvious part is that switching to screenshots is not a free upgrade: it gains the visual defects and the popups, and gives back typos. The cheapest robust setup this data suggests is both views (a cheap vision model that tested well, such as GPT-6 Luna or Gemini 3.7 Flash here, on the screenshot, plus a text model on the text), and a pair against the last deploy when one exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  Limitations
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The pages are synthetic, with exactly one defect each and a clear control. Real pages have several overlapping problems and no clean original.&lt;/li&gt;
&lt;li&gt;10 base designs means 10 samples per defect per model, so one miss moves a category by 10 points. Read the per-category numbers as coarse.&lt;/li&gt;
&lt;li&gt;One run per model and one prompt wording. I did not measure run-to-run variance or prompt sensitivity.&lt;/li&gt;
&lt;li&gt;The pairs-text task is saturated: three of ten defects are invisible in text, so 70% caught is the ceiling, and four models (Sonnet 5.5, GPT-6.1 Sol, Gemma 4 31B, GLM-5) hit it exactly.&lt;/li&gt;
&lt;li&gt;Kaggle's overall leaderboard score averages the tasks each model ran, so the three text-only models are averaged over the two text tasks. Compare them on the text columns.&lt;/li&gt;
&lt;li&gt;Grok could not be run at all, and gpt-oss-120b was dropped after two timeouts.&lt;/li&gt;
&lt;li&gt;A small local pilot on Groq models was used only to debug the task code; none of the numbers here come from it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What I would measure next
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Subtler variants of the same defects (one typo instead of three, contrast at 2.5:1 instead of 1.25:1, a half-hidden button) to find where each model's threshold is.&lt;/li&gt;
&lt;li&gt;Real pages with several defects at once, graded against human reviewers.&lt;/li&gt;
&lt;li&gt;Repeated runs per model, to put error bars on these numbers.&lt;/li&gt;
&lt;li&gt;Whether asking for less reasoning reduces the invented contradictions.&lt;/li&gt;
&lt;li&gt;Mobile widths, where cut-off text and popups behave differently.&lt;/li&gt;
&lt;/ul&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%2Fqa388g3ce1jw7w5klo6w.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%2Fqa388g3ce1jw7w5klo6w.png" alt="The PageTwins leaderboard on Kaggle" width="799" height="571"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  My Benchmark
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Benchmark on Kaggle:&lt;/strong&gt; &lt;a href="https://www.kaggle.com/benchmarks/prasenx/pagetwins" rel="noopener noreferrer"&gt;kaggle.com/benchmarks/prasenx/pagetwins&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tasks:&lt;/strong&gt; &lt;a href="https://www.kaggle.com/benchmarks/tasks/prasenx/pagetwins-pairs-vision" rel="noopener noreferrer"&gt;pairs, screenshots&lt;/a&gt;, &lt;a href="https://www.kaggle.com/benchmarks/tasks/prasenx/pagetwins-pairs-text" rel="noopener noreferrer"&gt;pairs, text&lt;/a&gt;, &lt;a href="https://www.kaggle.com/benchmarks/tasks/prasenx/pagetwins-solo-vision" rel="noopener noreferrer"&gt;single page, screenshot&lt;/a&gt;, &lt;a href="https://www.kaggle.com/benchmarks/tasks/prasenx/pagetwins-solo-textview" rel="noopener noreferrer"&gt;single page, text&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dataset:&lt;/strong&gt; &lt;a href="https://www.kaggle.com/datasets/prasenx/pagetwins" rel="noopener noreferrer"&gt;kaggle.com/datasets/prasenx/pagetwins&lt;/a&gt; (120 pages with HTML, full-page screenshots and text views, CC BY 4.0)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code, raw answers and charts:&lt;/strong&gt; &lt;a href="https://github.com/StarKnightt/pagetwins" rel="noopener noreferrer"&gt;github.com/StarKnightt/pagetwins&lt;/a&gt; (MIT)&lt;/li&gt;
&lt;/ul&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/StarKnightt" rel="noopener noreferrer"&gt;
        StarKnightt
      &lt;/a&gt; / &lt;a href="https://github.com/StarKnightt/pagetwins" rel="noopener noreferrer"&gt;
        pagetwins
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      PageTwins: a Kaggle benchmark of 100 one-defect landing page twins. Do AI graders notice what broke?
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;PageTwins&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;A Kaggle benchmark that asks a simple question: when a landing page breaks in
one specific way, does an AI grader notice, name the problem, and leave a clean
page alone?&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Benchmark: &lt;a href="https://www.kaggle.com/benchmarks/prasenx/pagetwins" rel="nofollow noopener noreferrer"&gt;https://www.kaggle.com/benchmarks/prasenx/pagetwins&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Dataset: &lt;a href="https://www.kaggle.com/datasets/prasenx/pagetwins" rel="nofollow noopener noreferrer"&gt;https://www.kaggle.com/datasets/prasenx/pagetwins&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/StarKnightt/pagetwins/charts/examples.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FStarKnightt%2Fpagetwins%2FHEAD%2Fcharts%2Fexamples.png" alt="Six of the ten defects next to the original page"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The dataset&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;10 original landing pages (SaaS, developer tool, e-commerce tool, consumer
products, mobile apps, an online course, a local service) on four layouts
Each one has 10 "evil twins" that are identical except for exactly one planted
defect, plus two controls: an identical copy and a copy with a harmless change.
That makes 120 pages, 100 defect pairs and 20 control pairs. Every brand,
product, quote and price is fictional and was written for the dataset.&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Defect&lt;/th&gt;
&lt;th&gt;What changed in the twin&lt;/th&gt;
&lt;th&gt;In the text view&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;missing_cta&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;every sign-up, buy or download button removed&lt;/td&gt;
&lt;td&gt;visible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hidden_price&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;paid prices replaced with "Contact us" or similar&lt;/td&gt;
&lt;td&gt;visible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contradiction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;one claim changed so two parts of&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/StarKnightt/pagetwins" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>devchallenge</category>
      <category>kagglechallenge</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Waymark: a trail guide you listen to, written by Gemma 4 in your browser</title>
      <dc:creator>StarKnight</dc:creator>
      <pubDate>Mon, 05 Oct 2026 22:35:41 +0000</pubDate>
      <link>https://dev.to/starknight/waymark-a-trail-guide-you-listen-to-written-by-gemma-4-in-your-browser-3pgf</link>
      <guid>https://dev.to/starknight/waymark-a-trail-guide-you-listen-to-written-by-gemma-4-in-your-browser-3pgf</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Most trail apps ask you to look at them. At a junction you take the phone out, wake the screen and find the blue dot, and for that moment you are looking at a map instead of the trail. This week's theme asks for builds where the screen is the shortest part of the experience, so I built a trail guide that you listen to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Waymark&lt;/strong&gt; turns a real trail into a short spoken guide. You pick a trail, or open a GPX file, and Waymark works out the facts from open data: where the junctions are and which way to go at each one, where the climbs start and how steep they are, where the water, bridges, steps and viewpoints are, and how long the walk should take. Gemma 4 E2B, an open-weight model running inside the browser, writes one or two plain sentences for each of those waymarks and a short briefing for the whole walk.&lt;/p&gt;

&lt;p&gt;Before you leave, a 3D model of the real terrain previews the walk in about a minute, with every cue shown where it will be spoken. Then you send the guide to your phone and put the phone in your pocket. Pocket mode keeps the screen dark, follows GPS and speaks each cue when you reach that waymark, for example: "Continue straight onto Mist Trail. John Muir Trail on the right is not your route." On a two-hour walk, you look at the screen for about a minute.&lt;/p&gt;

&lt;p&gt;It is for walkers and hikers on trails they do not know well, and for anyone who would rather keep their eyes on the path. A group can share one link.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8uptz0d63gg01re09aam.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8uptz0d63gg01re09aam.gif" alt="A flyover of the Mist Trail in Yosemite, with each cue shown where it will be spoken"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://starknightt.github.io/waymark/" rel="noopener noreferrer"&gt;https://starknightt.github.io/waymark/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Things to try:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pick a demo trail.&lt;/strong&gt; Mist Trail (Yosemite, USA), Lake Agnes Trail (Banff, Canada), Galu Devi to Triund (Himachal Pradesh, India) and the Pen y Fan and Corn Du circular walk (Wales, UK). Their guides were written by Gemma 4 E2B in Chrome and are saved with the site, so nothing large is downloaded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preview the walk.&lt;/strong&gt; The camera flies along the route and slows at each waymark. Tick "Read the cues aloud" to hear the guide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Show the facts behind each cue.&lt;/strong&gt; Each cue then lists the facts it was checked against, and what the checker changed, if anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walk it with your phone.&lt;/strong&gt; Scan the QR code, then press "Try a simulated walk". It replays the route at eight times walking speed and speaks each cue as the simulated walker reaches it. "Start walk" uses real GPS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write a guide yourself.&lt;/strong&gt; On a desktop with Chrome or Edge, press "Write it again on this device" on any demo trail, or "Make a guide for a trail" to search for a mapped route, paste an OpenStreetMap route link or open a GPX file. The first time, the browser downloads the model (2.0 GB) and keeps it for next time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The phone view during a simulated walk. Each cue is also shown as text when it is spoken:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbu8ukj47ognjh31kubjy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbu8ukj47ognjh31kubjy.gif" alt="Pocket mode on a phone, speaking cues during a simulated walk on the Lake Agnes Trail"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Gemma writing a guide for the Triund trail in the browser, with nothing mocked (sped up 2 times):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhr8uv0mrvfzwkqgciglp.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhr8uv0mrvfzwkqgciglp.gif" alt="Gemma 4 E2B writing the Triund guide inside the browser"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each cue next to the facts it came from:&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%2Fji9al3ll1aqz2443erf7.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%2Fji9al3ll1aqz2443erf7.png" alt="A cue with the facts it was checked against"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/StarKnightt" rel="noopener noreferrer"&gt;
        StarKnightt
      &lt;/a&gt; / &lt;a href="https://github.com/StarKnightt/waymark" rel="noopener noreferrer"&gt;
        waymark
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A trail guide you listen to. Gemma 4 runs in your browser and writes spoken cues for real trails from open map data.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Waymark&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;A trail guide you listen to.&lt;/strong&gt; Pick a real trail. Waymark builds the facts from OpenStreetMap and open elevation data, and Gemma 4 E2B, running in your browser on WebGPU, writes a short spoken cue for every junction, climb and landmark. A 3D model of the real terrain previews the walk in about a minute. Then the phone goes in your pocket: it keeps the screen dark and speaks each cue when you reach that waymark.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://starknightt.github.io/waymark/" rel="nofollow noopener noreferrer"&gt;https://starknightt.github.io/waymark/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/StarKnightt/waymark/docs/flyover.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2FStarKnightt%2Fwaymark%2FHEAD%2Fdocs%2Fflyover.gif" alt="A flyover of the Mist Trail with its spoken cues"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Built for the DEV Hacktoberfest Open-Source AI Challenge, Week 1 (Touch Grass). The repository was started on October 5, 2026, inside the challenge window.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What it does&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Four demo trails&lt;/strong&gt; that open instantly: Mist Trail (Yosemite, USA), Lake Agnes Trail (Banff, Canada), Galu Devi to Triund (Himachal Pradesh, India) and the Pen y Fan and Corn Du circular walk (Wales, UK). Their guides were written by Gemma 4 E2B in Chrome…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/StarKnightt/waymark" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;TypeScript, Vite, Three.js and LiteRT-LM, under the MIT license. All of the code was written during the challenge window, starting on October 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Facts first, then the model
&lt;/h3&gt;

&lt;p&gt;A spoken guide is worse than useless if it says left when the path goes right, and a 2B model cannot do geometry. So the work is split. Code builds the facts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Junctions&lt;/strong&gt; come from the OpenStreetMap path network. Wherever other paths meet the route, Waymark measures the bearing of every branch over about 22 m and decides whether the route turns left or right, keeps left or right at a fork, or continues straight, and which of the other ways are not the route. When two ways leave on the same side, it says which one to take.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Climbs and descents&lt;/strong&gt; come from open elevation tiles: sections steeper than 7% that gain at least 40 m, with their length, gain, average grade and top elevation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Landmarks near the path:&lt;/strong&gt; drinking water, springs, waterfalls, viewpoints, summits, huts, bridges, flights of steps, lakes that come into view, and where the path enters or leaves woodland.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walking time&lt;/strong&gt; uses Tobler's hiking function on the elevation profile.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are merged into waymarks, between 5 and 22 on the twelve test trails, each a short list of facts. The start, the finish and every junction where the route turns or moves onto a different path are marked as required. This is what Gemma sees for one waymark on the Mist Trail, wrapped here to fit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;w8 (REQUIRED): junction: continue straight onto "Mist Trail";
  "John Muir Trail" on the right is not your route
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Gemma 4 E2B in the browser
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Runtime:&lt;/strong&gt; the JavaScript API of LiteRT-LM from Google AI Edge, on WebGPU. The model file is &lt;code&gt;gemma-4-E2B-it-web.litertlm&lt;/code&gt; (2.0 GB). The browser downloads it once into its private file storage (OPFS), and after that it loads in a few seconds (2.3 s on my PC).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One constrained tool call:&lt;/strong&gt; &lt;code&gt;write_guide({ cues: [{ waymark, text }], briefing })&lt;/code&gt;. The &lt;code&gt;waymark&lt;/code&gt; field is an enum of the real waymark IDs, so the model cannot invent a waymark, and constrained decoding guarantees the call can be parsed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules in the prompt:&lt;/strong&gt; use only the facts listed for that waymark, give directions exactly as written, one or two short sentences, and a cue for every required waymark.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost of a guide:&lt;/strong&gt; about 1,400 prompt tokens and 700 output tokens, 18 seconds on my PC's RTX 4060.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  A checker that knows what each number means
&lt;/h3&gt;

&lt;p&gt;Every cue is checked against the facts of its own waymark before it is saved:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Numbers&lt;/strong&gt; must exist in the facts with the same meaning. The words around a number decide its role: "gains 70 m" is a gain, "topping out at 1,980 m" is an elevation and "the next 450 m" is a length. Numbers written as words, such as "an hour and a half", are checked as well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Names&lt;/strong&gt; must exist on the route, including names in other scripts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Directions&lt;/strong&gt; must match the junction. A wrong side is swapped, an invented turn is removed, and a turn that went missing is restored from the facts. When a waymark holds two junctions close together, both turns are allowed, in order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Features:&lt;/strong&gt; a summit, waterfall or bridge can only be mentioned where the facts mention one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A sentence that fails is removed. If nothing safe is left, the cue is rebuilt from the facts. The checker has 35 unit cases that run in Chrome, and most of them come from real mistakes.&lt;/p&gt;

&lt;h3&gt;
  
  
  What went wrong along the way
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One call instead of many.&lt;/strong&gt; My first design asked Gemma to call &lt;code&gt;add_cue&lt;/code&gt; once per waymark and &lt;code&gt;set_briefing&lt;/code&gt; once. It called &lt;code&gt;set_briefing&lt;/code&gt; and stopped, in every test. A single &lt;code&gt;write_guide&lt;/code&gt; call with an array fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every number was right and the sentence was wrong.&lt;/strong&gt; "Gain 730 meters in 1.5 km." Both numbers were in the facts: 730 m was the length of the climb and 1.5 km was its distance from the start. A checker that only looks for numbers passed it. That is why numbers now carry a role, and why the prompt no longer includes each waymark's distance from the start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My first checker was wrong more often than the model.&lt;/strong&gt; In its first full run over 12 trails it changed 12 cues. Four of those changes were right. Five turned a correct cue into a wrong one: on the Dipsea Trail, two junctions 40 m apart share a waymark ("turn left onto Hazel Avenue, then right onto Hale Lane"), and the checker "corrected" the second turn to match the first. Three more removed correct sentences, such as "Find the Sharma store on your right", rejected because "Find the Sharma" looked like an unknown place. Each of these is now a unit case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phrases copied between waymarks.&lt;/strong&gt; Gemma sometimes repeated "the path straight ahead is not your route" at a junction with no such path. The checker now requires every side path it mentions to be listed in that junction's facts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Map data needs care.&lt;/strong&gt; I had mapped OpenStreetMap's wetland areas to water, so the bogs around Pen y Fan were drawn as lakes, and the Merced River's water area produced "a lake comes into view on your right" on the Mist Trail. Forest areas also run over 3,000 m ridges, so the 3D view applies a treeline estimated from latitude.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A colour space bug.&lt;/strong&gt; &lt;code&gt;THREE.Color&lt;/code&gt; converts colours to linear light, so my tree placement compared sRGB pixels with linear values and matched nothing. Every mountain was bare until I read the hex value directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Map servers.&lt;/strong&gt; From my network only one of the three public Overpass servers answered, and slowly. The demo trails are built ahead of time from the OpenStreetMap API, the app tries the three servers in turn, and if none answers it still builds a guide from elevation alone.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Results
&lt;/h3&gt;

&lt;p&gt;Twelve trails in seven countries, Chrome on an RTX 4060 desktop GPU, greedy decoding. The second column asks for the same guide as JSON in plain text, without constrained decoding.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;One constrained tool call (shipped)&lt;/th&gt;
&lt;th&gt;JSON in plain text&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cues written by Gemma&lt;/td&gt;
&lt;td&gt;180&lt;/td&gt;
&lt;td&gt;180&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Passed every check unchanged&lt;/td&gt;
&lt;td&gt;171&lt;/td&gt;
&lt;td&gt;170&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Changed by the checker&lt;/td&gt;
&lt;td&gt;9 (8 of them on Mount Takao)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Required waymarks covered&lt;/td&gt;
&lt;td&gt;120 of 120&lt;/td&gt;
&lt;td&gt;120 of 120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output that could not be used&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time per guide&lt;/td&gt;
&lt;td&gt;18.2 s&lt;/td&gt;
&lt;td&gt;11.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decoding speed&lt;/td&gt;
&lt;td&gt;44 tokens/s&lt;/td&gt;
&lt;td&gt;80 tokens/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eight of the nine changes were on Mount Takao, where Gemma translated or romanised Japanese path names, for example "Takao Mountain Line" for 高尾山線. The checker replaced them with the names on the map and kept the turns.&lt;/p&gt;

&lt;p&gt;The result I did not expect: Gemma 4 E2B followed the JSON format in plain text on every trail, so constrained decoding mainly bought certainty, at a cost of about 45% of the decoding speed. I kept it, because a guide for a trail nobody has tested must always parse. Most of the accuracy came from the facts and the checker rather than from the decoding method.&lt;/p&gt;

&lt;p&gt;Two more checks. On the live site, in a fresh browser, the first guide took about 10 minutes, nearly all of it the 2.0 GB download on my connection; writing the guide itself took about 20 seconds. All 62 cues of the four demo guides were also checked line by line against their source facts during development. After the checker, none of them gives a wrong turn, distance or climb, though a few read awkwardly, such as "There is a bridge, Mist Trail.", where the bridge is mapped as part of the Mist Trail. One mistake the checker cannot see is in a briefing: the Pen y Fan briefing says the walk reaches the summit of Pen y Fan, while the map data puts the summit 250 m from the route.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it cannot do yet
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;It is not a navigation or safety device. Carry a map and check conditions.&lt;/li&gt;
&lt;li&gt;Writing a guide needs WebGPU and about 2 GB of storage, so in practice a desktop or laptop. Any phone can play the guide.&lt;/li&gt;
&lt;li&gt;The cues are only as good as the map. An unmapped junction is missed, and English voices read names in other scripts, such as 高尾山線 on Mount Takao, poorly.&lt;/li&gt;
&lt;li&gt;The elevation data has a resolution of roughly 10 to 30 m, so sharp summits read lower than their true height.&lt;/li&gt;
&lt;li&gt;On a phone the page has to stay open with the screen awake for GPS and speech to keep working. Waymark keeps the screen dark to save power.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Where you walk, and when, is personal.&lt;/strong&gt; In January 2018, Strava's public heatmap of its users' activity revealed the locations and patrol routes of military bases. A trail plan says where you will be and when you will be away from home. With an open model running in the browser, the route, the guide and your position never go to an AI provider.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It has to work without signal.&lt;/strong&gt; Trails are often where coverage fails. The guide is written before you leave and fits in a link of about 1.5 KB, so on the trail the phone needs neither a network nor a model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I could control how the model writes, not only what I ask it.&lt;/strong&gt; Constrained decoding with my own schema, where the only valid waymarks are the real ones, and a 12-trail evaluation that I could re-run as often as I liked, at no cost. Most of the mistakes described above were found by those repeated runs. With a paid API I would have run the evaluation less often and found fewer of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is open all the way down.&lt;/strong&gt; OpenStreetMap data, open elevation tiles, an Apache-2.0 model, an Apache-2.0 runtime and MIT code. Anyone can host Waymark as static files, swap in another model, or adjust the facts for the trails where they live.&lt;/p&gt;

&lt;p&gt;The trade-off is a 2.0 GB first download and a desktop GPU to write guides. A larger hosted model would write more varied sentences, but for spoken directions being right matters more than style, and that comes from the facts and the checker, which work the same way with an open model on my own machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 E2B is the only model in Waymark, and it runs entirely in the browser through LiteRT-LM on WebGPU. It writes every spoken cue and every briefing through a constrained tool call whose schema only allows real waymarks. Every demo guide on the site was written by it, and "Write it again on this device" runs it live.&lt;/p&gt;

&lt;p&gt;Thanks for reading. If you try Waymark on a trail near you, I would like to hear how the cues held up.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>webgpu</category>
    </item>
    <item>
      <title>I built my grandmother a tiny island that keeps track of her day, with Gemma running inside the browser</title>
      <dc:creator>StarKnight</dc:creator>
      <pubDate>Sun, 04 Oct 2026 11:15:53 +0000</pubDate>
      <link>https://dev.to/starknight/i-built-my-grandmother-a-tiny-island-that-keeps-track-of-her-day-with-gemma-running-inside-the-gha</link>
      <guid>https://dev.to/starknight/i-built-my-grandmother-a-tiny-island-that-keeps-track-of-her-day-with-gemma-running-inside-the-gha</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;I built this for my grandmother.&lt;/p&gt;

&lt;p&gt;Because of her age, she can't remember a lot of things these days, and keeping track of her daily routine, such as when to take which medicine, has become hard for her. I wanted to give her something calm and simple that shows her day and what is left to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Little World&lt;/strong&gt; is a daily routine tracker shown as a small 3D island. You describe the routine in plain English. Gemma, an open model running inside the browser, turns the description into a schedule. Each task appears on the rim of the island at its time of day, and the sun and moon move across the sky with the real clock, so the current time and the next task are visible at a glance.&lt;/p&gt;

&lt;p&gt;This is the example routine I used during development. It is made up, not hers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Thyroid tablet, 50 mcg, when I wake up, on an empty stomach.&lt;br&gt;
Blood pressure tablet, Amlodipine 5 mg, after breakfast.&lt;br&gt;
Metformin 500 mg before lunch and before dinner.&lt;br&gt;
Eye drops in both eyes at 4 PM.&lt;br&gt;
Calcium tablet after dinner, not with the thyroid tablet.&lt;br&gt;
A 20 minute walk at 6 PM.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When a task is due, its marker lights up and a reminder appears. Pressing &lt;strong&gt;Done&lt;/strong&gt; marks it complete. The &lt;strong&gt;Ask&lt;/strong&gt; tab answers questions such as "What's next?" or "Did I take my eye drops?" using only the routine that was entered.&lt;/p&gt;

&lt;p&gt;The routine never leaves the device. There is no account, no server and no analytics, and after the first load the app works offline. For something as personal as a medicine list, that was a firm requirement.&lt;/p&gt;

&lt;p&gt;Little World is not a medical tool, and the app says so. It only repeats what it was told. When I asked "Can I take calcium with my thyroid tablet?", it answered that it could not see that in its notes and that I should check with a doctor or pharmacist, which is the behavior I wanted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Showing it to my grandmother
&lt;/h3&gt;

&lt;p&gt;I showed Little World to my grandmother. Her reaction was "oh wow, this is so good". Then she said "you made something for me", and she was genuinely pleased.&lt;/p&gt;

&lt;h3&gt;
  
  
  Designed for an older user
&lt;/h3&gt;

&lt;p&gt;After that, I focused on making it easier to use for someone her age:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"What do I do now?"&lt;/strong&gt; A single button opens a full-screen view with one task in very large type, one large "I've done it" button with an undo option, and the next few tasks below. It can be set as the default screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spoken reminders.&lt;/strong&gt; Reminders can be read aloud with the browser's built-in speech synthesis, so no external voice service is involved. If a task is still not done, the reminder repeats after 20 and 40 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Setup by a family member.&lt;/strong&gt; Gemma needs a computer with a capable GPU, but the person using the routine does not. A family member can enter the routine on a computer, select "Send to another device", and share the QR code or link. The routine is encoded in the part of the link after the &lt;code&gt;#&lt;/code&gt;, which browsers do not send to a server. Opening the link on a phone or tablet shows the routine and asks for confirmation before using it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Readability.&lt;/strong&gt; Larger text throughout, buttons at least 48 pixels tall, labels that stay readable against the daytime sky, and task states written as words ("Now", "Waiting", "Done") rather than shown only by color. A reduced-motion setting turns on automatically when the device requests it.&lt;/li&gt;
&lt;/ul&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%2Fxd8wdk6uvi3m9s2qvurz.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%2Fxd8wdk6uvi3m9s2qvurz.png" alt="The " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1nz0d2te44tr9lgeushx.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1nz0d2te44tr9lgeushx.gif" alt="Sending the routine to another device, opening it there and marking a task done" width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://starknightt.github.io/little-world/" rel="noopener noreferrer"&gt;https://starknightt.github.io/little-world/&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Try the demo island"&lt;/strong&gt; works on any device, including phones. It loads a routine that Gemma already parsed, so nothing is downloaded. From there, "What do I do now?" opens the large view and "Send to another device" shows the QR code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Describe my own routine"&lt;/strong&gt; requires desktop Chrome or another browser with WebGPU. Gemma 2 2B is downloaded once (about 1.4 GB) and stored by the browser. After that it loads in a few seconds and works offline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F08en7vz45nks1oi3s2q2.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F08en7vz45nks1oi3s2q2.gif" alt="A full day passing on the island" width="600" height="315"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The clip below shows the real model with nothing mocked: I enter a routine, Gemma parses it, I review the preview and save it, then ask two questions. The GIF runs at 2.5x speed; the &lt;a href="https://github.com/StarKnightt/little-world/blob/main/docs/demo.mp4" rel="noopener noreferrer"&gt;27-second video&lt;/a&gt; runs at 1.25x.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp2fkm4wp66w1b07rvriy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp2fkm4wp66w1b07rvriy.gif" alt="Gemma parsing a routine in the browser and answering questions" width="640" height="360"&gt;&lt;/a&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%2Fd59m69rjxo15tn3itc4n.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%2Fd59m69rjxo15tn3itc4n.png" alt="The editable preview of Gemma's output" width="800" height="450"&gt;&lt;/a&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%2Fq1dssnue5861n34m5sfl.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%2Fq1dssnue5861n34m5sfl.png" alt="Asking " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same routine at night, and on a phone:&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%2Fjyyc9jl3v420f9nf2148.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%2Fjyyc9jl3v420f9nf2148.png" alt="Night view with all tasks done" width="800" height="500"&gt;&lt;/a&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%2Fr4kkchl3vunt09pvoq7l.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%2Fr4kkchl3vunt09pvoq7l.png" alt="Phone view" width="780" height="1688"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/StarKnightt" rel="noopener noreferrer"&gt;
        StarKnightt
      &lt;/a&gt; / &lt;a href="https://github.com/StarKnightt/little-world" rel="noopener noreferrer"&gt;
        little-world
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A daily routine tracker on a small 3D island. Gemma runs in the browser through WebGPU, and nothing leaves the device.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Little World&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;A daily routine tracker shown as a small 3D island.&lt;/strong&gt; Describe a routine in plain English ("Thyroid tablet when I wake up, Metformin before lunch and dinner, eye drops at 4 PM"). Gemma, running entirely in the browser, turns it into a validated schedule. Each task is placed on the rim of the island at its time of day, and the sun and moon follow the real clock. Mark a task as done when it is complete. Ask "What's next?" and Gemma answers using only the routine.&lt;/p&gt;
&lt;p&gt;Nothing leaves your device. No account, no server, no analytics. After the first load it works offline.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://starknightt.github.io/little-world/" rel="nofollow noopener noreferrer"&gt;https://starknightt.github.io/little-world/&lt;/a&gt; (open &lt;code&gt;?demo&lt;/code&gt; for the instant demo, no download)&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/StarKnightt/little-world/docs/hero.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2FStarKnightt%2Flittle-world%2FHEAD%2Fdocs%2Fhero.gif" alt="Little World, a day passing on the island"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Day&lt;/th&gt;
&lt;th&gt;Night&lt;/th&gt;
&lt;th&gt;Phone&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/StarKnightt/little-world/docs/day.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FStarKnightt%2Flittle-world%2FHEAD%2Fdocs%2Fday.png" alt="Day"&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/StarKnightt/little-world/docs/night.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FStarKnightt%2Flittle-world%2FHEAD%2Fdocs%2Fnight.png" alt="Night"&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/StarKnightt/little-world/docs/mobile.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FStarKnightt%2Flittle-world%2FHEAD%2Fdocs%2Fmobile.png" alt="Mobile"&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Big view&lt;/th&gt;
&lt;th&gt;Family setup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/StarKnightt/little-world/docs/bigview.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FStarKnightt%2Flittle-world%2FHEAD%2Fdocs%2Fbigview.png" alt="What do I do now?"&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/StarKnightt/little-world/docs/family.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2FStarKnightt%2Flittle-world%2FHEAD%2Fdocs%2Ffamily.gif" alt="Send to another device, then open it there"&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Not medical advice.&lt;/strong&gt; Little World only repeats what you told it. It does not know about medicines, doses or interactions, and it is told to send those…&lt;/p&gt;
&lt;/blockquote&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/StarKnightt/little-world" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Built with Vite, TypeScript, Three.js, WebLLM and zod, under the MIT license. All code in the repository was written during the challenge weekend, starting October 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Stack
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 2 2B&lt;/strong&gt; (&lt;code&gt;gemma-2-2b-it-q4f16_1-MLC&lt;/code&gt;), Google's open-weight model, quantized to 4 bits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebLLM&lt;/strong&gt; from MLC, which runs the model on the GPU through WebGPU. It runs in a Web Worker so the 3D scene stays responsive while the model is working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three.js&lt;/strong&gt; for the island, with a custom sky shader, bloom and HTML labels that avoid overlapping each other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;zod&lt;/strong&gt; to validate every routine before it is saved, and &lt;strong&gt;vite-plugin-pwa&lt;/strong&gt; for offline support.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Pipeline
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;routine text
  → Gemma 2 2B, constrained by a grammar
  → JSON, one object per task: quoted source text, name, dose, time anchor, food rule, icon
  → quote check (plain code)
  → zod validation (one retry if it fails)
  → time anchors converted to clock times
  → editable preview
  → saved routine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Key decisions
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. A grammar instead of asking for JSON.&lt;/strong&gt; WebLLM can constrain decoding to a grammar. I wrote a small EBNF grammar for the exact routine format with fixed separators, so the model chooses only the content, never the structure. With it, every output in my test set was valid JSON.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The model chooses a meaning, and code computes the time.&lt;/strong&gt; My first version asked Gemma for an hour and a minute, and it often returned &lt;code&gt;0:00&lt;/code&gt; for "after breakfast". Now the model chooses from a fixed list of anchors (&lt;code&gt;on_waking&lt;/code&gt;, &lt;code&gt;after_breakfast&lt;/code&gt;, &lt;code&gt;before_lunch&lt;/code&gt;, &lt;code&gt;bedtime&lt;/code&gt; and a few others), and code converts them to times such as 8:30, 12:30 and 22:00. The model returns a clock time only when the text contains one, such as "at 4 PM". The food rule is derived from the anchor as well, so "before lunch" cannot become "after food".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Quote first, then verify.&lt;/strong&gt; For each task the model must also copy the exact source text into a &lt;code&gt;said&lt;/code&gt; field. A short function then checks that quote with regular expressions. If the quote names exactly one time or food rule and the model chose something else, the quote takes precedence. A quote such as "before lunch and before dinner" is split into two tasks, and a dose containing a number that does not appear in the source text is removed. This check produced the largest accuracy improvement, in about 100 lines of code.&lt;/p&gt;

&lt;p&gt;Before anything is saved, the user sees a preview in which every time can be edited and every task can be removed. A 2B model will sometimes be wrong, so the person always has the final say.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Questions&lt;/strong&gt; follow the same principle. Code first computes the facts: what is done, what is waiting and for how long, and what is next. Gemma only phrases those facts as a short answer, and it is instructed not to give medical advice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Results
&lt;/h3&gt;

&lt;p&gt;I wrote 20 test routines with known correct answers. They cover medicines, insulin, an inhaler, eye drops, plant care, feeding a pet, explicit clock times and descriptive wording such as "the white tablet with breakfast, the pink tablet at bedtime". I scored Gemma on all of them in headless Chrome on my PC's RTX 4060.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gemma 2 2B&lt;/th&gt;
&lt;th&gt;Valid JSON&lt;/th&gt;
&lt;th&gt;Correct task count&lt;/th&gt;
&lt;th&gt;Correct times&lt;/th&gt;
&lt;th&gt;Correct food rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free text, no grammar&lt;/td&gt;
&lt;td&gt;14/20 (17/20 after one retry)&lt;/td&gt;
&lt;td&gt;13/20&lt;/td&gt;
&lt;td&gt;38/54&lt;/td&gt;
&lt;td&gt;16/21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grammar, model alone&lt;/td&gt;
&lt;td&gt;20/20&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;36/54&lt;/td&gt;
&lt;td&gt;11/21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Grammar + quote check (shipped)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20/20&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17/20&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;47/54 (87%)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17/21&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Download:&lt;/strong&gt; 1.4 GB, once, in 42 parts. On the live site, the first run including the download took about 7.7 minutes on my connection. After that, loading from the browser cache takes about 4 to 6 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speed:&lt;/strong&gt; about 40 to 55 tokens per second for generation and 500 to 700 tokens per second for reading the prompt. Short routines take 4 to 6 seconds. The six-line example above is 688 tokens in and 330 out, about 8 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Questions:&lt;/strong&gt; answers take about 1.5 to 3 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline:&lt;/strong&gt; on the live site with a fresh browser profile, I downloaded the model, created a routine, disconnected the network and reloaded. The app loaded from its own cache (0.9 MB), Gemma loaded from the browser cache, and a new routine was created in about 12 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First paint:&lt;/strong&gt; under one second on the live site at desktop and phone sizes. The CSS is inlined into the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated checks:&lt;/strong&gt; a headless test opens the live site at desktop and phone sizes, covers the large view, the share link round trip and a browser without WebGPU, and fails on any console error. All 28 checks pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What did not work
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 3 1B was my first choice.&lt;/strong&gt; At 537 MB it is a much smaller download. In the WebLLM build I used, its sliding-window attention configuration either failed to load or, once forced, produced unusable output. Without the grammar it produced no valid routines in 3 test cases. With the grammar it produced valid JSON for all 5 cases, but none of the 11 times were correct. I switched to Gemma 2 2B, at the cost of a download nearly three times larger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON schema mode looped in early tests.&lt;/strong&gt; With a plain JSON schema, the small models sometimes generated whitespace until they reached the token limit. The custom grammar with fixed separators avoids this. In the final evaluation, schema mode was also valid for all 20 cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Times were the hardest part.&lt;/strong&gt; Asking for hours and minutes directly produced many wrong times, often midnight. The anchor list plus the quote check brought it to 47 of 54.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model still misses tasks.&lt;/strong&gt; In the example routine above, Gemma leaves out the walk. I kept that in the demo deliberately, because it shows why the editable preview matters. During the offline test it also produced a dose of "500 mg" for a Vitamin D tablet whose dose I had not written, which is why the dose rule exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A bug in my own regular expressions.&lt;/strong&gt; I generated part of the quote checker with a script that turned every &lt;code&gt;\b&lt;/code&gt; (word boundary) into a literal backspace character. Nothing matched, and accuracy dropped until I found it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing a 1.4 GB model is slow.&lt;/strong&gt; Each test with a fresh browser profile includes an eight-minute download. A force-closed Chrome once left the model cache half-written, and the app then waited indefinitely while checking the GPU. That check now times out after 3 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not every device can run the model.&lt;/strong&gt; It needs WebGPU with 16-bit float support and a few GB of GPU memory, and I only verified it on my own GPU. Phones get the full app with the demo routine and can open a routine sent from a computer. When the model cannot run, the first screen says so and suggests that option.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;A daily routine, especially one with medicines in it, is private. It shows what a person is treated for, how often, and how consistently they keep up. My grandmother should not have to send that to a company's server to get a reminder or an answer to "Did I take the evening one?"&lt;/p&gt;

&lt;p&gt;Because Gemma's weights are open, the whole model can run inside the page. The routine, the completed tasks and the questions stay in one browser on one device. There is no API key to leak, no usage bill, and no dependency on a service that could change its terms or shut down. Once loaded, it keeps working without a network connection.&lt;/p&gt;

&lt;p&gt;Open weights also meant I could control how the model behaves, not only what I ask it. WebLLM let me constrain decoding with my own grammar, which raised JSON validity from 14 out of 20 to 20 out of 20. I could switch from Gemma 3 1B to Gemma 2 2B with a one-line change when the first did not work, and I could run the 20-case evaluation repeatedly on my own GPU at no cost. With a closed API, I would have been limited to adjusting the prompt.&lt;/p&gt;

&lt;p&gt;The trade-off is a 1.4 GB first download and a WebGPU requirement. For a health routine I think that is the right trade, and smaller open models will reduce it further.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemma&lt;/strong&gt;: Gemma 2 2B runs entirely in the browser through WebLLM and WebGPU and is the core of the app. It turns a plain-language description into the routine and answers questions using only that routine.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>I built a 163-page paycheck calculator that costs $0 a month to run</title>
      <dc:creator>StarKnight</dc:creator>
      <pubDate>Thu, 02 Jul 2026 07:39:03 +0000</pubDate>
      <link>https://dev.to/starknight/i-built-a-163-page-paycheck-calculator-that-costs-0-a-month-to-run-355d</link>
      <guid>https://dev.to/starknight/i-built-a-163-page-paycheck-calculator-that-costs-0-a-month-to-run-355d</guid>
      <description>

&lt;p&gt;Earlier this month I shipped &lt;a href="https://paybrackets.com" rel="noopener noreferrer"&gt;PayBrackets&lt;/a&gt;, a free calculator that tells Americans what they actually keep from their paycheck after federal tax, state tax, and Social Security take their cut. It covers all 50 states plus D.C., it has no database, no backend, no APIs, and my total running cost is the domain.&lt;/p&gt;

&lt;p&gt;I want to walk through how it's built, because the interesting part isn't the code. It's how much you can get out of very little code if the data underneath is right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a tax calculator, of all things
&lt;/h2&gt;

&lt;p&gt;Every month, millions of people type things like "75000 after taxes texas" or "25 an hour is how much a year" into Google. I looked at what they find. Mostly it's ten-year-old calculators buried under popups, or freshly generated AI pages where the numbers are confidently wrong.&lt;/p&gt;

&lt;p&gt;That gap felt workable for one person. Salaries and taxes are pure math. The rules are public. Nothing about it needs a server, an account system, or content writers. It needs someone to sit down, get the rules right once, and present them clearly.&lt;/p&gt;

&lt;p&gt;So the whole plan was: encode the 2026 tax rules properly, build one calculation engine, and let that engine generate every page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unglamorous part was the whole ballgame
&lt;/h2&gt;

&lt;p&gt;There's no tax API behind the site. The source of truth is a set of TypeScript files I wrote by hand, encoding the 2026 federal brackets from the IRS, the Social Security wage base from the SSA, and every state's brackets, deductions, exemptions, and phaseouts from Tax Foundation data. It looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;California&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;california&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;graduated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;topRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.133&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;brackets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;single&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="nx"&gt;_079&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="c1"&gt;// ...8 more brackets up to 13.3% at $1M&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;standardDeduction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;single&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_540&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;married&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="nx"&gt;_080&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;payrollTaxes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CA SDI&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.013&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt; &lt;span class="c1"&gt;// uncapped since 2024&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Doing this for 51 jurisdictions took me a full day, and it was the most valuable day of the project. States are genuinely weird. Maryland's counties add their own income tax on top of the state's. California's disability insurance tax lost its wage cap in 2024, so it applies to every dollar. Connecticut quietly claws back the benefit of its lower brackets once you earn enough. Nine states don't tax wages at all.&lt;/p&gt;

&lt;p&gt;If I had modeled states as "one tax rate" like most quick calculators do, every single page would be wrong in a different way. And where I chose not to model something, like those Maryland county rates, the page says so in a visible note instead of pretending.&lt;/p&gt;

&lt;h2&gt;
  
  
  One engine, checked against reality
&lt;/h2&gt;

&lt;p&gt;The heart of the site is embarrassingly small. Progressive taxes are just a walk over brackets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;marginalTax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taxable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;brackets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Bracket&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;tax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;brackets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;brackets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taxable&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ceiling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;brackets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;brackets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;threshold&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;tax&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taxable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ceiling&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tax&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Around that sits the composition: federal income tax after the standard deduction, Social Security up to the wage cap, Medicare plus the surtax on high earners, optional 401(k) deferrals, then the state layer with all its exceptions.&lt;/p&gt;

&lt;p&gt;Before launch I wrote a small check script with hand-verified salaries. It runs the engine against numbers I worked out independently and fails loudly if anything drifts. A typo in a bracket threshold is invisible in a code review. In a paycheck for a specific salary, it screams.&lt;/p&gt;

&lt;h2&gt;
  
  
  163 pages from one route
&lt;/h2&gt;

&lt;p&gt;The site is Next.js, fully static. All the calculator pages hang off a single dynamic route, and the slugs are literally the questions people search:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;allProgrammaticSlugs&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;STATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-paycheck-calculator`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;      &lt;span class="c1"&gt;// 51 pages&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;HOURLY_RATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-an-hour-is-how-much-a-year`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// 61 pages&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;SALARY_AMOUNTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-after-taxes`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;           &lt;span class="c1"&gt;// 35 pages&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At build time, each page computes its own numbers. The "$50,000 after taxes" page shows the exact take-home in all 51 jurisdictions. The Texas page shows what no state income tax is actually worth against California at the same salary. No two pages share their content, which is the difference between programmatic pages that help people and the thin templated stuff Google buries.&lt;/p&gt;

&lt;p&gt;The build produces 163 static pages in about half a minute, and the interactive calculator runs the same engine in the browser. Your salary never leaves the page, which was important to me. Nobody should upload their income to a stranger's server to learn what they already earn.&lt;/p&gt;

&lt;h2&gt;
  
  
  The readers that aren't people
&lt;/h2&gt;

&lt;p&gt;A growing share of these questions never reach Google. People ask ChatGPT or Perplexity instead. So the site treats AI crawlers as an audience of their own.&lt;/p&gt;

&lt;p&gt;Every page opens with the question stated plainly and a two-sentence answer with real numbers, before any explanation. There's FAQ markup throughout, a methodology page naming every data source, and a robots.txt that explicitly welcomes the OpenAI, Anthropic, and Perplexity crawlers rather than blocking them.&lt;/p&gt;

&lt;p&gt;The bet is simple. Assistants quote pages that are structured like clean answers with checkable numbers. If an assistant answers "what's $70k after taxes in Florida" by citing my site, that citation is the new referral traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The launch video is also code
&lt;/h2&gt;

&lt;p&gt;One more thing that amused me. I wanted a proper product video for the launch, the smooth animated kind startups pay studios for. I don't own After Effects, so I built the video in Remotion instead. Every scene is a React component, the dollar figures in the animation come from the same tax engine as the site, and the final MP4 renders from the command line.&lt;/p&gt;

&lt;p&gt;There's something satisfying about a project where even the marketing assets compile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;The site has been live for a few weeks. It's indexing, impressions are climbing, and the first ad review is in progress. I deliberately have no revenue story to tell yet, and I'd rather say that than inflate it. Once there are real numbers, good or bad, I'll write the follow-up.&lt;/p&gt;

&lt;p&gt;The site is &lt;a href="https://paybrackets.com" rel="noopener noreferrer"&gt;paybrackets.com&lt;/a&gt; if you want to poke at it. Happy to go deeper on the tax data modeling, the static generation, or the video in the comments.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>seo</category>
      <category>webdev</category>
      <category>sideprojects</category>
    </item>
  </channel>
</rss>
