<?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: ashg2099</title>
    <description>The latest articles on DEV Community by ashg2099 (@ashg2099).</description>
    <link>https://dev.to/ashg2099</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%2F3955365%2Ff85a7d7c-8c96-4b9d-b6e7-06674e471f89.jpeg</url>
      <title>DEV Community: ashg2099</title>
      <link>https://dev.to/ashg2099</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ashg2099"/>
    <language>en</language>
    <item>
      <title>A test you can't run is a test you don't have</title>
      <dc:creator>ashg2099</dc:creator>
      <pubDate>Thu, 17 Sep 2026 15:12:57 +0000</pubDate>
      <link>https://dev.to/ashg2099/a-test-you-cant-run-is-a-test-you-dont-have-15dk</link>
      <guid>https://dev.to/ashg2099/a-test-you-cant-run-is-a-test-you-dont-have-15dk</guid>
      <description>&lt;p&gt;In my last post, I wrote about &lt;a href="https://dev.to/ashg2099/56-fault-injection-tests-passed-the-one-that-injected-nothing-failed-ihj"&gt;the negative control that failed&lt;/a&gt;—the test in my fault-injection suite that injects nothing and expects silence. 56 seeded faults passed. The one that seeded nothing screamed.&lt;/p&gt;

&lt;p&gt;This post is about the sequel to that, which turned out to be the same lesson wearing different clothes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The choice
&lt;/h2&gt;

&lt;p&gt;Upstrace started as a DuckDB-only tool—time to add a second warehouse.&lt;/p&gt;

&lt;p&gt;Snowflake was the obvious answer. It's what's on the job ads, it's what people ask about, and it would have made a better headline than what I actually did. I could have signed up for a trial, made it work, written "supports Snowflake" in the README, and shipped.&lt;/p&gt;

&lt;p&gt;Here's what happens on day 31.&lt;/p&gt;

&lt;p&gt;The trial expires. From then on, every commit I push can break the Snowflake path, and I have no way of finding out. The README still says it works. The first person to discover otherwise is a stranger opening an issue.&lt;/p&gt;

&lt;p&gt;That isn't a feature. It's a claim with an expiry date.&lt;/p&gt;

&lt;p&gt;Postgres runs in GitHub Actions as a service container. Free. On every push. Which means the claim checks itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CI actually asserts
&lt;/h2&gt;

&lt;p&gt;The interesting part isn't that it runs on both. It's what it checks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Baseline&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;upstrace profile&lt;/span&gt;
    &lt;span class="s"&gt;upstrace profile&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;No &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="s"&gt; positives on unchanged data&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;upstrace drift --fail-on warning&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Inject a unit change&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;psql -f fault.sql&lt;/span&gt;
    &lt;span class="s"&gt;dbt run --profiles-dir&lt;/span&gt;
    &lt;span class="s"&gt;upstrace profile&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;The fault is detected&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;if upstrace drift --fail-on critical; then&lt;/span&gt;
      &lt;span class="s"&gt;echo "::error::A unit change was injected and no critical signal was raised."&lt;/span&gt;
      &lt;span class="s"&gt;exit 1&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;The root cause is the source, not a downstream model&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;upstrace rca | tee rca.txt&lt;/span&gt;
    &lt;span class="s"&gt;grep -q "root: orders (source)" rca.txt || exit 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first two are the negative control from the last post, promoted from a benchmark scenario to a CI gate. Profile twice over data that did not change, and fail the build if anything at all is raised.&lt;/p&gt;

&lt;p&gt;The last one is the check I'd argue hardest for.&lt;/p&gt;

&lt;p&gt;When the fault lands, Upstrace raises &lt;strong&gt;22 drift signals&lt;/strong&gt;. Twenty-one of them are downstream: staging inherited the change, the fact table inherited it, the daily aggregate inherited it. Exactly one names the source.&lt;/p&gt;

&lt;p&gt;So if my lineage rule broke tomorrow — if the root-cause logic started blaming the aggregate instead of the source — the "did it detect something" check would still pass. Twenty-two signals would still appear. The build would still be green, and I'd learn nothing.&lt;/p&gt;

&lt;p&gt;Only asserting the root &lt;strong&gt;by name&lt;/strong&gt; fails when the thing I actually built stops working.&lt;/p&gt;

&lt;p&gt;Which is the same asymmetry as the negative control. "It produced output" is not the property I care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The finding reproduced itself
&lt;/h2&gt;

