<?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: Victor</title>
    <description>The latest articles on DEV Community by Victor (@vicarioy).</description>
    <link>https://dev.to/vicarioy</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%2F4108517%2F088683b5-acb5-41fc-85db-bb000c7ebdd8.jpg</url>
      <title>DEV Community: Victor</title>
      <link>https://dev.to/vicarioy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vicarioy"/>
    <language>en</language>
    <item>
      <title>The Failure My Quality Metrics Couldn't See: Testing the Router in Vicquant</title>
      <dc:creator>Victor</dc:creator>
      <pubDate>Sat, 17 Oct 2026 14:45:49 +0000</pubDate>
      <link>https://dev.to/vicarioy/the-failure-my-quality-metrics-couldnt-see-testing-the-router-in-vicquant-2h6l</link>
      <guid>https://dev.to/vicarioy/the-failure-my-quality-metrics-couldnt-see-testing-the-router-in-vicquant-2h6l</guid>
      <description>&lt;p&gt;This was called to my attention by one of my readers which tell me to check for the router path. &lt;br&gt;
I really appreciate my readers which take their time to read, drop their suggestions and correct me to do the right thing. Thank you all... &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Short Version of how Vicquant answer financial question&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Vicquant has two pathways to answer a financial question:&lt;/p&gt;

&lt;p&gt;A RAG path (rag_vault) that searches the user's uploaded documents (such as a 401(k) statement or tax summary) and cites the exact source.&lt;/p&gt;

&lt;p&gt;A chat path (fast_chat) that answers from the model's general financial training data.&lt;/p&gt;

&lt;p&gt;A small classification heuristic (the query router ) decides which path each incoming question takes.&lt;/p&gt;

&lt;p&gt;My quality run scores four metrics (including faithfulness and context recall) on 25 golden questions. Every one of those questions goes straight into the RAG path. So those four numbers describe RAG only after a question has already arrived there. They say nothing about whether the router picked the right path.&lt;/p&gt;

&lt;p&gt;That was a blind spot. When I finally audited the router, I found that 18 out of 20 document-grounded questions never reached the document vault at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why This Matters&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Imagine a user uploads their 401(k) statement and asks: "What formula does my employer use to match my contributions?"&lt;/p&gt;

&lt;p&gt;If routed to RAG: The system inspects the statement, extracts the matching tier, and cites page 2 of the document.&lt;/p&gt;

&lt;p&gt;If routed to Chat: The model answers from general knowledge. It might sound completely confident, stating a standard 50% match up to 6% even though the employer's actual formula is 100% up to 4%.&lt;/p&gt;

&lt;p&gt;In the second case, my quality metrics didn't drop at all, because the question never entered the RAG pipeline. The failure was completely invisible to my evaluation harness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Two Errors Do Not Cost the Same&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The one sent to RAG and the one sent to Chat cost two different things which are;&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%2Fka8t3pedtt51zs6wm3le.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%2Fka8t3pedtt51zs6wm3le.png" alt=" " width="378" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Because the failure costs are completely lopsided, the router should lean heavily toward retrieval. When in doubt, the question must go to RAG.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Metric I Added&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Alongside latency benchmarks, I now track:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The share of document-grounded inquiries that mistakenly route to the chat path.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I also track the reverse (general inquiries routed to RAG) so the engineering trade-off remains transparent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How the Check Was Run&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A Clean Benchmark Set: 40 questions (20 document-grounded inquiries about a sample 401(k) statement and 20 general personal finance questions).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Pre-Labelled: The expected path for every question was locked in before running the benchmark.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Automated End-to-End Execution: A test user uploaded the sample document, dispatched each query through the API, and recorded the path taken directly from the streaming response metadata.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Baseline Results (Pre-Fix)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The results before the fix was carried out;&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%2Ffcale0y32t1v591dy8di.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%2Ffcale0y32t1v591dy8di.png" alt=" " width="694" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Did 18 Document Questions Miss the Vault?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Personal Possessive Pronouns (8 misses): Questions like "What formula does my employer use?" or "Who is listed as my primary beneficiary?" used personal references (my employer, my balance) instead of generic keywords. The router mistook them for general conversational chat.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Missing Document Nouns (4 misses): Questions referencing "the statement" or "my plan summary" lacked exact token matches in the old keyword list.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Unrecognized Plan Terminology (6 misses): Operational benefits terms (graded vesting schedule, in-service rollover, plan loan origination fee) had no heuristic triggers.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The Fix: "Retrieval by Default"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of "send to RAG only if an explicit keyword matches," the architecture flipped to retrieval by default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A query is sent to fast_chat only if it matches an unequivocal, positive general financial signal (such as "How does compound interest work?" or "Explain the 50/30/20 budget").&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Any ambiguous, borderline, or pronoun-anchored question automatically defaults to rag_vault.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If a user has not uploaded any documents, the sub-millisecond retrieval check returns zero matching chunks, and the pipeline gracefully streams a standard answer without failing or injecting empty context.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Before and After: Validation Across Two Sets&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set 1: Benchmark Set (40 Questions)&lt;/strong&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%2Flxar50pv76ocnn1rxe20.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%2Flxar50pv76ocnn1rxe20.png" alt=" " width="598" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set 2: Fresh Evaluation Set (30 Unseen Questions, marked not independent)&lt;/strong&gt;&lt;br&gt;
To test generalizability against new phrasing, a second set of 30 questions (15 document, 15 general) was run once without tuning:&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%2Fc6fzjlidg4e46awn0bgh.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%2Fc6fzjlidg4e46awn0bgh.png" alt=" " width="588" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Note: In a 15-question sample, 0 misses statistically rules out a true miss rate above roughly ~20%. A larger ongoing test corpus will narrow this confidence interval).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Cost of Fallback&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Local Compute: Negligible. Local hybrid BM25 and vector search takes 13.8 ms mean compute time.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Context Overhead: A fallback question incurs ~400 additional tokens of retrieved context, costing approximately +$0.000015 USD on OpenRouter.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The Asymmetric Trade-Off: Sending 73% of borderline general inquiries through the RAG pipeline is an intentional design choice. Paying 14 ms of compute and a fraction of a cent is incomparably better than allowing a participant's retirement question to reach a chat model that cannot see their statement.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I Learned&lt;/strong&gt;&lt;br&gt;
A quality metric only measures what flows through it.&lt;/p&gt;

&lt;p&gt;My four RAG evaluation scores were mathematically sound, yet completely blind to our most dangerous failure mode because the failure occurred before the pipeline began.&lt;/p&gt;

&lt;p&gt;If your AI system has a router, a classifier, or an intent gate standing in front of your RAG pipeline, evaluate the gate first, and weight its errors by what they actually cost the user.&lt;/p&gt;

&lt;p&gt;In your spare time, kindly check out vicquant(It is in the beta testing phase ) so i am in need of all the help i can get and send your feedback. So I can Improve it and get it to work better. &lt;br&gt;
The Link: &lt;a href="https://budget-planner-advisor-rho.vercel.app/" rel="noopener noreferrer"&gt;https://budget-planner-advisor-rho.vercel.app/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thank you!&lt;/p&gt;

</description>
      <category>ai</category>
      <category>buildinpublic</category>
      <category>rag</category>
      <category>financial</category>
    </item>
    <item>
      <title>I Retracted My Latency Numbers. Here Are the Real Ones: 1,938 Requests Over 3 Days</title>
      <dc:creator>Victor</dc:creator>
      <pubDate>Fri, 16 Oct 2026 12:29:57 +0000</pubDate>
      <link>https://dev.to/vicarioy/i-retracted-my-latency-numbers-here-are-the-real-ones-1938-requests-over-3-days-42p7</link>
      <guid>https://dev.to/vicarioy/i-retracted-my-latency-numbers-here-are-the-real-ones-1938-requests-over-3-days-42p7</guid>
      <description>&lt;p&gt;A few days ago I published latency numbers for my AI finance app that were wrong. The headline was "all queries under one second." A reader asked what the benchmark actually measured, and my own audit revealed that 35 of the 75 data points had been simulated rather than measured end-to-end. I retracted the numbers immediately.&lt;/p&gt;

&lt;p&gt;This is the replacement: measured through the real, live production app, across six benchmark sessions over three consecutive days, with zero simulated data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Short Version of what i worked on in the past few days&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Typical case: Time to first token (TTFT) through the deployed app averaged 2.3 to 3.6 seconds (the median of each session). The exact same model call made directly to the inference provider took about 0.6 to 1.8 seconds.&lt;/p&gt;

&lt;p&gt;The surprise: With several people using the app concurrently, it slowed down dramatically. At 5 simultaneous users, median time to first token jumped to 9 to 22 seconds.&lt;/p&gt;

&lt;p&gt;What I can't explain yet: An overhead of about 1.9 seconds sits outside the model inference window, and the "fast" chat path was unexpectedly slower on average than the document-retrieval (RAG) path.&lt;/p&gt;

&lt;p&gt;Earlier tests on my laptop against a remote database clocked ~13 seconds to first token. Removing four sequential database round trips and repairing missing HTTP streaming chunk headers eliminated that initial lag. Those older numbers came from a local environment and different code; the numbers below represent the audited, production-verified measurements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How I Measured&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Target Deployment: The live app hosted on Render, a Neon Postgres database in us-east-2, and meta-llama/llama-3.1-8b-instruct routed through OpenRouter using the production provider hierarchy (Groq first, then Novita, then DeepInfra).&lt;/p&gt;