&lt;p&gt;The Postgres demo is a completely different schema. Orders and daily revenue aggregates, not taxi trips. Different column types, different table sizes, different SQL engine. The fault is a 1.6× scale applied to the last 30 days out of 90.&lt;/p&gt;

&lt;p&gt;severity model column metric baseline current change days&lt;br&gt;
critical orders amount mean_value 35.2144 56.3430 60.0% 30&lt;br&gt;
high orders amount mean_value 35.1582 42.1915 20.0% -&lt;/p&gt;

&lt;p&gt;Same column. Same run. Two rows.&lt;/p&gt;

&lt;p&gt;Measured per day, the mean moved 60%. Measured across the whole table, it moved 20%.&lt;/p&gt;

&lt;p&gt;The fault touches a third of the history, so a whole-table average dilutes it by roughly two thirds. This is the reason Upstrace profiles per partition rather than per table — on the original dataset, making that change took detection from 44 of 56 scenarios to 55.&lt;/p&gt;

&lt;p&gt;I had quietly assumed some of that was an artefact of the taxi data. It isn't. It's arithmetic, and every whole-table metric has it. Running on a second engine with a second schema is what turned an assumption into something I know.&lt;/p&gt;

&lt;p&gt;That's a side effect of portability I didn't plan for: a second implementation is a second opinion on your own results.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part that saved me a week
&lt;/h2&gt;

&lt;p&gt;One implementation note, because I nearly got this wrong.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;psycopg&lt;/code&gt; and DuckDB's Python API do not agree. &lt;code&gt;execute()&lt;/code&gt; returns a cursor rather than something you can fetch from directly. Placeholders are &lt;code&gt;%s&lt;/code&gt; rather than &lt;code&gt;?&lt;/code&gt;. My first instinct was to open the codebase and fix the hundred-odd call sites.&lt;/p&gt;

&lt;p&gt;Instead I wrapped the driver:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_PgConnection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_qmark_to_pyformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__getattr__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;About thirty lines, including the &lt;code&gt;?&lt;/code&gt; → &lt;code&gt;%s&lt;/code&gt; translation. Every existing call site kept working unchanged.&lt;/p&gt;

&lt;p&gt;The alternative would have meant editing every query in the codebase in order to add a second warehouse — and destabilising the DuckDB path, which was the one thing already known to work.&lt;/p&gt;

&lt;p&gt;Adapting the driver to the codebase was much cheaper than adapting the codebase to the driver.&lt;/p&gt;

&lt;p&gt;Everything else that differs between the two engines — &lt;code&gt;DESCRIBE&lt;/code&gt; vs &lt;code&gt;information_schema.columns&lt;/code&gt;, how floats round, bulk insert, type names — now lives in a single file behind a seven-method interface. A third warehouse means writing those seven methods and touching nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I didn't do
&lt;/h2&gt;

&lt;p&gt;Two things are still DuckDB-only, and I'd rather say so than let someone find out.&lt;/p&gt;

&lt;p&gt;The dashboard reads DuckDB directly instead of going through the dialect layer, so on a Postgres project it returns a 503 explaining that. &lt;code&gt;drift&lt;/code&gt;, &lt;code&gt;rca&lt;/code&gt; and &lt;code&gt;report&lt;/code&gt; all work on both — the HTML report gives you the same incident view without a server.&lt;/p&gt;

&lt;p&gt;And the fault-injection benchmark itself is DuckDB-only. It's built on &lt;code&gt;read_parquet&lt;/code&gt; and reservoir sampling. It's a test fixture, not part of the engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trying it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"upstrace[postgres]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;dialect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
&lt;span class="na"&gt;warehouse&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql://user:password@host:5432/database&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every command behaves identically after that. There's a complete worked example — dbt project, deterministic 90-day seed, and a &lt;code&gt;fault.sql&lt;/code&gt; you can inject to watch root-cause analysis produce a real incident — in &lt;a href="https://github.com/ashg2099/upstrace/tree/main/postgres-demo" rel="noopener noreferrer"&gt;&lt;code&gt;postgres-demo/&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/ashg2099/upstrace" rel="noopener noreferrer"&gt;github.com/ashg2099/upstrace&lt;/a&gt;&lt;/p&gt;