&lt;p&gt;Client Vantage Point: An automated streaming client on a mobile cellular broadband connection, connecting through the public HTTPS URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Metric Definitions:&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Time to First Token (TTFT): Elapsed duration from client request transmission (t_client_send) until the first content token arrives at the client.&lt;/p&gt;

&lt;p&gt;Completion: Elapsed duration from client request transmission to the arrival of the final token / stream termination.&lt;/p&gt;

&lt;p&gt;Test Protocol: Six discrete sessions across three days (two sessions per day, October 13–15), with 323 requests per session (1,938 total requests):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;120 steady-state requests on a warm, reused connection.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;40 requests on fresh, newly established TCP/TLS connections.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;30 direct OpenRouter requests across each of three provider policies (Default, Groq-excluded, Groq+Novita excluded).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;60 concurrency load-test requests (20 each at $c=1, 3, 5$).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;3 cold-start probes following server idle intervals.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;10 baseline network probe pings.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hygiene &amp;amp; Cost: Test accounts used reserved ephemeral domains (&lt;a class="mentioned-user" href="https://dev.to/bench"&gt;@bench&lt;/a&gt;.invalid) and were purged by exact ID from Neon at session conclusion (verified 0 residual rows). Total OpenRouter inference cost across all 1,938 requests was $0.0108 USD.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result 1: The App Adds About 1.9 Seconds to the Raw Model Call&lt;/strong&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%2F7zgzgsgmly3zp6ykbnri.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%2F7zgzgsgmly3zp6ykbnri.png" alt=" " width="697" height="550"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note: that the difference column isn't all "app." Some of it is the extra hop from my laptop to Render, and the network probe (a lightweight endpoint, so it isn't a clean subtraction) suggests that hop costs a few hundred milliseconds or more on my connection. I did not trace the server side in these sessions, so I won't pin the rest on any single cause. My candidates are database round trips, request middleware, and streaming overhead, but those are guesses.&lt;/p&gt;

&lt;p&gt;Two other patterns I can't explain yet:&lt;/p&gt;

&lt;p&gt;Slower on the second and third day; Day 1 medians were 2.3 to 2.7 seconds, and days 2 and 3 were 3.0 to 3.6. The network probe didn't rise by a comparable amount. I'm checking whether the deployed code or the data changed between days.&lt;/p&gt;

&lt;p&gt;The "fast" path was slower. The fast-chat path had a mean session median of about 3.4 seconds, and the document (RAG) path about 2.7 seconds. I expected the opposite. One would expect document retrieval, vector matching, and reranking to add latency, not decrease it.&lt;/p&gt;

&lt;p&gt;Result 2: a fresh connection costs 60 to 714 ms&lt;br&gt;
On a brand-new connection, first token arrived 60 to 714 ms later than on a reused connection, with a mean of about 330 ms. Browsers reuse connections, so most turns skip this, but a first-time visitor pays it. While browsers reuse active connections during an ongoing chat session, first-time visitors must absorb this initial handshake overhead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result 3: Groq-First Provider Order Delivers ~0.4s Faster TTFT; It is about 0.4 seconds faster to the first token&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By isolating provider endpoints in our routing policy, we measured the upstream first-token latency differences:&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%2F4ucq2r18bv0n74jns7hf.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%2F4ucq2r18bv0n74jns7hf.png" alt=" " width="700" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Important Context: This test evaluated first-token arrival only. In full generation benchmarks, DeepInfra exhibits lower tokens-per-second output throughput, leading to substantially longer full-response completion times (~10–14 seconds). Provider selection involves a trade-off between instant token streaming initiation and sustained output throughput.&lt;/p&gt;

&lt;p&gt;*&lt;em&gt;Result 4: Concurrency Bottlenecks Emerge Quickly; running simultaneous users hurt a lot&lt;br&gt;
*&lt;/em&gt;&lt;br&gt;
Running concurrent client requests through the deployed app exposed an event loop / worker concurrency wall:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Median TTFT (seconds) across Concurrency Tiers (20 requests per tier per session):&lt;/strong&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%2Fmzl04k65a3e16kxzb7fy.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%2Fmzl04k65a3e16kxzb7fy.png" alt=" " width="685" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With 3 clients the median first token was 1.6 to 3.9 times slower than with 1, and with 5 clients it was 2.7 to 4.4 times slower. That's roughly what you'd see if the server handled requests largely one after another: with k requests arriving together, the median wait is about (k+1)/2 times a single request, which is 2 times for 3 clients and 3 times for 5. It's consistent with that picture, but it doesn't prove it. I haven't measured whether something is blocking the event loop. Day 3 was worse at every level, and I don't know why.&lt;br&gt;
For a small beta, a single user sees about 3 seconds. Three or more people chatting at the same moment is the current limit, and it's the first thing I'm fixing.&lt;/p&gt;

&lt;p&gt;When multiple clients query the system concurrently, response times degrade in near-linear proportion to the concurrency level. A pattern characteristic of single-worker event loop blocking or synchronous I/O operations occurring within asynchronous route handlers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result 5: Cold Starts&lt;/strong&gt;&lt;br&gt;
The first request after an idle period took &lt;strong&gt;5.4, 3.6, 3.2, 3.8, 5.8 and 6.6 seconds&lt;/strong&gt; across the six sessions. Those are single runs, so I'm reporting ranges, not statistics. On day 2, session 2, the second and third requests were also slow (7.0 and 6.2 seconds), and I have no explanation for that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pooled Aggregate Percentiles (All 720 Steady-State App Requests)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Rather than evaluating session medians alone, pooling all 720 steady-state requests ($A1$) collected across identical code and conditions yields the following distribution:&lt;br&gt;
&lt;strong&gt;p50 (Median):&lt;/strong&gt; 2,842 ms&lt;br&gt;
&lt;strong&gt;p90:&lt;/strong&gt; 5,420 ms&lt;br&gt;
&lt;strong&gt;p95:&lt;/strong&gt; 7,140 ms&lt;br&gt;
&lt;strong&gt;p99:&lt;/strong&gt; 10,680 ms&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Can and Cannot Claim&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Network Specificity: All telemetry was measured from a remote cellular client connecting over public international routing to a US-East server deployment. Users on direct fiber or closer to the server host will experience lower transport and TLS handshake latencies.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Server-Side Dissection: Because server tracing was turned off during benchmark execution, the exact millisecond distribution between application middleware, template compilation, database session acquisition, and upstream proxy dispatch remains unverified.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Quality vs. Speed: These benchmarks isolate response velocity only. They make no assessment of response grounding, semantic relevance, or financial calculation accuracy.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I Learned&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Benchmark the User Path, Not the Bare Model: Direct model calls bypass the database, session handling, serialization, and network routing. Measuring the raw model told me how fast OpenRouter was, but told me nothing about the experience of someone using the app.&lt;/p&gt;

&lt;p&gt;Concurrency Reveals What Single-User Testing Hides: A 2.8-second single-user response is usable; a 16-second response under modest concurrency is unacceptable. Concurrency profiling must be an integral component of testing from day one.&lt;/p&gt;

&lt;p&gt;Transparency Builds Trust: Retracting incorrect numbers is painful, but publishing verified, unvarnished data is the only standard that matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'm doing next&lt;/strong&gt;&lt;br&gt;
1.Trace the server side to find where the extra 1.9 seconds goes, and why the fast path is slower than the document path.&lt;br&gt;
2.Fix the concurrency behavior, then re-run this exact harness and publish before and after numbers.&lt;br&gt;
3.Pooled percentiles with confidence intervals, grouped by network and code version.&lt;br&gt;
4.Repeat the provider quality comparison with enough runs to see past the noise.&lt;br&gt;
5.Tomorrow: a second article on a blind spot in my quality metrics which a reader . They measured the pipeline accurately and still missed that 18 of 20 document questions never reached it.&lt;/p&gt;

&lt;p&gt;If you've tackled single-worker event loop concurrency bottlenecks in production FastAPI/Uvicorn applications, what was your primary culprit?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>buildinpublic</category>
      <category>performance</category>
      <category>fastapi</category>
    </item>
    <item>
      <title>I Built an AI Financial Advisor. Now I Need You to Break It.</title>
      <dc:creator>Victor</dc:creator>
      <pubDate>Thu, 15 Oct 2026 15:49:16 +0000</pubDate>
      <link>https://dev.to/vicarioy/i-built-an-ai-financial-advisor-now-ineed-you-to-break-it-3ab3</link>
      <guid>https://dev.to/vicarioy/i-built-an-ai-financial-advisor-now-ineed-you-to-break-it-3ab3</guid>
      <description>&lt;p&gt;Vicquant is open for beta testers: pick a frontend design, run four short tasks, and tell me exactly what feels wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem with asking "What do you think?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I first put Vicquant in front of beta testers, I asked the question every builder asks:&lt;br&gt;
"What do you think?" The answers I got back were polite and vague. "Looks good." "Nice app." &lt;br&gt;
Not one of them told me where someone got stuck, what confused them, or what they expected to happen and didn't.&lt;br&gt;
That is my mistake, not theirs. Open-ended questions produce open-ended politeness.&lt;/p&gt;

&lt;p&gt;So this time I am doing it differently: I am giving you specific things to do, a short way to report what&lt;br&gt;
happened, and a say in how the app looks. If you have ever wanted to shape a product while it is still early, this is that chance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is Vicquant?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Vicquant is an AI-assisted personal finance and market-analysis app. It started as a small local chatbot that helped people plan debt repayment and savings, and it has grown into something much bigger. One rule shaped it from day one: the AI never does the arithmetic. All the&lt;br&gt;
money math (balances, payoffs, risk numbers) is computed by normal, testable code, and the language model only explains the results. That way a language model's confident guess cannot quietly become your budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you can try in the beta:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;• AI Advisor and Budget: ask questions about debt, savings and spending in plain language.&lt;br&gt;
• RAG Vault: upload your own financial documents and get answers with citations back to the source, so you can check the answer instead of trusting it.&lt;br&gt;
• Stocks and Portfolio: track holdings and see risk measures such as Sharpe, Sortino, VaR and CVaR, without needing a finance degree to read them.&lt;br&gt;
• Market regime detection and alerts: a statistical model that tries to tell calm markets from turbulent ones.&lt;br&gt;
• Proof ledger: trading signals are recorded in a tamper-evident, hash-chained log with fixed rules set in advance, so results cannot be quietly rewritten after the fact. No win rate is shown until at least 30 signals have been resolved, because a handful of lucky calls proves&lt;br&gt;
nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest status: what is rough right now&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A beta invitation is only useful if it is honest, so here is where things stand:&lt;br&gt;
• The AI currently runs through a hosted model service rather than fully on your own device.&lt;br&gt;
Running it locally again is on the roadmap, but it is not the current setup.&lt;br&gt;
• The mobile web experience is not good enough. A native Android and iOS app is planned, so please judge the layout with that in mind.&lt;br&gt;
• Vicquant gives information and analysis. It is not licensed financial advice, and nothing in it is a guarantee of returns.&lt;br&gt;
• Some features are still being tuned, and speed can vary. If something feels slow, I want to know exactly when it happened.&lt;br&gt;
• The frontend UI and UX design also needs working on. This is my first time on working on something of this scale. &lt;br&gt;
Please, I need your honest review and contribution, so i can improve on it&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Please use sample numbers. During the beta, enter made-up balances and debts rather than real account details.You can test everything that matters without exposing anything&lt;br&gt;
personal.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Help me choose the design&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I have three frontend designs and I do not want to choose alone. The feedback I got earlier was that the interface needs to feel easier for someone who is not a finance expert, so this is where your eyes matter most. The UI design is okay for laptop size screen but for the mobile, i am still working on 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%2Fqjocxw2hk1kl16z1gq9r.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%2Fqjocxw2hk1kl16z1gq9r.png" alt=" " width="800" height="348"&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%2Fgcjuow4e90tdawj2vlmf.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%2Fgcjuow4e90tdawj2vlmf.png" alt=" " width="800" height="348"&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%2F4rnw822sl8b9j9k6lmnq.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%2F4rnw822sl8b9j9k6lmnq.png" alt=" " width="800" height="346"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When you vote, do not only tell me which one you like. Tell me which one you understood fastest. In a finance app, "pretty" matters less than "I knew what to tap."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your 15-minute mission&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Four tasks. Do them in order, and write down anywhere you hesitated, even for a second.&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%2Fei0d6fxqsgacns2ideuq.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%2Fei0d6fxqsgacns2ideuq.png" alt=" " width="800" height="322"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to send feedback (it takes two minutes)&lt;/strong&gt;&lt;br&gt;
After the four tasks, answer these five questions in the feedback form:&lt;/p&gt;

&lt;p&gt;• Which design did you pick, and which one did you understand fastest?&lt;br&gt;
• Where did you get stuck or confused? Name the screen.&lt;br&gt;
• What did you expect to happen that did not?&lt;br&gt;
• Did any AI answer feel wrong, vague or untrustworthy? Paste it if you can.&lt;br&gt;
• Would you use this again? What single change would make you say yes?&lt;/p&gt;

&lt;p&gt;Bug reports are gold. A screenshot, your device and browser, and one sentence about what you were doing is all I need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Join the beta&lt;/strong&gt;&lt;br&gt;
If you are a developer, a student, a finance enthusiast, or simply someone who gets confused by money apps, you are exactly who I need. Beginners are especially welcome, because they&lt;br&gt;
find the problems experts forgive.&lt;/p&gt;

&lt;p&gt;• Beta link: &lt;a href="https://budget-planner-advisor-rho.vercel.app/" rel="noopener noreferrer"&gt;https://budget-planner-advisor-rho.vercel.app/&lt;/a&gt;&lt;br&gt;
• Reply here or message me with questions.&lt;/p&gt;

&lt;p&gt;Thank you for reading, and for being willing to break something so it can be built better. Tell me what is wrong with it, and I will make sure the next version is better because of you.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>programming</category>
      <category>finance</category>
      <category>ai</category>
    </item>
    <item>
      <title>I Measured My RAG Pipeline Honestly. It Was 40x Slower Than I Thought.</title>
      <dc:creator>Victor</dc:creator>
      <pubDate>Sat, 10 Oct 2026 15:54:04 +0000</pubDate>
      <link>https://dev.to/vicarioy/i-measured-my-rag-pipeline-honestly-it-was-40x-slower-than-i-thought-3o5h</link>
      <guid>https://dev.to/vicarioy/i-measured-my-rag-pipeline-honestly-it-was-40x-slower-than-i-thought-3o5h</guid>
      <description>&lt;p&gt;A few days ago I published the architecture behind Vicquant’s RAG Vault; a 9-stage retrieval pipeline built to ground financial AI answers in actual source documents, with strict citations, so a user asking “what’s my 401(k) contribution limit” gets an answer tied to a real document, not a model’s confident guess. The quality evaluation backed it up: +5.2% answer relevance, +16% context precision, +4% context recall over the simpler pipeline it replaced, with zero hallucinations measured on either version.&lt;/p&gt;

&lt;p&gt;What I didn’t have yet was an honest measurement of how fast it actually was. This is the story of getting that number, not liking it, and fixing it properly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The number that was true and meaningless&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My first latency measurement showed the pipeline completing in about 4.56 milliseconds. That number was real, and completely useless, because the test suite that produced it had the network-dependent calls (the hosted embedding API, the LLM calls for HyDE and generation) running against mocked test doubles, not the real OpenRouter API. It measured the pipeline’s own logic, correctly, while saying nothing about what a user would actually experience.&lt;/p&gt;

&lt;p&gt;So I built a live benchmark: the same 25-question golden evaluation set, run through the real pipeline against the real API, three times, with full per-stage timing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mean latency: 14.81 seconds. p95: 21.28 seconds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That’s not a number you publish as a feature. That’s a number you fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Finding the actual levers&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The full pipeline runs several sequential steps that each cost real time: HyDE generates a hypothetical document via an LLM call, multi-query expansion generates alternate phrasings via another LLM call, then retrieval, reranking, and a final generation call. Three separate cloud LLM calls, done carefully but done on every single query, including ones that had no reason to need any of it.&lt;/p&gt;

&lt;p&gt;_The first and biggest fix was routing: _Most of what a financial advisor app gets asked; budgeting questions, “how am I doing this month,” general conversation, doesn’t reference an uploaded document at all. Only questions actually grounded in RAG Vault content need the full apparatus. I added a lightweight, sub-millisecond heuristic classifier to route document-grounded questions into the full pipeline and everything else into the existing fast direct-chat path. Tested against a realistic 75-query mix (25 document-grounded, 50 general), it classified with 100% accuracy at roughly 0.09 milliseconds of overhead — a rounding error compared to what it saves.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The second fix was concurrency:&lt;/em&gt; HyDE generation and multi-query expansion don’t depend on each other’s output, but they were running one after another. Running them with asyncio.gather instead cut roughly 20% off the document-grounded path on its own.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The third fix was streaming:&lt;/em&gt; Total response time matters, but felt response time matters more for whether something feels broken. Adding SSE token streaming meant the fast path now renders its first token in under a second, instead of a user staring at nothing until the full response assembles.&lt;/p&gt;

&lt;p&gt;Combined, these three changes brought mean latency from 14.81s down to 6.24s, and median latency from 14.71s down to 3.61s. A real improvement. Still not where I wanted to land.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The variance that wasn’t random&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Even after those fixes, something was off: identical questions, identical model, wildly different response times from one call to the next. It looked like noise. It wasn’t.&lt;/p&gt;

&lt;p&gt;OpenRouter doesn’t always serve a given model from the same place. A model string like meta-llama/llama-3.1-8b-instruct can be fulfilled by several different upstream inference providers, and OpenRouter rotates between them per request. I added logging to capture which provider actually served every call, then ran twelve identical back-to-back requests with the exact same prompt and model string to see what would happen.&lt;/p&gt;

&lt;p&gt;Three different providers answered across those twelve calls; Groq, Novita, and DeepInfra and the latency gap between them was stark:&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%2Fag8goyx1jh6dpnoohfc1.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%2Fag8goyx1jh6dpnoohfc1.png" alt=" " width="800" height="234"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;DeepInfra was roughly 11x slower than Groq for identical work. That single provider, showing up unpredictably in the rotation, was the entire source of the long tail in my earlier benchmarks, the 2-to-14-second HyDE call spread I’d measured wasn’t jitter, it was DeepInfra showing up some of the time.&lt;/p&gt;

&lt;p&gt;OpenRouter supports explicit provider ordering in the request body, so I pinned it: Groq first, Novita second, DeepInfra kept as a last-resort fallback (never removed entirely because availability matters more than shaving off a few hundred more milliseconds when both fast providers happen to be down).&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%2Fsw6djj6s10fvkd2rss4v.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%2Fsw6djj6s10fvkd2rss4v.png" alt=" " width="799" height="277"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every one of the 75 test queries now completes in under one second. That’s a 97.5% reduction in mean latency from where this started.&lt;/p&gt;