</description>
      <category>dataengineering</category>
      <category>postgres</category>
      <category>testing</category>
      <category>python</category>
    </item>
    <item>
      <title>56 fault-injection tests passed. The one that injected nothing failed.</title>
      <dc:creator>ashg2099</dc:creator>
      <pubDate>Sun, 13 Sep 2026 04:05:27 +0000</pubDate>
      <link>https://dev.to/ashg2099/56-fault-injection-tests-passed-the-one-that-injected-nothing-failed-ihj</link>
      <guid>https://dev.to/ashg2099/56-fault-injection-tests-passed-the-one-that-injected-nothing-failed-ihj</guid>
      <description>&lt;p&gt;I was building a tool that detects when data quietly changes meaning — a vendor switching units, a source dropping a field, an undocumented enum appearing. The kind of failure where every test passes and every job is green.&lt;/p&gt;

&lt;p&gt;Claims about detection are cheap, so I built a benchmark. 56 seeded defects across fault type, magnitude, time window and pipeline layer. Each one has a known root cause. The tool profiles the pipeline, detects drift, walks the lineage graph, and names the node where the problem started. Score it against the node I actually broke.&lt;/p&gt;

&lt;p&gt;It scored 55/56. I was pleased with myself for about a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I added the controls
&lt;/h2&gt;

&lt;p&gt;A benchmark made only of faults can only tell you one thing: does the detector fire? It cannot tell you whether it fires too much. A detector that screams on every run scores 100% on that benchmark and is completely useless in production, because nobody reads an alert channel that cries wolf.&lt;/p&gt;

&lt;p&gt;So I added four negative controls. Scenarios where the correct answer is silence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;control-null&lt;/code&gt; — rebuild and reprofile with no change at all&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;control-subthreshold-tip&lt;/code&gt; — tips up 3%, under the 5% threshold&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;control-subthreshold-extra&lt;/code&gt; — extras up 2%&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;control-subthreshold-tip-near-limit&lt;/code&gt; — tips up 4.5%, just under the line&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A control passes only if it raises no high or critical signal.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;control-null&lt;/code&gt; failed. Three high-severity signals on a run where nothing had changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually happening
&lt;/h2&gt;

&lt;p&gt;Two separate causes, compounding.&lt;/p&gt;

&lt;p&gt;HyperLogLog. I was computing distinct counts with approx_count_distinct. It is fast, and for dashboards the approximation is fine. But HLL is a probabilistic sketch, and its estimate is not stable across runs — I measured variation up to 30% between two identical runs on the same data. My distinct-count threshold was 10%. The estimator's own noise was three times louder than the signal I was trying to detect.&lt;/p&gt;

&lt;p&gt;Floating point. The second cause is the one I would not have guessed. DuckDB parallelises aggregates, so sum() and avg() accumulate in a non-deterministic order across threads. Floating-point addition is not associative: (a + b) + c and a + (b + c) can differ in the last bits. Two mathematically identical groups could therefore produce values that differed at the fifteenth decimal place — and that was enough to change which values counted as distinct.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Count exactly, and round floats before counting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;python&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_distinct_expr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;_is_float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_type&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count(distinct round(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, 6))&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count(distinct &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same reasoning later applied to min and max, which I was recording as text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;python&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_bound_expr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;_is_float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_type&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;round(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;), 6)::varchar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)::varchar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without that second one, a float min of 22575.66999999999 in one run and 22575.669999999995 in the next reads as a changed value. It is not a changed value. It is the same number, added up in a different order.&lt;/p&gt;

&lt;p&gt;Two identical runs now produce zero signals.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually took away
&lt;/h2&gt;

&lt;p&gt;The obvious lesson is "use exact counts". That is not the interesting one.&lt;/p&gt;

&lt;p&gt;The interesting one is that determinism is a precondition for detection, not a nice-to-have. A drift detector compares two measurements and calls the difference a signal. If your measurement process has its own variance, you have built a random number generator with a threshold on it. Every false positive it produces is indistinguishable from a real finding — and you will only find out after someone has stopped trusting the alerts.&lt;/p&gt;

&lt;p&gt;And the second: fifty-six tests that expected something to happen never caught this. One test that expected nothing to happen caught it immediately.&lt;/p&gt;

&lt;p&gt;That asymmetry generalises well beyond data tools. Most test suites are built entirely out of "given this input, assert this output". Very few contain "given no change, assert no output". The second kind is cheap to write and catches a category of bug the first kind is structurally blind to — anything where your system is noisy rather than wrong.&lt;/p&gt;

&lt;p&gt;If you are building anything that detects, classifies, or alerts, write the test that expects silence. It will not pass on the first try.&lt;/p&gt;

&lt;p&gt;The tool is &lt;a href="https://github.com/ashg2099/upstrace" rel="noopener noreferrer"&gt;Upstrace&lt;/a&gt; — column-level drift detection and lineage-based root-cause analysis for dbt projects. &lt;code&gt;pip install upstrace&lt;/code&gt;; MIT-licensed. The full benchmark, including the one scenario that still fails, is in the repo.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>python</category>
      <category>testing</category>
      <category>dbt</category>
    </item>
    <item>
      <title>Why I'm Betting on CrewAI for Multi-Agent Orchestration (And Where It Falls Short)</title>
      <dc:creator>ashg2099</dc:creator>
      <pubDate>Wed, 08 Jul 2026 02:02:36 +0000</pubDate>
      <link>https://dev.to/ashg2099/why-im-betting-on-crewai-for-multi-agent-orchestration-and-where-it-falls-short-23oe</link>
      <guid>https://dev.to/ashg2099/why-im-betting-on-crewai-for-multi-agent-orchestration-and-where-it-falls-short-23oe</guid>
      <description>&lt;p&gt;I've been deep-diving into CrewAI lately, and here's my honest technical breakdown.&lt;/p&gt;

&lt;p&gt;What is CrewAI?&lt;br&gt;
It's a multi-agent orchestration framework where you define a crew of AI agents, each with a role, goal, backstory, and tools, that collaborate to solve complex tasks sequentially or in parallel.&lt;br&gt;
Think: a team of specialists instead of one generalist doing everything.&lt;/p&gt;

&lt;p&gt;Single LLM vs CrewAI, where it breaks down:&lt;br&gt;
A single LLM call has no memory across steps, no specialization, and collapses under long, complex workflows.&lt;br&gt;
CrewAI solves this by:&lt;br&gt;
✅ Decomposing problems into focused subtasks&lt;br&gt;
✅ Letting agents maintain context within their scope&lt;br&gt;
✅ Passing outputs between agents as structured inputs&lt;br&gt;
✅ Supporting tool use per agent (search, code execution, file I/O)&lt;/p&gt;

&lt;p&gt;How it compares to other frameworks:&lt;br&gt;
🔵 LangChain — great for chains and RAG pipelines, but not built for agent collaboration. CrewAI is purpose-built for multi-agent coordination.&lt;br&gt;
🟠 LangGraph — more low-level, gives you full control over state machines and conditional flows. Better for complex branching logic. CrewAI trades that flexibility for simplicity and speed of development.&lt;br&gt;
🟡 AutoGen (Microsoft) — conversation-based multi-agent, agents talk to each other in chat loops. CrewAI is more structured, roles and tasks are explicit, not emergent from conversation.&lt;/p&gt;

&lt;p&gt;The verdict: CrewAI sits in the sweet spot, higher-level than LangGraph, more structured than AutoGen, more agent-native than LangChain.&lt;/p&gt;

&lt;p&gt;Where CrewAI genuinely shines:&lt;br&gt;
🔹 Research pipelines (search → summarize → report)&lt;br&gt;
🔹 Automated data analysis workflows&lt;br&gt;
🔹 Content generation with review/editing loops&lt;br&gt;
🔹 Customer support triage with routing logic&lt;/p&gt;

&lt;p&gt;Real limitations:&lt;br&gt;
⚠️ Token costs compound fast — every agent call is an LLM call&lt;br&gt;
⚠️ Debugging is hard — tracing which agent caused a failure isn't always obvious&lt;br&gt;
⚠️ Sequential crews can be slow — parallelism requires careful design&lt;br&gt;
⚠️ Agent "hallucination" compounds — errors in one agent propagate downstream&lt;br&gt;
⚠️ Still maturing — production reliability is improving, but not battle-tested at scale like LangChain&lt;/p&gt;

&lt;p&gt;For prototyping agentic workflows fast, CrewAI is hard to beat.&lt;br&gt;
For production-grade, fine-grained control, reach for LangGraph.&lt;br&gt;
The best engineers I've seen aren't loyal to one framework. They know when to use each one. &lt;br&gt;
I'm still learning, but this is the mental map I'm building.&lt;/p&gt;

&lt;h1&gt;
  
  
  CrewAI #AIAgents #MultiAgent #DataScience #AIEngineering #LLM #LangChain #LangGraph
&lt;/h1&gt;