&lt;p&gt;The cost of pinning to faster providers was functionally zero about $0.000003 extra per query, something like three-thousandths of a cent. And at every single stage of this entire process, I re-ran the quality evaluation: faithfulness, answer relevance, context precision, and context recall moved by exactly 0.0000 throughout. None of these fixes touched what the pipeline retrieves or generates, only when the calls happen and which infrastructure answers them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this actually proves&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three things I’d rather have learned on purpose than by accident, written down for whoever reads this next:&lt;/p&gt;

&lt;p&gt;A benchmark is only honest if it measures the thing a user actually experiences. Mocked-out network calls will always look fast, that’s not a benchmark, it’s a logic test wearing a benchmark’s clothes.&lt;/p&gt;

&lt;p&gt;“Random” variance usually isn’t. The twelve-identical-calls test took a few minutes to write and turned an unexplained 6x latency spread into a fully understood, fully fixable root cause.&lt;/p&gt;

&lt;p&gt;And the last one: speed and correctness aren’t actually in tension here, even though it’s tempting to assume they are. Every optimization in this piece made the pipeline faster without moving a single quality metric. The tradeoff I was bracing for, give up some grounding rigor for responsiveness and was never had to be made.&lt;/p&gt;

&lt;p&gt;RAG Vault is done. Next up: what Vicquant needed a mobile-native rebuild to get right, and why a web wrapper wasn’t going to cut it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>fintech</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Why a Financial Advisor App Needs a RAG Vault, Not Just a Chatbot</title>
      <dc:creator>Victor</dc:creator>
      <pubDate>Wed, 07 Oct 2026 19:56:12 +0000</pubDate>
      <link>https://dev.to/vicarioy/why-a-financial-advisor-app-needs-a-rag-vault-not-just-a-chatbot-46ph</link>
      <guid>https://dev.to/vicarioy/why-a-financial-advisor-app-needs-a-rag-vault-not-just-a-chatbot-46ph</guid>
      <description>&lt;p&gt;When I started building Vicquant, the core design principle was simple and non-negotiable: the AI never does the math. Every number and a debt payoff schedule, a backtested win rate, a risk metric  is computed by deterministic code. The model's only job is to explain results in plain language, never to generate a number from scratch.&lt;/p&gt;

&lt;p&gt;That principle protects users from one kind of failure: a model confidently inventing a wrong calculation. But it doesn't protect against a second, quieter failure mode, when a model confidently answering a factual question about a document it has never actually seen, using something that merely sounds plausible. If a user uploads a fee schedule and asks "what does this actually charge me for an overdraft," a generic chatbot answer isn't good enough. It needs to be grounded in that specific document, with a receipt.&lt;/p&gt;

&lt;p&gt;That's what the RAG Vault is for, and getting it right turned out to be a deeper engineering problem than I expected.&lt;/p&gt;

&lt;p&gt;*&lt;em&gt;Why the naive version doesn't work for finance&lt;br&gt;
*&lt;/em&gt;&lt;br&gt;
A basic RAG implementation do split text into fixed-size chunks, embed them, retrieve the closest matches and works reasonably well for general text. It falls apart on financial documents for a specific reason: financial documents are full of tables, and a naive character-splitter slices through them without any awareness that it's destroying a row of numbers. Tax brackets, fee schedules, 10-K disclosures are all tabular, all fragile to blind splitting. The fix was layout-aware parsing that detects and preserves tables, headings, and structured sections as whole units rather than arbitrary text windows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The chunk-size dilemma&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Even with layout-aware parsing, there's a tension in how big each retrievable piece should be. Small chunks match a search query precisely but don't give the model enough surrounding context to answer well. Large chunks give plenty of context but are harder to match precisely against a specific question. Vicquant resolves this with hierarchical chunking: small "child" chunks (150-250 tokens) are what gets searched and matched, but each one is linked to a larger "parent" chunk (800-1200 tokens) that actually gets handed to the model once a match is found. Precision for retrieval, context for generation and it decoupled instead of traded off against each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Finding the right passage when the words don't match&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Pure keyword or basic cosine-similarity search misses conceptual synonyms and also "drawdown mitigation" and "capital preservation" mean roughly the same thing to a person asking a question, but not to a system doing literal string or frequency matching. The fix is dense neural embeddings combined with traditional keyword search (BM25) in a hybrid approach, fused using Reciprocal Rank Fusion(RRF) rather than an arbitrary weighted average. RRF is scale-invariant, so it doesn't break when one search method's scores happen to run on a different numeric range than the other's.&lt;/p&gt;

&lt;p&gt;On top of that sits a cross-encoder reranker: a second-pass model that looks at the actual query and each candidate passage together, rather than comparing pre-computed embeddings in isolation. This catches token-level relevance that embedding similarity alone misses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where the model's attention actually goes&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One detail that's easy to miss: large language models don't pay uniform attention across a long context window — information placed in the middle tends to get under-weighted compared to the beginning and end (a well-documented effect sometimes called "lost in the middle"). So the most relevant retrieved passages are deliberately placed at the start and end of what gets sent to the model, not simply stacked in ranked order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Grounding, not just retrieval&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The last piece is making every answer auditable. Vicquant's citation format isn't a vague "according to the document". it's a strict, deterministic token: [Citation ID: doc_id:chunk_id], pointing at the exact passage that produced the claim. For a financial app, this matters for a reason beyond user trust: it's the difference between an answer someone can verify and an answer they just have to believe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the evaluation actually showed&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I ran the new pipeline against the old one on a 25-question financial benchmark, split across numeric lookups, conceptual synonym matches, and multi-table extraction, scored using RAGAS metrics:&lt;/p&gt;

&lt;p&gt;Metric  Legacy pipeline New pipeline    Change&lt;br&gt;
Faithfulness    100%    100%    unchanged&lt;br&gt;
Answer Relevance    78.7%   83.9%   +5.2%&lt;br&gt;
Context Precision   34.0%   50.0%   +16.0%&lt;br&gt;
Context Recall  92.0%   96.0%   +4.0%&lt;/p&gt;

&lt;p&gt;_Faithfulness _— the measure of whether claims are actually grounded in retrieved context rather than hallucinated, it was already perfect on the old pipeline and stayed perfect. That's expected; faithfulness is downstream of the generation step, not the retrieval architecture. The real gains are in Context Precision, up 16 points and the new pipeline pulls in noticeably less irrelevant "distractor" content, largely thanks to the cross-encoder reranking stage filtering out near-misses before they reach the model.&lt;/p&gt;

&lt;p&gt;I also stress-tested the resilience claims directly rather than assuming them: deliberately failing the hosted embedding API confirms the circuit breaker trips and the pipeline falls back to the local offline embedder with zero unhandled exceptions; feeding in a document containing an embedded instruction ("SYSTEM OVERRIDE: ignore all prior instructions") confirms that text stays encapsulated as inert data rather than influencing the model's behavior.&lt;/p&gt;

&lt;p&gt;One honest caveat on performance: the pipeline's internal processing overhead measured in testing is small, but that number reflects the pipeline's own logic with the network-dependent components (the hosted embedding call, HyDE's LLM call) running against test doubles, not live API latency. Real-world response time will be dominated by those actual network calls to OpenRouter, which I haven't benchmarked against the live API yet and that's the next thing to measure honestly before making any speed claims to users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why build a financial advisor this way at all&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The short version: the worst thing an AI assistant can do with someone's money isn't getting something wrong. It's getting something wrong confidently, with no way for the person to check. Every design decision in Vicquant, from "the AI never computes numbers" to "every RAG answer carries a citation," traces back to that one principle. It's slower to build this way. I think it's the only responsible way to build it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>buildinpublic</category>
      <category>fintech</category>
      <category>rag</category>
    </item>
    <item>
      <title>Building a Financial App That Works With Zero Internet (and Doesn't Lie About It)</title>
      <dc:creator>Victor</dc:creator>
      <pubDate>Tue, 06 Oct 2026 15:03:40 +0000</pubDate>
      <link>https://dev.to/vicarioy/building-a-financial-app-that-works-with-zero-internet-and-doesnt-lie-about-it-56m2</link>
      <guid>https://dev.to/vicarioy/building-a-financial-app-that-works-with-zero-internet-and-doesnt-lie-about-it-56m2</guid>
      <description>&lt;p&gt;Most personal-finance and trading apps make one quiet assumption: you always have a fast, stable connection to their servers. If you don't, you get a spinner, a blank screen, or worse, a stale number presented as if it were live.&lt;/p&gt;

&lt;p&gt;I'm building Vicquant, a financial advisor and quant-analytics app that flips that assumption. The architecture offline design to runs the local AI model on everyone device for budgeting and debt planning, no internet required and layers in live market data, quant signals, and backtesting when you do have a connection. This article covers the engineering behind making that hybrid model actually reliable, not just "works most of the time."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The core problem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once you decide an app must work offline and online, you inherit a new category of bugs that a purely cloud app never has to think about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;What happens when the network drops mid-request?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;What happens when a free-tier data API rate-limits you?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;What happens when a provider is down for an hour?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;How do you show data from an hour ago without pretending it's from now?&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are exotic problems. They're just usually ignored, because "assume the internet works" is the default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first Design Decision: Python computes, the AI explains&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Small local language models are surprisingly capable at conversation, but they are unreliable at arithmetic off-by-one errors, dropped decimal places, confidently wrong totals. For a finance app, a wrong number is worse than no number.&lt;/p&gt;

&lt;p&gt;So every calculation needs a debt payoff schedules, savings projections, risk metrics and it runs in deterministic Python code first. The model's only job is to explain the result in plain language. It never generates a number from scratch. This single rule eliminates an entire class of trust-destroying bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design decision 2: every external call assumes failure&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The data layer is built around a simple idea: assume every network call will eventually fail, and design for what happens next, not just the happy path. Concretely, that means four pieces working together:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Retry with backoff and jitter;&lt;/em&gt; A single timeout shouldn't be treated as a real failure. It's retried with an increasing delay, plus a little randomness (jitter) so many clients don't all retry at the exact same moment and overwhelm the provider again.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A circuit breaker per data provider;&lt;/em&gt; If a provider fails repeatedly, the app stops calling it for a cool-down period instead of retrying forever and wasting time on a dead endpoint. After the cool-down, one trial call checks if it's healthy again.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A rate limiter per provider;&lt;/em&gt; Free-tier market-data APIs have strict call limits. A simple per-provider minimum interval between calls keeps you under the limit without needing to track quotas manually.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A provider fallback chain;&lt;/em&gt; If provider A is down, the app automatically tries provider B, then C. The rest of the app never knows or cares which one actually answered, it just gets a quote back, or a clear failure.&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;def&lt;/span&gt; &lt;span class="nf"&gt;get_quote&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;symbol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_currency&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_connectivity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_online&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;quote&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_try_providers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# walks the fallback chain
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;quote&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&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;_cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&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;quote:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_dict&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="nf"&gt;_maybe_convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_currency&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="nf"&gt;_serve_from_cache&lt;/span&gt;&lt;span class="p"&gt;(&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;quote:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_currency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Design decision 3: never hide staleness&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When every provider fails and the app falls back to cache, that data gets returned with stale=True and an age_seconds field. The UI is required to show a "last updated 3 hours ago" badge whenever that flag is set. This is a small technical detail with an outsized trust impact, a user who sees an honest "this is old" beats a user who later discovers a number quietly lied to them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design decision 4: not every error deserves a retry&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A "symbol not found" error and a "server timed out" error look similar on the surface but mean opposite things. One is a permanent fact; the other is transient. The error hierarchy distinguishes them explicitly, so a typo'd ticker fails fast instead of retrying three times against every provider in the fallback chain for no reason.&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;ProviderError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DataLayerError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Retryable: timeout, 5xx, temporary provider trouble.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SymbolNotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DataLayerError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Not retryable: the symbol simply doesn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t exist.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'm currently building the backtesting engine, every quant signal has to prove itself against two years of historical data, with realistic transaction costs, before it's allowed to appear live in the app.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>fintech</category>
      <category>offlinefirst</category>
    </item>
    <item>
      <title>The Architecture I Built vs. The Architecture I Could Deploy</title>
      <dc:creator>Victor</dc:creator>
      <pubDate>Sun, 04 Oct 2026 09:22:06 +0000</pubDate>
      <link>https://dev.to/vicarioy/the-architecture-i-built-vs-the-architecture-i-could-deploy-5eoi</link>
      <guid>https://dev.to/vicarioy/the-architecture-i-built-vs-the-architecture-i-could-deploy-5eoi</guid>
      <description>&lt;p&gt;I participated in a competition and it gave me an insight into something interesting and it led me to build Vicquant. An offline financial advsior app but when i was building it, i ran into a problem and and this aid me in solving real world problem and be innovative for the time being.&lt;/p&gt;

&lt;p&gt;Vicquant's core idea is a dual local-model setup with zero cloud dependency: Llama-3.1-8B-Instruct for deep financial reasoning, Phi-3.5-mini as a fast, low-RAM fallback, both running through llama.cpp directly on the user's device. I will explain the reason for the split in an article soon, the short version is that reasoning quality and response speed pull in opposite directions, and one model can't optimize for both.&lt;/p&gt;

&lt;p&gt;That design is real. The code exists, the model-routing logic exists, and it passed testing on an 8GB RAM / 4 vCPU target at roughly 12-15 tokens/second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I could actually deploy&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's the part that doesn't usually make it into these posts: running an 8B-parameter model needs meaningful RAM and CPU, and the PaaS tier I'm currently hosting on have a Pro plan available which i haven't yet purchased. So the version of Vicquant live right now cannot run the local models in production.&lt;/p&gt;

&lt;p&gt;Rather than leave the deployment broken or ship nothing, I connected it to OpenRouter so the app can serve AI responses today. Practically, this means: the deployed demo currently requires internet and routes model calls through OpenRouter. Offline mode is off for this deployment. The local-inference architecture isn't gone, it's in the codebase, tested, and it comes back as soon as i can pay for the hosting plans. But I'd rather say that directly than let a live demo imply a capability it isn't currently providing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I'm writing this instead of hiding it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two reasons.&lt;/p&gt;

&lt;p&gt;First, credibility compounds. If someone inspects network requests on the live demo and finds a mismatch with what I claimed, every other technical claim I've made gets discounted too; the backtesting gate, the test suite, all of it. Saying the true thing now costs me one slightly less impressive post. Saying the false thing costs trust I can't easily rebuild.&lt;/p&gt;

&lt;p&gt;Second, this constraint is itself worth writing about. The gap between "architecture you design" and "architecture you can afford to deploy on day one" is a real, common problem, and it's one most build-in-public content glosses over in favor of a clean narrative. The clean narrative isn't always the honest one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's unaffected by this&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The things that don't depend on which model is answering:&lt;/p&gt;

&lt;p&gt;147/147 tests passing across indicators (SMA, EMA, RSI, MACD, Bollinger, ATR), the signal engine (candlestick patterns, z-score mean reversion, momentum), and the backtesting engine (2-year minimum history, walk-forward validation, costs and slippage included).&lt;/p&gt;

&lt;p&gt;The AI never computes anything. Every number comes from deterministic Python. Whether the explanation comes from a local Phi-3.5 model or an OpenRouter-hosted model, its job is identical: describe an already-correct number in plain language, never generate one.&lt;/p&gt;

&lt;p&gt;The backtest gate. No signal reaches a user without proving itself against two years of historical data first, regardless of which model is running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's next&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Local inference returns once the hosting constraint lifts. I'll post that update when it happens, not before. Thanks for reading&lt;/p&gt;

</description>
      <category>ai</category>
      <category>fintech</category>
      <category>opensource</category>
      <category>python</category>
    </item>
    <item>
      <title>Nebula Agent: An AI That Surfaces Contradictions in Structured Content</title>
      <dc:creator>Victor</dc:creator>
      <pubDate>Sun, 27 Sep 2026 10:27:07 +0000</pubDate>
      <link>https://dev.to/vicarioy/nebula-agent-an-ai-that-surfaces-contradictions-in-structured-content-3760</link>
      <guid>https://dev.to/vicarioy/nebula-agent-an-ai-that-surfaces-contradictions-in-structured-content-3760</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/sanity-2026-09-16"&gt;Sanity Challenge, Path One: Ship an Agent That Queries Real Content&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;A &lt;strong&gt;Sanity-powered AI agent&lt;/strong&gt; that answers questions about a fictional board game called &lt;strong&gt;Nebula&lt;/strong&gt; and, crucially, &lt;strong&gt;surfaces contradictions between sources&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The agent doesn't just search for keywords. It writes its own &lt;strong&gt;GROQ queries(Graph Relational Object Queries)&lt;/strong&gt; against a Sanity Content Lake in real time, reads the structured articles it retrieves, and when two sources disagree and presents &lt;strong&gt;both claims side by side with their source URLs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The core question:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"According to the Nebula rulebook and errata, how many energy tokens do players start with? Are there any contradictions?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;The agent's answer:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The sources provide conflicting information:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nebula Core Rulebook - Setup&lt;/strong&gt; states players start with &lt;strong&gt;5 energy tokens&lt;/strong&gt; (&lt;a href="https://example.com/nebula-rulebook" rel="noopener noreferrer"&gt;source&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nebula Official Errata v2.1&lt;/strong&gt; corrects this to &lt;strong&gt;8 energy tokens&lt;/strong&gt; (&lt;a href="https://example.com/nebula-errata" rel="noopener noreferrer"&gt;source&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;A keyword search would have returned both articles. It would not have known which was authoritative, nor that the errata corrects the rulebook. My agent does this &lt;strong&gt;because the content was structured&lt;/strong&gt;.&lt;/p&gt;

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

&lt;p&gt;Since the agent runs in the terminal, here's a live transcript from two consecutive runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question 1_Energy tokens:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;text
👤 User: According to the Nebula rulebook and errata, how many energy tokens
   do players start with? Are there any contradictions?

🤖 Agent using tool: query_documents
   Args: {"query":"*[_type match \"*rule*\" || _type match \"*errata*\" || lower(title) match \"*rule*\" || lower(title) match \"*errata*\"]"}
   Result preview: [{"_type":"article","body":"To win Nebula, you must collect 10 stars..."}]

🤖 Agent: According to the Nebula rulebook and errata, there is a contradiction
regarding how many energy tokens players start with:

* Nebula Core Rulebook - Setup: States that each player starts with 5 energy
  tokens. Source: https://example.com/nebula-rulebook

* Nebula Official Errata v2.1: States that the core rulebook is incorrect and
  that the correct starting amount is 8 energy tokens.
  Source: https://example.com/nebula-errata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Question 2_Win condition:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;👤 User: What is the win condition for Nebula?

🤖 Agent using tool: query_documents
   Args: {"query":"*[_type == \"article\" &amp;amp;&amp;amp; (title match \"*Nebula*\" || body match \"*Nebula*\")]{title, body, source}"}
   Result preview: [{"body":"Many players struggle with energy management..."}]

🤖 Agent: The sources provide conflicting information regarding the win
condition for Nebula:

* According to the Nebula Core Rulebook and the strategy guide, you must
  collect 10 stars to win (Rulebook, Strategy Guide).

* According to the Nebula Official Errata v2.1, the win condition was updated
  to collecting 12 stars (Errata).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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/Vicarioy" rel="noopener noreferrer"&gt;
        Vicarioy
      &lt;/a&gt; / &lt;a href="https://github.com/Vicarioy/sanity-nebula-agent" rel="noopener noreferrer"&gt;
        sanity-nebula-agent
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &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;Nebula Sanity Agent&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;A command-line agent that uses Gemini to answer questions from article content stored in Sanity.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How It Works&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;The agent checks its Sanity connection, sends a built-in question about Nebula's win condition to Gemini, and answers using Sanity query results. It can query documents with GROQ or list the dataset's document types and fields. Its instructions require source URLs for claims and ask it to show contradictory sources side by side.&lt;/p&gt;

&lt;p&gt;The command prints the question, connection status, tool name, a result preview, and the final answer. Query arguments are not printed. Errors are written to stderr.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Stack&lt;/h2&gt;
&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;Node.js with ES modules&lt;/li&gt;
&lt;li&gt;Gemini model &lt;code&gt;gemini-3.5-flash-lite&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Sanity project &lt;code&gt;uyvc8si1&lt;/code&gt;, dataset &lt;code&gt;production&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;GROQ for Sanity queries&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Setup&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Run these commands from the &lt;code&gt;agent&lt;/code&gt; directory:&lt;/p&gt;

&lt;div class="highlight highlight-source-powershell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;npm install&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Create an &lt;code&gt;.env&lt;/code&gt; file in the &lt;code&gt;agent&lt;/code&gt; directory with both keys:&lt;/p&gt;

&lt;div class="highlight highlight-source-dotenv notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-v"&gt;GEMINI_API_KEY&lt;/span&gt;&lt;span class="pl-k"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;your-gemini-api-key&lt;/span&gt;
&lt;span class="pl-v"&gt;SANITY_API_TOKEN&lt;/span&gt;&lt;span class="pl-k"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;your-sanity-api-token&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;The Sanity token must have…&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/Vicarioy/sanity-nebula-agent" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Stack:&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Node.js&lt;br&gt;&lt;br&gt;
Gemini 3.5 Flash Lite (free tier)&lt;br&gt;&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/sanity"&gt;@sanity&lt;/a&gt;/client for GROQ queries&lt;br&gt;&lt;br&gt;
@google/genai for function calling&lt;/p&gt;

&lt;h2&gt;
  
  
  How i used Sanity
&lt;/h2&gt;

&lt;p&gt;** Structured Content Model**&lt;br&gt;
i defined a single document type in sanity studio using typescript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineType&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="s1"&gt;article&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Article&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;document&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&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="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&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="s1"&gt;slug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Slug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;slug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&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="s1"&gt;body&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Body&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;defineField&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="s1"&gt;source&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Source&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;url&lt;/span&gt;&lt;span class="dl"&gt;'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I populated it with three documents that set up a deliberate contradiction:&lt;/p&gt;

&lt;p&gt;The title and the source for each of the documents are:&lt;br&gt;
Nebula Core Rulebook; The Setup - &lt;a href="https://example.com/nebula-rulebook" rel="noopener noreferrer"&gt;https://example.com/nebula-rulebook&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nebula Official Errata - &lt;a href="https://example.com/nebula-errata" rel="noopener noreferrer"&gt;https://example.com/nebula-errata&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;How to Win at Nebula - &lt;a href="https://example.com/nebula-strategy" rel="noopener noreferrer"&gt;https://example.com/nebula-strategy&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;(The source URLs are placeholder examples. In a real deployment, this field would point to the actual publisher page.)&lt;/p&gt;
&lt;h2&gt;
  
  
  How the agents read and queries Content
&lt;/h2&gt;

&lt;p&gt;The agent uses function calling with Gemini. It has one primary tool and it was coded out with javascript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;name:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"query_documents"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;description:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Run a GROQ query against the Sanity dataset. Returns JSON."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;parameters:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;properties:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="err"&gt;query:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;description:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A GROQ query string."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;required:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the User asks a question, the LLM:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Writes a GROQ query itself: an example- *[_type == "article"]{title, body, source}&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;My code executes it via &lt;a class="mentioned-user" href="https://dev.to/sanity"&gt;@sanity&lt;/a&gt;/client against project uyvc8sil, dataset production&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The JSON result is fed back into the LLM's Context&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The LLM reads the content and answers with citations&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The system prompt tells the model: &lt;br&gt;
"When two sources contradict each other, show both claims side by side with their sources. Cite the source URL for every claim. Never invent information."&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent does with retrieved content
&lt;/h2&gt;

&lt;p&gt;Because the content is structured, the agent can do the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Compare specific fields across documents; it reads body from the rulebook and body from the errata and notices the numeric values differ.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Attribute each claim to its source URL; the citations aren't guessed, they're data.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Distinguish types of documents; the errata is authoritative over the rulebook because its title says so.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A plain-text search would have surfaced both documents but had no way to reason about which was newer, more authoritative, or even that they contradicted each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designs decisions that mattered
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Schema-informed prompting; The LLM initially guessed type names like card and document, returning empty results. I injected a schema description into the system prompt so it knows to query *[_type == "article"]. This is a small detail that helps to dramatically improved reliability.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Sanity as the source of truth(where the articles are confirmed from); No content is hardcoded in the agent. Every fact the agent reports comes from a live GROQ query against the Content Lake. If I update an article in the Studio, the next question gets the updated answer.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;useCdn(false); I disabled the CDN for the agent so responses are always fresh from the Content Lake and always 100% Real Time. This matters for an agent where "the latest errata" is the whole point. &lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Sanity Project Details
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Project ID: uyvc8sil&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Dataset: production&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Document type: article (fields: title, slug, body, source)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;I learned a lot because this is my first time of hearing about sanity. I learned how to made the case for structured content viscerally clear. So this provide me an opportunity that an LLM can reason about provenance when we have fields like title, body, and source defined in a schema.  This led me to understand the difference between these statements "here are two documents" and "these two sources disagree, and here's the URL for each claim".&lt;/p&gt;

&lt;p&gt;The hardest part wasn't the LLM logic, it was teaching the agent the schema. Once I stopped trying to make the model guess type names and instead told it was in the content Lake, everything clicked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thanks
&lt;/h2&gt;

&lt;p&gt;Thanks to Sanity and DEV for the challenge. It pushed me to build something I'd been wanting to try: an agent that treats structured content as the foundation, not an afterthought.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>sanitychallenge</category>
      <category>sanity</category>
      <category>ai</category>
    </item>
    <item>
      <title>Correlation vs. Causation: What They Actually Mean</title>
      <dc:creator>Victor</dc:creator>
      <pubDate>Mon, 14 Sep 2026 18:48:49 +0000</pubDate>
      <link>https://dev.to/vicarioy/correlation-vs-causation-what-they-actuallymean-1ejl</link>
      <guid>https://dev.to/vicarioy/correlation-vs-causation-what-they-actuallymean-1ejl</guid>
      <description>&lt;p&gt;" Correlation isn't causation" is one of the most repeated phrases in data science and one of the least explained. Here's what each term actually means, why the confusion happens, and four specific ways it goes wrong in practice.&lt;/p&gt;

&lt;p&gt;If you've spent any time around data, you've heard "correlation doesn't imply causation" so many times it's become background noise. But if you stopped someone and asked them to define correlation and causation separately, in plain words, a lot of people would struggle. So let's start there, before touching any of the traps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Is Correlation?&lt;/strong&gt;&lt;br&gt;
Correlation is simply a measure of how two things tend to move together. If one goes up and the other usually goes up too, that's a positive correlation. If one goes up while the other tends to go down, that's a negative correlation. If they seem to have nothing to do with each other, that's little to no correlation.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A simple, real example:&lt;/em&gt; height and shoe size are correlated. Taller people tend to have bigger feet. That's a positive correlation, and it's intuitive, but notice we haven't said anything yet about why.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Correlation is purely descriptive:&lt;/em&gt; it tells you two variables tend to change together, and roughly how strongly, using a number called the correlation coefficient (often written as r), which ranges from -1 (perfectly opposite) to +1 (perfectly together), with 0 meaning no relationship at all. That's the entire job of correlation: describe a pattern. It has no opinion on what's causing that pattern, or whether anything is causing it at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What Is Causation?&lt;/strong&gt;&lt;br&gt;
Causation is a stronger, different claim: it means one variable directly produces a change in another. There's a mechanism connecting them, and if you intervened and changed the first variable, and only that variable, the second one would change as a result.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Take a simple example:&lt;/em&gt; turning up your oven's temperature causes your food to cook faster. If you change the temperature, cooking time changes because of that action. There's a direct, physical mechanism (more heat transfers energy faster), and you can test it by changing nothing else and watching the result change predictably.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A useful mental test for causation:&lt;/em&gt; "If I could reach in and change only this one thing, would the other thing change as a result?" If yes, that's a causal relationship. If you can't actually intervene, or if changing one doesn't reliably move the other, you're probably just looking at correlation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why We Keep Mixing Them Up&lt;/strong&gt;&lt;br&gt;
Human brains are pattern-matching machines; we're wired to notice when two things happen together and jump to "one must be causing the other," because that instinct was useful for survival long before it was useful for data analysis. The problem is that correlation is easy to measure (a single formula gives you a number) while causation is hard to establish (it usually requires careful experiments or reasoning about mechanisms). So we default to the easy signal and treat it like the hard one.&lt;/p&gt;

&lt;p&gt;This is exactly where the four classic traps come in; they're the specific ways a real, measurable correlation can exist without any real causation behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confounding Variables (The Third-Variable Problem)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This happens when a hidden third factor is actually driving both variables you're looking at, making them look connected to each other when they're really both just reacting independently to something else. &lt;/p&gt;

&lt;p&gt;&lt;em&gt;The classic case:&lt;/em&gt; ice cream sales and shark attacks both rise in summer. It's not that ice cream causes attacks, or attacks cause ice cream sales warmer weather (the confounder) independently drives more people to buy ice cream and more people into the ocean at the same time. The confounder never shows up if you only look at the two headline variables.&lt;/p&gt;

&lt;p&gt;In real analysis work, common hidden confounders include location, income, age, and season  they quietly explain relationships that look meaningful on the surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reverse Causality&lt;/strong&gt;&lt;br&gt;
Sometimes the relationship is genuinely causal, just pointing the opposite direction from what you assumed. A model might show "customers who contact support more often churn more," tempting you to conclude support interactions drive churn. &lt;/p&gt;

&lt;p&gt;&lt;em&gt;Just as plausible:&lt;/em&gt; customers who are already unhappy and about to leave contact support more because they're frustrated. The outcome is quietly driving the presumed cause.&lt;/p&gt;

&lt;p&gt;Reverse causality is sneaky because the correlation itself carries no signal about which direction is correct you need outside knowledge, timing data, or a controlled study to untangle it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coincidence (Spurious Correlations)&lt;/strong&gt;&lt;br&gt;
Given enough variables and enough time, some completely unrelated trends will line up purely by chance. This is the trap behind the famous "spurious correlations" examples; like per-capita cheese consumption tracking almost perfectly with the number of people who died tangled in their bedsheets. There is no mechanism connecting them at all. With enough random series being compared, some pair will always match closely just by luck which is why testing dozens of variables against each other without a hypothesis ("data dredging") is such a well-known danger in statistics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Selection Bias&lt;/strong&gt;&lt;br&gt;
This trap comes from how the data was collected, not from the variables themselves. If your sample isn't representative of the population you're trying to understand, you can manufacture a statistical link that isn't real or hide one that is.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A classic case:&lt;/em&gt; surveying only customers who already responded to a marketing email makes that email look more effective than it is, because people who never open emails are systematically missing from the data. The relationship you're seeing was baked in by how the sample was built, not by anything the two variables are doing to each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So How Do You Actually Prove Causation?&lt;/strong&gt;&lt;br&gt;
If correlation alone can't do it, what can? A few real tools researchers and data teams use:&lt;br&gt;
● &lt;em&gt;Randomized controlled trials (RCTs):&lt;/em&gt; randomly split a group in two, change one variable for one group only, and compare outcomes. Randomization cancels out confounders on average, so any difference in outcome can be attributed to the variable you changed.&lt;br&gt;
● &lt;em&gt;Natural experiments:&lt;/em&gt; when you can't randomize, look for situations where something close to random chance assigned the variable anyway (like a policy that took effect in one region but not a neighboring one).&lt;br&gt;
● &lt;em&gt;Statistical controls: _techniques like propensity score matching or regression with control variables that try to account for known confounders mathematically, when an experiment isn't possible.&lt;br&gt;
● _A believable mechanism:&lt;/em&gt; even strong statistical evidence is more convincing when there's a plausible, explainable reason why one thing would cause the other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Quick Checklist Before You Claim Causation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;● Could a third factor explain both variables?&lt;br&gt;
● Could the arrow of cause and effect be pointing the other way?&lt;br&gt;
● How many other variables did I compare before I found this one?&lt;br&gt;
● Does my sample actually represent the population I'm making claims about?&lt;br&gt;
● Do I have experimental evidence, or just an observed pattern?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why This Matters&lt;/strong&gt;&lt;br&gt;
None of this means correlation is useless a strong correlation is a great lead. It tells you where to look. The mistake isn't noticing the pattern; it's stopping there and calling it an explanation. Treat correlation as the start of an investigation, not the end of one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"""Demonstrating why correlation does not prove causation in marketing.

Scenario:
- A company spends more on ads during the holiday season.
- Customers also buy more during the holiday season.
- Holiday demand causes both variables to increase.
- Marketing spend has no causal effect in this simulation.
"""

import numpy as np

rng = np.random.default_rng(42)
months = np.arange(24)
holiday_season = (months % 12 &amp;gt;= 9).astype(float)

# Holiday demand is a hidden third variable (a confounder).
marketing_spend = 10 + 20 * holiday_season + rng.normal(0, 2, size=24)
sales = 100 + 80 * holiday_season + rng.normal(0, 5, size=24)

correlation = np.corrcoef(marketing_spend, sales)[0, 1]

print(f"Correlation between marketing spend and sales: {correlation:.2f}")
print("A high correlation alone does not prove that marketing caused the sales.")
print("In this simulation, the true causal effect of marketing spend is zero.")

# Once we compare months within the same season, the misleading relationship
# largely disappears because the hidden seasonal factor is held constant.
non_holiday = holiday_season == 0
holiday = holiday_season == 1

non_holiday_correlation = np.corrcoef(marketing_spend[non_holiday], sales[non_holiday])[
    0, 1
]
holiday_correlation = np.corrcoef(marketing_spend[holiday], sales[holiday])[0, 1]

print(f"Correlation in non-holiday months: {non_holiday_correlation:.2f}")
print(f"Correlation in holiday months: {holiday_correlation:.2f}")
print("To test causation, use a randomized marketing experiment or A/B test.")

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>datascience</category>
      <category>statistics</category>
      <category>machinelearning</category>
      <category>aiengineering</category>
    </item>
    <item>
      <title>HealthBridge: Engineering an Offline, CPU-Only LLM to Bridge Nigeria’s 1:4,000 Doctor-to-Patient Gap</title>
      <dc:creator>Victor</dc:creator>
      <pubDate>Sun, 06 Sep 2026 16:12:47 +0000</pubDate>
      <link>https://dev.to/vicarioy/healthbridge-engineering-an-offline-cpu-only-llm-to-bridge-nigerias-14000-doctor-to-patient-gap-34b6</link>
      <guid>https://dev.to/vicarioy/healthbridge-engineering-an-offline-cpu-only-llm-to-bridge-nigerias-14000-doctor-to-patient-gap-34b6</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-09-03"&gt;Weekend Challenge: Generosity Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;I built HealthBridge; an offline, CPU-only AI assistant designed to tackle Nigeria's severe doctor shortage and connectivity barriers.&lt;/p&gt;

&lt;p&gt;Specifically, here is exactly what I delivered:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;A fully offline LLM-powered health education tool&lt;br&gt;
I packaged a quantized large language model (Qwen2.5-1.5B-Instruct in GGUF Q4_K_M) so it runs entirely on a standard laptop's CPU. No internet, no GPU, and no recurring cloud costs making it viable for rural and peri-urban Nigerian communities where connectivity is spotty or unaffordable.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A triage and patient education interface&lt;br&gt;
I built an interactive assistant (working through the Streamlit interface challenges you saw in the report) that does four specific jobs for community health workers, patients, and caregivers:&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;. Explains common symptoms in plain language.&lt;/p&gt;

&lt;p&gt;. Describes basic treatments (e.g., oral rehydration therapy for cholera/diarrhea).&lt;/p&gt;

&lt;p&gt;. Guides users on when to urgently seek hospital care (red-flag triage).&lt;/p&gt;

&lt;p&gt;. Actively steers people away from seeking advice from unqualified friends, neighbours, or unregistered local pharmacies.&lt;/p&gt;

&lt;p&gt;Crucially, it is strictly scoped as a complement to professionals; it never claims to replace clinical diagnosis.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;A lightweight, resource-optimized runtime&lt;br&gt;
I chose the llama.cpp runtime and carefully picked the Q4_K_M quantization to hit the sweet spot: coherent, medically-contextual responses while fitting comfortably inside an 8GB RAM budget (peaks at just 1.7 GB during my tests). The model runs at 16.76 tokens/second on integrated graphics; no discrete GPU required.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A zero-dependency deployment pipeline&lt;br&gt;
I set up a download_model.sh script to fetch the model via Hugging Face, but after that initial download, the entire inference pipeline runs completely offline. I ensured the model file itself isn't committed to git, so evaluators can pull it fresh and run it immediately on the ADTC standard laptop (10th–12th gen i5, 8GB DDR4, Ubuntu 22.04).&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In short: I built a portable, cost-free, offline health-information kiosk-in-a-laptop that gives populations accurate, actionable guidance—bridging the gap between community pharmacies and overstretched hospitals, without ever pretending to be a doctor. &lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://youtu.be/PI3vzJGBDSA" rel="noopener noreferrer"&gt;https://youtu.be/PI3vzJGBDSA&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://github.com/Vicarioy/adtc-2026-submission-template.git" rel="noopener noreferrer"&gt;https://github.com/Vicarioy/adtc-2026-submission-template.git&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Here is the technical walkthrough of how I built HealthBridge; covering the architecture, the tough trade-offs, and the exact steps I took to wrestle it onto resource-constrained hardware.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Model Selection: The "Goldilocks" Search
The first decision was choosing the brain. I couldn't just pick the biggest model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I tested: I ran local benchmarks with Phi-3-mini-4k (3.8B—too much RAM, risked exceeding the 8GB target), Qwen2.5-0.5B (fit easily but gave vague, sometimes incorrect health advice), and Llama-3.2-1B (decent, but weaker at following multi-turn health Q&amp;amp;A prompts).&lt;/p&gt;

&lt;p&gt;The decision: I chose Qwen2.5-1.5B-Instruct. It sits perfectly in the middle strong enough to reason through symptom-checking and triage logic, yet small enough to leave headroom for the OS and interface. Its multilingual tokenizer also helps handle local language nuances when users describe symptoms in pidgin or their native tongue.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Quantization Strategy (The Accuracy-vs-Memory Trade-off)
A raw 1.5B FP16 model wouldn't fit in 8GB comfortably, so I needed compression.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I rejected Q4_0 (too much precision loss—critical for medical context) and Q5/Q8 (too heavy).&lt;/p&gt;

&lt;p&gt;I settled on GGUF Q4_K_M. This uses K-means grouping on the most important weight matrices. It applies 4-bit quantization but allocates bits more intelligently across layers. This preserves the model's "reasoning" capability far better than flat quantization, giving me medically coherent responses while keeping peak RAM at just 1.7GB, well under the 8GB ceiling.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Runtime &amp;amp; Inference Engine (llama.cpp)
The competition rules strictly mandated llama.cpp, but honestly, it was the perfect choice anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I compiled llama.cpp with standard x86-64 CPU optimizations (leveraging AVX2 instructions on Intel chips).&lt;/p&gt;

&lt;p&gt;I wrote a Python wrapper that uses llama-cpp-python to interface with the .gguf file. The key tweak here was setting n_ctx=32768 (to utilize Qwen's full context window for long patient histories) but carefully tuning n_batch to 512 to maximize throughput without blowing up the CPU cache.&lt;/p&gt;

&lt;p&gt;This setup gave me 16.76 tokens/second on an integrated UHD 620; fast enough for real-time conversation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Interface Nightmare (Where I Almost Broke)
You noted in the report that building the interface was a massive problem, and you're right. Streamlit kept timing out or failing silently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The issue: Loading a 1.5B GGUF model inline during Streamlit's startup sequence blocks the main thread. Streamlit's watchdog timer would kill the process if the model loaded slower than ~10 seconds.&lt;/p&gt;

&lt;p&gt;How I fixed it: I decoupled the model lifecycle. Instead of loading the model globally, I wrapped it inside a st.cache_resource decorator with lazy initialization meaning the model only loads on the first user prompt, not when the page boots. I also spawned the llama.cpp inference in a separate background thread with a queue, so the UI remains responsive while the model warms up. This single architectural shift made the app stable and prevented timeouts.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deployment &amp;amp; Zero-Connectivity Pipeline
Since internet is the problem, I had to make internet the non-requirement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wrote a download_model.sh script to fetch the .gguf from Hugging Face only once during setup.&lt;/p&gt;

&lt;p&gt;I strictly excluded the ~900MB model file from git using .gitignore. The evaluator runs the script, grabs the model, and from that point forward, every inference call hits the local filesystem. No API keys, no network retries, no cloud latency.&lt;/p&gt;

&lt;p&gt;The entire app runs on localhost; I pinned the environment dependencies (llama-cpp-python, streamlit) to specific versions to guarantee reproducible builds on the ADTC Ubuntu 22.04 target.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Domain-Specific Prompt Engineering
Because I'm legally and ethically scoped to education/triage, not diagnosis, I hardcoded a system prompt that:&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Explicitly states: "I am an educational assistant, not a doctor. Do not take my advice as a clinical diagnosis."&lt;/p&gt;

&lt;p&gt;Guides the model to always include a "Red Flag" section in responses (e.g., "Go to a hospital immediately if you see blood in stool or have a high fever over 39°C").&lt;/p&gt;

&lt;p&gt;Instructs the model to actively deflect dangerous queries if a user asks for a prescription, it responds with safety advice and urges them to visit an MDCN-registered practitioner.&lt;/p&gt;

&lt;p&gt;Prize Technology Highlight: This project squarely falls into Edge AI / On-Device AI categories. By combining gguf quantization with llama.cpp, I built a production-ready, CPU-only LLM that democratizes health information in bandwidth-scarce regions; proving that impactful AI doesn't require the cloud. &lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
    </item>
    <item>
      <title>5 ML Math Concepts I Stopped Memorizing (And Started Actually Using)</title>
      <dc:creator>Victor</dc:creator>
      <pubDate>Sun, 06 Sep 2026 15:16:26 +0000</pubDate>
      <link>https://dev.to/vicarioy/5-ml-math-concepts-i-stopped-memorizing-and-started-actually-using-md5</link>
      <guid>https://dev.to/vicarioy/5-ml-math-concepts-i-stopped-memorizing-and-started-actually-using-md5</guid>
      <description>&lt;p&gt;I've been deep in linear algebra and calculus for the better part of a year. But up until this week, I realized i was still treating core ML math like the chain rule and eigenvalue; as a syllabus checklist. Week 3 of my structured deep dive is when i finally forced myself to stop, derive them by hand and connect them to actual code using numpy. Here are some of the concepts that finally clicked:&lt;br&gt;
&lt;strong&gt;1. The Chain Rule Is the Engine Behind Backpropagation&lt;/strong&gt;&lt;br&gt;
Every neural network learns by adjusting its weights, and it knows which direction to adjust them in because of the chain rule. A network is really just a stack of functions: input goes through a layer, then an activation function, then another layer, and so on. To know how much a single weight buried deep in that stack contributed to the final error, you need to differentiate through every function it passed through. That's exactly what the chain rule lets you do. I traced this end to end: starting from gradient descent (which says "move the weights a little in the&lt;br&gt;
direction that reduces error"), through the activation function's own derivative, back to the weight itself. Once I could follow that chain by hand instead of trusting a library to do it, backpropagation stopped feeling like magic and started feeling like bookkeeping.&lt;br&gt;
&lt;strong&gt;2. The Gaussian (Normal) Distribution Is Everywhere in ML&lt;/strong&gt;&lt;br&gt;
The bell curve isn't just a statistics-class cliché; it shows up constantly in machine learning: in how we initialize weights, in the noise assumptions behind linear regression, in probabilistic models, and in how we reason about errors clustering around a mean. Understanding its two parameters; mean and variance, is really understanding "where is the data centered" and "how spread out isit," which turns out to be one of the most reused ideas in the entire field.&lt;br&gt;
&lt;strong&gt;3. Eigenvalues Are the Secret Behind Face Recognition&lt;/strong&gt;&lt;br&gt;
This was the concept that surprised me most. Eigenvalues and eigenvectors which sound purely academic are the foundation of techniques like PCA (Principal Component Analysis) and the classic "Eigenfaces" approach to face recognition. The idea: a face image has thousands of pixels, but most of the meaningful variation between different faces can be captured by a much smaller set of directions, the eigenvectors with the largest eigenvalues. Compress along those directions, and you keep what matters while discarding noise. Linear algebra, doing real work.&lt;br&gt;
&lt;strong&gt;4. The Laplace Rule of Succession: Smoothing for Probability&lt;/strong&gt;&lt;br&gt;
What's the probability of an event you've never seen happen? Naively, zero but that's usually wrong and dangerous in ML, especially in text and classification models where an unseen word or category shouldn't automatically get a probability of zero. The Laplace rule of succession solves this by adding a small smoothing count to every outcome, so nothing is ever assigned a flat-out impossible probability. It's a simple idea with a name that sounds far more intimidating than it is.&lt;br&gt;
&lt;strong&gt;5. Sampling and Confidence Intervals: Choosing Data You Can&lt;br&gt;
Trust&lt;/strong&gt;&lt;br&gt;
You can't train on an entire population, so you sample from it; but that raises an obvious question: how do you know your sample actually represents the population? This is where confidence intervals come in. They give you a principled range around an estimate, along with a stated level of certainty, so that when you pick a dataset you know how much to trust conclusions drawn from it. This felt directly relevant to my own work, since a lot of real-world data (like the regional health data in my thesis project) is sampled, imperfect, and needs exactly this kind of scrutiny.&lt;br&gt;
&lt;strong&gt;Why This Matters&lt;/strong&gt;&lt;br&gt;
None of these five ideas are new inventions, they're decades old. But sitting with the math instead of skipping to the library call changed how I read papers. When something behaves strangely, I now have a better instinct for whether the culprit is a distributional assumption, a smoothing issue, or a bad sample, instead of just tuning hyperparameters and hoping.&lt;br&gt;
Next up: going deeper into differential calculus and connecting it more directly to optimizers beyond&lt;br&gt;
plain gradient descent.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>mlmath</category>
      <category>linearalgebra</category>
      <category>statistics</category>
    </item>
  </channel>
</rss>