</description>
      <category>crewai</category>
      <category>machinelearning</category>
      <category>ai</category>
      <category>llm</category>
    </item>
    <item>
      <title>How I improved my fact-checker from F1 0.655 0.813 — what actually changed</title>
      <dc:creator>ashg2099</dc:creator>
      <pubDate>Sun, 21 Jun 2026 01:31:47 +0000</pubDate>
      <link>https://dev.to/ashg2099/how-i-improved-my-fact-checker-from-f1-0655-0813-what-actually-changed-455a</link>
      <guid>https://dev.to/ashg2099/how-i-improved-my-fact-checker-from-f1-0655-0813-what-actually-changed-455a</guid>
      <description>&lt;p&gt;I built a multilingual fact-checker using XLM-RoBERTa fine-tuned on the FEVER dataset. The first version hit F1 0.655. Not bad, but it kept misfiring on obvious real-world claims. Earth being the third planet from the Sun returned FALSE at 76% confidence. Something was fundamentally wrong.&lt;br&gt;
A commenter identified the issue immediately: I was training the model on claims alone, with no evidence. FEVER is not a claim classification task. It's a Natural Language Inference task — the model is supposed to verify a claim against evidence, not guess from the claim text alone. I had been training it wrong from the start.&lt;/p&gt;

&lt;h3&gt;
  
  
  What FEVER actually is:
&lt;/h3&gt;

&lt;p&gt;FEVER (Fact Extraction and VERification) contains 228,000+ Wikipedia claim-evidence pairs. Each claim is annotated as SUPPORTS, REFUTES, or NOT ENOUGH INFO based on retrieved Wikipedia sentences. The whole point is that the model sees both the claim and the evidence together and decides if the evidence supports or contradicts the claim.&lt;/p&gt;

&lt;p&gt;Training on claims alone strips out all that signal. The model has nothing to reason about, it just memorizes surface patterns in the claim text.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 1 — Retraining with evidence
&lt;/h3&gt;

&lt;p&gt;The fix was straightforward: concatenate the claim with its gold evidence sentences before passing to the model. XLM-RoBERTa uses  as a sentence separator, so the format becomes [claim]  [evidence]. Fine-tuned for one epoch on the full FEVER training set, starting from the existing checkpoint. F1 jumped from 0.655 to 0.813.&lt;br&gt;
The improvement wasn't from a better architecture, more data, or longer training. It was purely from feeding the model what it was designed to receive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 2 — Making it work in the real world
&lt;/h3&gt;

&lt;p&gt;The retrained model was great on FEVER benchmarks but useless for real-world claims, because real-world claims don't come with pre-labeled Wikipedia evidence attached. You need to retrieve the evidence yourself.&lt;br&gt;
For this, I used BGE (BAAI/bge-base-en-v1.5), a retrieval-optimized embedding model from Beijing Academy of AI. The approach is called Reverse HyDE — instead of generating a hypothetical document for the query, you embed the claim as a retrieval query and find the most semantically similar evidence passages. The FEVER passages are indexed in a FAISS flat inner product index, which gives cosine similarity over normalized vectors.&lt;br&gt;
At inference time: embed the claim, retrieve the top 3 most relevant FEVER passages, concatenate them with the claim, and pass to XLM-RoBERTa. The whole pipeline takes under a second.&lt;/p&gt;

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

&lt;p&gt;The combined system — retrieval augmented XLM-RoBERTa — handled real-world claims correctly where the v1 model failed. Claims about historical facts, scientific facts, and geography all returned sensible verdicts with high confidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why XLM-RoBERTa specifically
&lt;/h3&gt;

&lt;p&gt;XLM-RoBERTa is pretrained on CommonCrawl data across 100 languages. This means the fine-tuned model inherits multilingual capability without any additional training. You can submit claims in Hindi, Spanish, Tamil, Arabic, or Chinese and the model understands them. The retrieved evidence is always English (since FEVER is English), but XLM-RoBERTa handles cross-lingual NLI reasonably well — the claim and evidence don't need to be in the same language.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key lesson
&lt;/h3&gt;

&lt;p&gt;The architecture did not change. The dataset did not change. The training duration did not change. What changed was understanding what the task actually requires and formatting the input accordingly. A 24% F1 improvement from fixing the input format is a good reminder to read the dataset paper before training.&lt;/p&gt;

&lt;h3&gt;
  
  
  🤗 Model:
&lt;/h3&gt;

&lt;p&gt;huggingface.co/ashg2099/xlm-roberta-factchecker&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>nlp</category>
      <category>huggingface</category>
      <category>python</category>
    </item>
    <item>
      <title>Misinformation doesn't speak one language. Our tools do.</title>
      <dc:creator>ashg2099</dc:creator>
      <pubDate>Tue, 02 Jun 2026 02:50:20 +0000</pubDate>
      <link>https://dev.to/ashg2099/misinformation-doesnt-speak-one-language-our-tools-do-3ifg</link>
      <guid>https://dev.to/ashg2099/misinformation-doesnt-speak-one-language-our-tools-do-3ifg</guid>
      <description>&lt;p&gt;In 2024, the Oxford Internet Institute studied misinformation spread across 81 countries.&lt;br&gt;
&lt;strong&gt;Their finding:&lt;/strong&gt; the most dangerous misinformation wasn't in English. It was in languages that English-language fact-checking tools couldn't read. WhatsApp forwards in Hindi. Facebook posts in Swahili. Telegram chains in Arabic. Viral claims in Tamil that never get fact-checked because the tools don't exist.&lt;/p&gt;

&lt;p&gt;Here's the uncomfortable truth about the current state of NLP fact-checking:&lt;br&gt;
&lt;strong&gt;95% of fact-checking models are English-only.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;LIAR dataset&lt;/strong&gt; — the most cited benchmark in claim verification research — is entirely in English. &lt;strong&gt;FEVER&lt;/strong&gt;, the gold standard for fact verification, is entirely in English. Most production fact-checking APIs? English only.&lt;/p&gt;

&lt;p&gt;Meanwhile, India alone has 22 official languages and 500 million WhatsApp users. A false claim about a vaccine, an election, a riot — spreads in minutes in a language no existing model can verify.&lt;br&gt;
This is not a model problem. It's an architecture problem.&lt;br&gt;
Cross-lingual transfer learning has existed since 2019 — XLM-RoBERTa was pre-trained on 100 languages simultaneously. The capability is there. The application isn't.&lt;/p&gt;

&lt;p&gt;Datasets like MM-COVID, CLEF CheckThat! 2023, and IndicGLUE exists precisely for this — multilingual misinformation benchmarks that almost nobody in the open-source community has seriously combined and trained on.&lt;/p&gt;

&lt;p&gt;The gap between what's possible and what's been built is embarrassingly wide. Someone should close it.&lt;/p&gt;

&lt;p&gt;This is exactly why I built Sift 🔍&lt;br&gt;
Sift is an open-source multi-agent fact-checking pipeline — 5 agents, each playing a distinct role. But Sift today only speaks English. 🌐&lt;br&gt;
And that's the problem I'm solving next. Someone should close this gap. I intend to. 🚀&lt;/p&gt;

&lt;p&gt;🔗 Full technical breakdown of how Sift works: [(&lt;a href="https://dev.to/ashg2099/i-built-an-open-source-multi-agent-fact-checker-heres-how-it-works-5eah)"&gt;https://dev.to/ashg2099/i-built-an-open-source-multi-agent-fact-checker-heres-how-it-works-5eah)&lt;/a&gt;]&lt;/p&gt;

</description>
      <category>nlp</category>
      <category>machinelearning</category>
      <category>opentowork</category>
      <category>factchecking</category>
    </item>
    <item>
      <title>Data Scientist &amp; AI Engineer — Open to Full-Time Opportunities</title>
      <dc:creator>ashg2099</dc:creator>
      <pubDate>Fri, 29 May 2026 00:20:39 +0000</pubDate>
      <link>https://dev.to/ashg2099/data-scientist-ai-engineer-open-to-full-time-opportunities-2248</link>
      <guid>https://dev.to/ashg2099/data-scientist-ai-engineer-open-to-full-time-opportunities-2248</guid>
      <description>&lt;p&gt;Hey Dev.to the community,&lt;/p&gt;

&lt;p&gt;I'm Ashwin Gururaj — a Data Scientist &amp;amp; AI Engineer based in Melbourne, Australia, currently open to full-time, contract, and internship opportunities.&lt;br&gt;
I specialise in building production-grade AI systems — not just notebooks and demos, but end-to-end pipelines that actually run in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I work with:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Python · LangChain · LangGraph · FastAPI · RAG pipelines · pgvector · Multi-agent systems · LLMs · Groq · HuggingFace · Pydantic · Docker · Celery · Redis · PostgreSQL · Data Science · SQL · Pandas · Scikit-learn&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I've built recently:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sift — an open-source multi-agent fact-checking pipeline. Takes any text, extracts every factual claim, retrieves grounded evidence via HyDE RAG + live web search, and returns auditable verdicts with cited sources. Built with LangGraph, pgvector, FastAPI, and Docker.&lt;br&gt;
→ &lt;strong&gt;&lt;a href="https://github.com/ashg2099/Sift" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open to:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full-time Data Scientist / AI Engineer / ML Engineer roles&lt;/li&gt;
&lt;li&gt;Remote or Melbourne-based&lt;/li&gt;
&lt;li&gt;Companies building serious AI products&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're hiring or know someone who is — I'd genuinely appreciate a connection.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/ashg2099" rel="noopener noreferrer"&gt;https://github.com/ashg2099&lt;/a&gt;&lt;br&gt;
LinkedIn: &lt;a href="https://www.linkedin.com/in/ashwin-gururaj-93943816a/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/ashwin-gururaj-93943816a/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thanks!&lt;/p&gt;

</description>
      <category>career</category>
      <category>hiring</category>
      <category>machinelearning</category>
      <category>python</category>
    </item>
    <item>
      <title>I Built an Open-Source Multi-Agent Fact-Checker — Here's How It Works</title>
      <dc:creator>ashg2099</dc:creator>
      <pubDate>Thu, 28 May 2026 00:25:32 +0000</pubDate>
      <link>https://dev.to/ashg2099/i-built-an-open-source-multi-agent-fact-checker-heres-how-it-works-5eah</link>
      <guid>https://dev.to/ashg2099/i-built-an-open-source-multi-agent-fact-checker-heres-how-it-works-5eah</guid>
      <description>&lt;h2&gt;
  
  
  Problem Statement
&lt;/h2&gt;

&lt;p&gt;We have a misinformation problem. But more specifically, we have a speed problem.&lt;br&gt;
A journalist spots a suspicious claim. They search for sources. Cross-reference databases. Call experts. Write a verdict. Get it edited. Publish, maybe 6 hours later. Maybe 3 days later.&lt;br&gt;
Meanwhile, the original claim has been screenshot, reposted, quoted in newsletters, and cited in arguments across five platforms.&lt;br&gt;
I wanted to build something that closed that gap. Not a chatbot that guesses. A proper pipeline, one that retrieves real evidence, reasons from it, and tells you why it reached a verdict.&lt;br&gt;
That's what Sift is.&lt;/p&gt;
&lt;h2&gt;
  
  
  What is Sift?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Sift (Source Inspection &amp;amp; Fact-checking Tool)&lt;/strong&gt; is an open-source multi-agent AI pipeline that takes any text, extracts every factual claim, retrieves grounded evidence, and returns auditable verdicts — TRUE, FALSE, or UNCERTAIN, with cited sources and full reasoning chains.&lt;br&gt;
Paste a news article. A politician's speech. A viral statistic. A WhatsApp forward. Sift breaks it into individual claims and fact-checks each one independently.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Multi-Agent?
&lt;/h2&gt;

&lt;p&gt;The naive approach is to ask an LLM: "Is this claim true?"&lt;br&gt;
The problem: LLMs hallucinate. They have knowledge cutoffs. They're confidently wrong in ways that are hard to detect. And critically, they don't show their work.&lt;br&gt;
A single LLM call can't reliably handle the full pipeline of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Extracting structured claims from noisy text&lt;/li&gt;
&lt;li&gt;Retrieving dated, traceable evidence from live sources&lt;/li&gt;
&lt;li&gt;Reasoning across conflicting evidence without confabulating&lt;/li&gt;
&lt;li&gt;Adversarially reviewing its own conclusions for overconfidence&lt;/li&gt;
&lt;li&gt;Finding corrections when something is wrong&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these is a distinct task that benefits from its own prompt, its own tools, and its own failure modes. That's why I built five separate agents, orchestrated with LangGraph.&lt;/p&gt;
&lt;h2&gt;
  
  
  The 5-Agent Pipeline
&lt;/h2&gt;
&lt;h2&gt;
  
  
  Agent 1 — Claim Extractor
&lt;/h2&gt;

&lt;p&gt;A single paragraph can contain 4-5 distinct factual claims. Generic LLMs miss them or conflate them.&lt;br&gt;
This agent uses LLaMA 3.3 70B via Groq with Pydantic structured output to extract every distinct verifiable claim from the input text. The output is a typed list of claims — exact text, no paraphrasing, no hallucination.&lt;/p&gt;
&lt;h2&gt;
  
  
  Agent 2 — Evidence Hunter
&lt;/h2&gt;

&lt;p&gt;LLMs hallucinate citations. You need real, retrievable, dated evidence.&lt;br&gt;
This agent runs HyDE retrieval across 4,270 indexed Guardian + Wikipedia chunks stored in pgvector, then hits Tavily live web search for recent data.&lt;br&gt;
Why HyDE instead of standard RAG?&lt;br&gt;
Standard RAG embeds the raw claim and searches for similar text. A short factual claim like "The Fed raised rates in March 2024" has a weak semantic signal on its own.&lt;br&gt;
HyDE (Hypothetical Document Embeddings) generates a hypothetical document that would contain the answer — something like a news article excerpt — then embeds that. The result is a richer semantic signal and significantly better retrieval recall on short factual claims.&lt;/p&gt;
&lt;h2&gt;
  
  
  Agent 3 — Synthesis Agent
&lt;/h2&gt;

&lt;p&gt;This agent reasons strictly from retrieved evidence. It returns TRUE / FALSE / UNCERTAIN with a calibrated confidence score.&lt;br&gt;
Critically — if evidence is thin or conflicting, it returns UNCERTAIN instead of confabulating certainty. This was one of the hardest things to get right. LLMs naturally trend toward false confidence. I had to explicitly prompt for epistemic humility and add Pydantic validators to catch zero-confidence outputs.&lt;/p&gt;
&lt;h2&gt;
  
  
  Agent 4 — Critic Agent
&lt;/h2&gt;

&lt;p&gt;Synthesis agents tend toward overconfidence when evidence partially supports a claim. You need an adversarial check.&lt;br&gt;
This agent independently reviews every verdict. It flags unsupported reasoning, catches cases where 1.1°C vs 1.19°C is a rounding difference, not a false claim, and adjusts confidence downward when warranted.&lt;br&gt;
This is the step most fact-checking systems skip — and it's the one that matters most for borderline claims.&lt;/p&gt;
&lt;h2&gt;
  
  
  Agent 5 — Correction Agent
&lt;/h2&gt;

&lt;p&gt;Knowing something is false isn't enough. Users need to know what IS true.&lt;br&gt;
This agent fires only on FALSE or UNCERTAIN verdicts. It runs a targeted live search to find the correct information and surfaces it with a cited source. Conditional — doesn't waste tokens on TRUE verdicts.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why LangGraph?
&lt;/h2&gt;

&lt;p&gt;The pipeline isn't linear for every claim. Some claims have no evidence — they skip synthesis and go straight to the criticism. Some need multiple retrieval attempts. Some claims loop.&lt;br&gt;
LangGraph's state machine handles conditional branching, loops, and shared state across agents cleanly. The state is typed with TypedDict — every agent reads from and writes to the same state object.&lt;/p&gt;
&lt;h2&gt;
  
  
  Infrastructure
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;FastAPI&lt;/strong&gt; returns a task ID immediately. &lt;strong&gt;Celery + Redis&lt;/strong&gt; runs the pipeline in the background. The client polls for results.&lt;br&gt;
&lt;strong&gt;Redis cache&lt;/strong&gt; stores results for 7 days — the same viral claim doesn't cost tokens twice. Cache hits at the API layer return in under 1 second, before Celery even runs.&lt;br&gt;
&lt;strong&gt;LangFuse&lt;/strong&gt; traces every LLM call — prompt, output, latency, token count — so I can debug agent failures without guessing.&lt;/p&gt;
&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;p&gt;LLM: LLaMA 3.3 70B via Groq API&lt;br&gt;
Embeddings: all-MiniLM-L6-v2 via HuggingFace Inference API&lt;br&gt;
Orchestration: LangGraph state machine&lt;br&gt;
RAG: HyDE + pgvector hybrid search&lt;br&gt;
Vector DB: PostgreSQL + pgvector&lt;br&gt;
API: FastAPI + Pydantic&lt;br&gt;
Task Queue: Celery + Redis&lt;br&gt;
Evidence Sources: Tavily (live) + Guardian API + Wikipedia&lt;br&gt;
Observability: LangFuse + Prometheus + Grafana&lt;/p&gt;
&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The project is fully open source and Dockerized. One command runs the entire stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git clone https://github.com/ashg2099/Sift.git
cd Sift
cp .env.example .env
# Add your API keys (Groq, Tavily, HuggingFace — all free tiers)
docker compose up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;strong&gt;&lt;a href="http://localhost:8000" rel="noopener noreferrer"&gt;http://localhost:8000&lt;/a&gt;&lt;/strong&gt; and start verifying claims.&lt;br&gt;
I'm actively looking for feedback — especially where it breaks. If you try it, I'd love to know what it gets wrong.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/ashg2099/Sift" rel="noopener noreferrer"&gt;https://github.com/ashg2099/Sift&lt;/a&gt;&lt;br&gt;
LinkedIn: &lt;a href="https://www.linkedin.com/in/ashwin-gururaj-93943816a/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/ashwin-gururaj-93943816a/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>llm</category>
      <category>rag</category>
    </item>
  </channel>
</rss>
