<?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: Hassan Imam</title>
    <description>The latest articles on DEV Community by Hassan Imam (@hassan_shah_733ea1eb37c88).</description>
    <link>https://dev.to/hassan_shah_733ea1eb37c88</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%2F3919991%2F2abd43d0-6aa6-47be-a38a-ca0e563dc2d5.jpg</url>
      <title>DEV Community: Hassan Imam</title>
      <link>https://dev.to/hassan_shah_733ea1eb37c88</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hassan_shah_733ea1eb37c88"/>
    <language>en</language>
    <item>
      <title>I Built an AI That Remembers the Promises We Forget</title>
      <dc:creator>Hassan Imam</dc:creator>
      <pubDate>Sat, 24 Oct 2026 12:27:09 +0000</pubDate>
      <link>https://dev.to/hassan_shah_733ea1eb37c88/i-built-an-ai-that-remembers-the-promises-we-forget-9db</link>
      <guid>https://dev.to/hassan_shah_733ea1eb37c88/i-built-an-ai-that-remembers-the-promises-we-forget-9db</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week3-2026-10-19"&gt;Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;The report was supposed to arrive yesterday. Somewhere in last week's notes, someone said they would send it. Before asking for an update, a researcher has to reconstruct the conversation: who offered, what they offered, and whether there was actually a deadline.&lt;/p&gt;

&lt;p&gt;That is the fictional situation in my explicitly synthetic demo. It captures a familiar kind of work: remembering the small agreements that never became formal tasks.&lt;/p&gt;

&lt;p&gt;I built PromiseKeeper to turn those agreements into a queue a person can check. Import meeting notes, a chat transcript, or a text file. A local language model proposes commitments. Every suggestion stays beside the exact words that support it. The reviewer can approve it, correct it, dismiss it, or mark it complete.&lt;/p&gt;

&lt;p&gt;An approved promise can then become a polite follow-up draft. The person reviews and edits the wording, and decides whether to send it. PromiseKeeper has no sending integration.&lt;/p&gt;

&lt;p&gt;The app is built around the difference between a possible promise and a confirmed one. “I'll send the report” deserves attention. “I already sent the report” describes something finished. “If the samples clear, I'll upload the appendix” carries a condition that should stay attached. An uncertain date should remain uncertain.&lt;/p&gt;

&lt;p&gt;The interface gives those distinctions room: an evidence review inbox, upcoming and overdue summaries, a timeline, follow-up drafts, and local privacy controls. It supports pasted text and &lt;code&gt;.txt&lt;/code&gt; or &lt;code&gt;.md&lt;/code&gt; files. SQLite stores the sources and review decisions on the same machine as the local model.&lt;/p&gt;

&lt;p&gt;The desired payoff is modest and concrete: bring the relevant promise back into view, with enough context to decide the next step.&lt;/p&gt;

&lt;p&gt;I designed the workspace around reading and deciding: a left sidebar, vertically arranged input fields, and charcoal, forest-green and mint surfaces. Source quotes stay readable inside each card. The mobile drawer supports keyboard focus and Escape, and the help dialog explains the review boundary. Follow-up drafts can be copied or downloaded; source deletion lets the person check exactly what they are removing.&lt;/p&gt;

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

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/rhyfJHkMmrs" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=rhyfJHkMmrs" rel="noopener noreferrer"&gt;Watch the demo on YouTube&lt;/a&gt;. The redesigned interface is demonstrated in a 112.36-second, 1920x1080 recording narrated by genuine Kokoro-82M using &lt;code&gt;af_sarah&lt;/code&gt;. The local export is saved at &lt;code&gt;demo/assets/video/final_demo.mp4&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The recording follows a researcher importing five snippets of synthetic notes. Actual local Qwen inference finds three candidates: a revised field report, cleaned survey figures, and a conditional appendix. The already-delivered consent forms and vague conference thought stay out of the action queue.&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%2Fumfma360o6t4epyiqu5i.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%2Fumfma360o6t4epyiqu5i.png" alt="Synthetic research notes in the actual import screen" width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The reviewer checks the quotes, approves the report, and sees one confirmed promise past its date. Two suggestions remain available for review.&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%2F4dsf3rqs1ymz2y0oj4ux.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%2F4dsf3rqs1ymz2y0oj4ux.png" alt="Exact source quotes beside the extracted suggestions" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuaurwl60e6tyeqlp1ael.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%2Fuaurwl60e6tyeqlp1ael.png" alt="Dashboard after approving the overdue report" width="799" height="655"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The final action is preparing an editable follow-up. The draft asks whether the recorded timing has shifted; it does not assume the person has failed to deliver.&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%2Feo91vmd9lttnoqrc2rrc.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%2Feo91vmd9lttnoqrc2rrc.png" alt="An editable follow-up draft that the user must review" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1155st4uo0b83dlymm8n.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%2F1155st4uo0b83dlymm8n.png" alt="Local model status, export and data deletion controls" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These are screenshots of the working application. The video preserves the browser's original action timing, with added scene holds for narration. Its edited runtime is not a processing benchmark.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://github.com/hassan-2050/PromiseKeeper" rel="noopener noreferrer"&gt;Browse PromiseKeeper on GitHub&lt;/a&gt;. The repository includes the frontend, backend, synthetic evaluation corpus, API tests, browser smoke test, real application screenshots, and recording and narration scripts. The recording and narration scripts reproduce the demo; generated video and WAV files are local outputs.&lt;/p&gt;

&lt;p&gt;The code is MIT licensed. The selected Qwen model and Kokoro weights have their own licenses and are downloaded separately.&lt;/p&gt;

&lt;p&gt;The manual setup uses Python 3.12+, Node 22+, and Ollama. Pull &lt;code&gt;qwen3:8b&lt;/code&gt;, start the FastAPI service on port 9000, and run the Next.js frontend. The README contains the commands and a Docker Compose option. There is no paid inference service to configure.&lt;/p&gt;

&lt;p&gt;Useful places to begin reading are &lt;code&gt;backend/app/main.py&lt;/code&gt; for the extraction and review boundary, &lt;code&gt;app/page.tsx&lt;/code&gt; for the user journey, and &lt;code&gt;backend/eval/run_evaluation.py&lt;/code&gt; for the measured synthetic evaluation. &lt;code&gt;demo/record_demo.ts&lt;/code&gt; and &lt;code&gt;demo/narrate_demo.py&lt;/code&gt; reproduce the screen recording and voiceover.&lt;/p&gt;

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

&lt;p&gt;The frontend uses Next.js and TypeScript. FastAPI handles import, local inference, validation, review updates, drafting, export and source deletion. SQLAlchemy stores the relationships in SQLite.&lt;/p&gt;

&lt;p&gt;The processing path is small enough to inspect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Notes → source document and paragraph fragments
      → local Ollama / Qwen extraction
      → schema validation and source-quote check
      → evidence-linked suggestions in SQLite
      → human review → approved promise → editable draft
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The configured extractor is Qwen3 8B through Ollama. The locally inspected model reports 8.2 billion parameters and Q4_K_M quantization. The evaluation records the model digest, rather than relying only on a tag that could later point to different weights. Structured extraction runs at temperature zero with reasoning disabled.&lt;/p&gt;

&lt;p&gt;The model proposes an action, owner, recipient, deadline, condition, certainty, evidence quote and next step. Pydantic checks the shape of those values. The API then independently verifies that each evidence quote is a literal substring of the original source text. A plausible quote that does not occur in the notes is rejected.&lt;/p&gt;

&lt;p&gt;That boundary is useful, but deliberately limited. A quote can be present and still fail to support the proposed owner or claim. The source check establishes provenance; the reviewer still has to establish meaning. The interface keeps the quote beside the action so that check can happen where the decision is made.&lt;/p&gt;

&lt;p&gt;The backend also applies conservative lexical rejection to obvious refusals, completed actions and hypothetical statements. An earlier development run exposed false positives in these cases. The final measurements therefore describe the combined prompt, model, schema checks and filters. They are not a score for the base model alone.&lt;/p&gt;

&lt;p&gt;Dates need a similar distinction. Explicit dates can be stored directly. Relative timing can be interpreted only when a source timestamp is known. Without that anchor, the date stays unresolved. A missing owner is allowed to remain missing too; filling a field with a confident guess would make the queue less trustworthy.&lt;/p&gt;

&lt;p&gt;Each source is split into paragraph fragments with character offsets. Evidence records preserve quotes and their locations. When the same extraction key repeats within an import, the API groups it while retaining distinct supporting quotes. This is normalized exact grouping, not semantic deduplication. Two differently phrased obligations can remain separate, and exact grouping has its own limits when conditions differ. Broader similarity matching needs more work before it should alter a person's action queue.&lt;/p&gt;

&lt;p&gt;Review changes create audit events. A source deletion removes its linked commitments and drafts. The dashboard counts confirmed overdue items, and CSV export includes approved commitments with their evidence. The prototype is intended for a person's local machine; shared hosting, account permissions and encrypted storage are separate deployment concerns.&lt;/p&gt;

&lt;p&gt;Follow-up drafting uses a deterministic template assembled from approved fields. It does not ask a second model to invent background context, recipients, or excuses. The local model's core job is finding candidate obligations in unstructured conversation. Once the person approves the fields, predictable drafting keeps the next step inspectable.&lt;/p&gt;

&lt;p&gt;I checked the implementation with four backend API tests and a production build including TypeScript checks. The browser journey uses genuine local Qwen inference and covers file loading, quote inspection, owner/date/note edits, approval, editable draft copy and download, the timeline, CSV export, completion, source deletion and cancellation, help, mobile keyboard navigation and dark mode. A later import is checked to ensure it does not inherit the previous source timestamp. Automated axe checks report zero violations for the selected rules across 16 light/dark page, dialog and mobile-drawer states; that is a scoped automated check, not a complete manual accessibility audit. The recording runs the actual browser, backend and local model.&lt;/p&gt;

&lt;p&gt;For extraction, I authored 55 labelled synthetic cases across 11 categories. The preserved run produced these results:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Measured result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Case-level commitment classification&lt;/td&gt;
&lt;td&gt;40 true positives, 0 false positives, 0 false negatives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Owner exact match&lt;/td&gt;
&lt;td&gt;30 of 35 eligible cases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Explicit calendar-date exact match&lt;/td&gt;
&lt;td&gt;5 of 5 eligible cases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accepted quote substring check&lt;/td&gt;
&lt;td&gt;41 of 41 accepted candidate quotes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The owner result is the most useful warning in that table: the system can identify a possible obligation and still attribute it incorrectly. In this corpus, implicit-owner examples account for the five owner mismatches.&lt;/p&gt;

&lt;p&gt;The perfect classification result is a small development-set observation. These cases are authored, repetitive, and were used while improving the pipeline. Quote validity is also an enforced acceptance rule, so its perfect rate is not independent evidence of understanding. The evaluation does not score claim entailment and does not cover sarcasm, multilingual conversations, noisy transcription, contradictions, or the full range of date language. The five date examples are a narrow check.&lt;/p&gt;

&lt;p&gt;The corpus, evaluator and per-case outputs are preserved so a reader can inspect those limits and add harder examples. Real conversations and a held-out evaluation would be the next useful evidence.&lt;/p&gt;

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

&lt;p&gt;A commitment assistant handles information that can be mundane in isolation and sensitive in combination: who is waiting for whom, which deadline is slipping, and what people agreed to in a private discussion.&lt;/p&gt;

&lt;p&gt;Running an open-weight extractor locally makes the data path understandable. After the model has been downloaded, inference does not need a hosted model account. The notes go from the local app to the configured local Ollama service, and the resulting records stay in SQLite. The demo uses synthetic material, but the architecture is chosen for conversations a person would want to keep private.&lt;/p&gt;

&lt;p&gt;Open components also make failure inspectable. I can retain a failing synthetic example, adjust the prompt or acceptance filter, and run the same evaluator again. A model digest, a source quote, and a saved result give someone else something concrete to investigate. They can replace the model adapter and keep the validation and human review boundaries.&lt;/p&gt;

&lt;p&gt;There are costs to that choice. An eight-billion-parameter model needs a substantial download and enough memory. Local inference can be slow or unavailable on a particular computer. Initial setup is more work than entering a hosted API key. The UI exposes model availability and processing failures because those constraints affect the user's experience.&lt;/p&gt;

&lt;p&gt;The same approach carries into the video. Kokoro runs locally, its selected voice and weight hashes are recorded, and the narration script and caption timings are included. The demonstration can be regenerated from the project instead of depending on an unexplained media asset.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lazy Payoff
&lt;/h2&gt;

&lt;p&gt;The visible transformation is five raw snippets becoming three candidate obligations, then one approved overdue report and an editable follow-up. The person makes the important decisions while the app gathers the relevant words into one place.&lt;/p&gt;

&lt;p&gt;I have not measured saved hours, fewer missed deadlines, or reduced stress. The synthetic demonstration shows a workflow, and the evaluation measures a narrow extraction task. Neither establishes a productivity improvement for real teams.&lt;/p&gt;

&lt;p&gt;The burden I want to reduce is repeatedly reconstructing the same agreement before taking a small, reasonable next step. Sometimes that step is sending a reminder. Sometimes it is noticing a condition and waiting. Sometimes it is rejecting the suggestion because there never was a promise.&lt;/p&gt;

&lt;p&gt;Keeping that choice with the person matters as much as finding the sentence. A useful assistant should help us remember what we owe one another, while leaving room for people to explain what changed.&lt;/p&gt;

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

&lt;p&gt;Overall &lt;strong&gt;Slow and Steady&lt;/strong&gt; challenge entry.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
      <category>programming</category>
    </item>
    <item>
      <title>Drafts That Never Shipped: do LLMs treat dead proposals as real standards?</title>
      <dc:creator>Hassan Imam</dc:creator>
      <pubDate>Sun, 11 Oct 2026 11:23:19 +0000</pubDate>
      <link>https://dev.to/hassan_shah_733ea1eb37c88/drafts-that-never-shipped-do-llms-treat-dead-proposals-as-real-standards-5mp</link>
      <guid>https://dev.to/hassan_shah_733ea1eb37c88/drafts-that-never-shipped-do-llms-treat-dead-proposals-as-real-standards-5mp</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/kaggle-2026-09-23"&gt;Kaggle Benchmarking Challenge&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;🎥 &lt;strong&gt;3-minute overview (narrated):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/glYnV0ha-tI" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

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

&lt;p&gt;Standards have a long tail of proposals that &lt;strong&gt;never shipped&lt;/strong&gt;: emoji withdrawn from a draft&lt;br&gt;
list, PEPs that were rejected or deferred, IETF Internet-Drafts that expired, HTTP headers&lt;br&gt;
that never made it into the registry. They are all over the web: blog posts, mailing lists,&lt;br&gt;
early implementations. So a model has seen plenty of text about them, often written as if&lt;br&gt;
they were real.&lt;/p&gt;

&lt;p&gt;I wanted to know: &lt;strong&gt;do LLMs treat these dead proposals as real standards?&lt;/strong&gt; And, more&lt;br&gt;
importantly, does it show up in the things models &lt;em&gt;produce&lt;/em&gt;, not only when you ask them&lt;br&gt;
directly? A model that says "PEP 505 was deferred" but then writes &lt;code&gt;a?.b&lt;/code&gt; in your code is&lt;br&gt;
the dangerous case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Drafts That Never Shipped&lt;/strong&gt; asks about 122 entities in five families:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Family&lt;/th&gt;
&lt;th&gt;Never shipped (example)&lt;/th&gt;
&lt;th&gt;Matched shipped "twin"&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Emoji&lt;/td&gt;
&lt;td&gt;Apple Core (withdrawn from the Emoji 17.0 draft)&lt;/td&gt;
&lt;td&gt;Lime, Shaking Face&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PEPs&lt;/td&gt;
&lt;td&gt;PEP 505 None-aware &lt;code&gt;?.&lt;/code&gt; (Deferred), PEP 3150 &lt;code&gt;given&lt;/code&gt; (Deferred)&lt;/td&gt;
&lt;td&gt;PEP 572 &lt;code&gt;:=&lt;/code&gt;, PEP 634 &lt;code&gt;match&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PEP &lt;em&gt;draft&lt;/em&gt; syntax&lt;/td&gt;
&lt;td&gt;PEP 622's &lt;code&gt;case .RED:&lt;/code&gt;, PEP 750's first-draft &lt;code&gt;greet"..."&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;case Color.RED:&lt;/code&gt;, &lt;code&gt;t"..."&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IETF drafts&lt;/td&gt;
&lt;td&gt;RateLimit headers draft, JSON Schema, OAuth 2.1&lt;/td&gt;
&lt;td&gt;RFC 8941, RFC 9068, …&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP fields&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Idempotency-Key&lt;/code&gt;, &lt;code&gt;RateLimit-Policy&lt;/code&gt;, &lt;code&gt;Want-Digest&lt;/code&gt; (obsoleted)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Priority&lt;/code&gt;, &lt;code&gt;Cache-Status&lt;/code&gt;, &lt;code&gt;Prefer&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Plus &lt;strong&gt;fictional placebos&lt;/strong&gt; (fake emoji names, PEP numbers that don't exist, draft names that&lt;br&gt;
404 on Datatracker) to measure plain hallucination.&lt;/p&gt;

&lt;p&gt;Each entity is asked about on several &lt;strong&gt;surfaces&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Status&lt;/strong&gt;: "Did X ship?" → &lt;code&gt;{"shipped": true|false}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lookup&lt;/strong&gt;: the codepoint / Python version / RFC number, or &lt;code&gt;NONE&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Artefact&lt;/strong&gt;: actually &lt;em&gt;use&lt;/em&gt; it:

&lt;ul&gt;
&lt;li&gt;a chat message with the emoji;&lt;/li&gt;
&lt;li&gt;a Python 3.14 snippet;&lt;/li&gt;
&lt;li&gt;a citation;&lt;/li&gt;
&lt;li&gt;raw HTTP headers.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every artefact prompt offers a neutral opt-out (&lt;code&gt;NOT_AVAILABLE&lt;/code&gt;), so a correct answer&lt;br&gt;
   always exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ground truth is never hand-typed.&lt;/strong&gt; Every label comes from a pinned, hashed,&lt;br&gt;
machine-readable source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unicode &lt;code&gt;emoji-test.txt&lt;/code&gt; / &lt;code&gt;UnicodeData.txt&lt;/code&gt; (15.1 → 18.0);&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;peps.json&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;the IETF Datatracker API;&lt;/li&gt;
&lt;li&gt;the IANA HTTP Field Name Registry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scoring is fully deterministic, with no LLM judge. Python snippets are &lt;strong&gt;compiled (never&lt;br&gt;
executed) under Python 3.14&lt;/strong&gt;: if your "PEP 505 example" doesn't compile, that's&lt;br&gt;
evidence you believed the syntax exists. My own curation was wrong once (I assumed the&lt;br&gt;
HTTP &lt;code&gt;QUERY&lt;/code&gt; method draft never shipped; Datatracker says it became RFC 10008). The pinned&lt;br&gt;
source won, and the conflict is logged.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Lab&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5&lt;/td&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;frontier general model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3 Flash&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;fast frontier model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.1 Flash Lite&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;small/cheap tier of the same family&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 nano&lt;/td&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;small/cheap tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-120b&lt;/td&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;open-weight&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek R1&lt;/td&gt;
&lt;td&gt;DeepSeek&lt;/td&gt;
&lt;td&gt;reasoning model: does thinking help?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Granite 4.0 H Small&lt;/td&gt;
&lt;td&gt;IBM&lt;/td&gt;
&lt;td&gt;small open model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.7 Flash&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;newest model available; run on Kaggle's own infrastructure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-Next 80B&lt;/td&gt;
&lt;td&gt;Qwen&lt;/td&gt;
&lt;td&gt;open-weight MoE; run on Kaggle's own infrastructure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That covers 9 models from 6 labs: frontier vs. small tiers within a family, open vs. closed,&lt;br&gt;
a reasoning model, and the newest Gemini. Qwen's endpoint hung through my local proxy, so it&lt;br&gt;
(like Gemini 3.7 Flash) was run once on Kaggle's own infrastructure. About 6,400 graded answers in total, at temperature 0, with 3 repeats on&lt;br&gt;
non-shipped items.&lt;/p&gt;

&lt;h2&gt;
  
  
  Findings
&lt;/h2&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%2F7s001poz17rs95pkbmfz.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%2F7s001poz17rs95pkbmfz.png" alt="Believed-shipped rate by model" width="800" height="531"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. One in four answers about a never-shipped standard says it shipped, and the errors&lt;br&gt;
point one way.&lt;/strong&gt; Pooled over all 7 models, &lt;strong&gt;26 %&lt;/strong&gt; of answers on real non-shipped items&lt;br&gt;
assert they shipped. Excluding very recent releases, &lt;strong&gt;84 % of all errors are "believed it&lt;br&gt;
shipped"&lt;/strong&gt;. Only about 10 % go the other way ("denied something real").&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Says a non-shipped standard shipped&lt;/th&gt;
&lt;th&gt;Accuracy on real twins&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5&lt;/td&gt;
&lt;td&gt;9 %&lt;/td&gt;
&lt;td&gt;90 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3 Flash&lt;/td&gt;
&lt;td&gt;11 %&lt;/td&gt;
&lt;td&gt;97 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-120b&lt;/td&gt;
&lt;td&gt;18 %&lt;/td&gt;
&lt;td&gt;77 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.1 Flash Lite&lt;/td&gt;
&lt;td&gt;21 %&lt;/td&gt;
&lt;td&gt;86 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek R1&lt;/td&gt;
&lt;td&gt;26 %&lt;/td&gt;
&lt;td&gt;92 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 nano&lt;/td&gt;
&lt;td&gt;44 %&lt;/td&gt;
&lt;td&gt;62 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Granite 4.0&lt;/td&gt;
&lt;td&gt;57 %&lt;/td&gt;
&lt;td&gt;34 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.7 Flash&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5 %&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;97 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-Next 80B&lt;/td&gt;
&lt;td&gt;9 %&lt;/td&gt;
&lt;td&gt;68 %&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;2. Strong models get the yes/no question right and the details wrong.&lt;/strong&gt; The frontier&lt;br&gt;
models rarely say "yes, that shipped". But ask for a &lt;em&gt;number&lt;/em&gt; or a &lt;em&gt;working artefact&lt;/em&gt; and&lt;br&gt;
the dead draft leaks through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude Sonnet 5&lt;/strong&gt;, asked which RFC defines the RateLimit headers (still an
Internet-Draft): &lt;strong&gt;"RFC 9777"&lt;/strong&gt;. Asked for example headers, it wrote
&lt;code&gt;RateLimit-Policy: 100;w=60&lt;/code&gt; as if it were a registered field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DeepSeek R1&lt;/strong&gt;, asked to cite the same draft, produced a perfectly formatted
&lt;code&gt;… "RateLimit Header Fields for HTTP", RFC 9436, DOI 10.17487/RFC9436, August 2023.&lt;/code&gt;
That citation is for an RFC that does not exist as that document.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkvvdlyt3ik2teeozgvmq.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%2Fkvvdlyt3ik2teeozgvmq.png" alt="Fabricated RFC numbers and citations" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude and Gemini&lt;/strong&gt; both say &lt;code&gt;Digest&lt;/code&gt; is defined by &lt;strong&gt;RFC 9530&lt;/strong&gt;, which is the RFC that
&lt;em&gt;obsoleted&lt;/em&gt; it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gpt-oss-120b&lt;/strong&gt;, asked for PEP 622's leading-dot pattern, wrote &lt;code&gt;case .RED:&lt;/code&gt;. That is
draft syntax that was dropped before pattern matching shipped as PEP 634, and it doesn't
compile.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqyqtwvx2natptfyl15w8.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%2Fqyqtwvx2natptfyl15w8.png" alt="gpt-oss-120b writes dropped PEP 622 draft syntax" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemini 3 Flash&lt;/strong&gt; (in the pilot run), asked for PEP 750, wrote &lt;code&gt;tag'Python {name}'&lt;/code&gt;: the custom-prefix
syntax from the PEP's &lt;strong&gt;first draft&lt;/strong&gt;. Only &lt;code&gt;t"..."&lt;/code&gt; shipped in Python 3.14.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The newest model, &lt;strong&gt;Gemini 3.7 Flash&lt;/strong&gt;, has the lowest error rate (5 %). But its remaining&lt;br&gt;
mistakes are &lt;em&gt;the same ones&lt;/em&gt;: it writes &lt;code&gt;RateLimit-Policy: 100;w=60&lt;/code&gt;, character for character&lt;br&gt;
what Claude wrote, and also names RFC 9530 as the home of &lt;code&gt;Digest&lt;/code&gt;. Different labs, same&lt;br&gt;
dead draft, same wrong details. These errors come from the web text the models learned&lt;br&gt;
from, not from any one model's quirks.&lt;/p&gt;

&lt;p&gt;The PEP draft-syntax pattern surprised me most. These aren't hallucinations from nowhere. They are&lt;br&gt;
&lt;strong&gt;faithful memories of an earlier draft of a spec that later changed&lt;/strong&gt;. The model learned&lt;br&gt;
the proposal, never learned the outcome.&lt;/p&gt;

&lt;p&gt;Qwen3-Next shows the opposite profile: it rarely believes dead drafts (9 %) but also denies a&lt;br&gt;
third of real twins (68 % twin accuracy). A low believed-shipped rate alone can mean caution,&lt;br&gt;
not knowledge; that's why every non-shipped item has a twin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. "Knows" ≠ "does", and it depends heavily on the model.&lt;/strong&gt; Among cases where a model got&lt;br&gt;
the status question right, how often was its artefact still wrong?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Claude 1.4 %, Gemini Flash 2.1 %, DeepSeek R1 2.0 %;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemini 3.1 Flash Lite 23 %&lt;/strong&gt;, &lt;strong&gt;GPT-5.4 nano 26 %&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Flash Lite correctly told me that two pieces of dropped draft syntax (PEP 572's&lt;br&gt;
&lt;code&gt;(expr as name)&lt;/code&gt; and PEP 622's &lt;code&gt;case .RED:&lt;/code&gt;) are not in Python, and then used them in all&lt;br&gt;
6 code samples I asked for. For the never-registered HTTP fields it said "not a current&lt;br&gt;
field" and then emitted the header anyway, 9 times out of 9. &lt;strong&gt;For small models, a correct answer to "does X&lt;br&gt;
exist?" says little about whether they'll use X in code.&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%2Fmevj85esz4lh1npxocfy.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%2Fmevj85esz4lh1npxocfy.png" alt="Gemini 3.1 Flash Lite: correct status, then dropped syntax" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Strong models aren't immune. Claude Sonnet 5 correctly said &lt;code&gt;RateLimit-Policy&lt;/code&gt; is &lt;em&gt;not&lt;/em&gt; a&lt;br&gt;
registered HTTP field, then wrote it into an HTTP response when asked for an example:&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%2Fvqcez8btckw3a8e2y3be.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%2Fvqcez8btckw3a8e2y3be.png" alt="Claude Sonnet 5 knows, then does anyway" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's an inverse failure too: Claude says Goal Net 🥅 and Sauropod 🦕 are real emoji, then&lt;br&gt;
answers &lt;code&gt;NO_SUCH_EMOJI&lt;/code&gt; when asked to write a message using them. That's over-caution,&lt;br&gt;
not premature belief, but it's the same disconnect between knowing and doing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Showing the source works; telling the model to "be careful" mostly doesn't.&lt;/strong&gt; On&lt;br&gt;
held-out items I compared three conditions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no fix;&lt;/li&gt;
&lt;li&gt;a placebo instruction ("Be careful: some proposals never shipped.");&lt;/li&gt;
&lt;li&gt;the actual pinned source row in the prompt (the &lt;code&gt;peps.json&lt;/code&gt; entry, the &lt;code&gt;emoji-test.txt&lt;/code&gt;
line, the Datatracker state, the IANA registry row).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbtbupn2ucukpegw0l0en.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%2Fbtbupn2ucukpegw0l0en.png" alt="Fix experiment: evidence vs placebo instruction" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;No fix&lt;/th&gt;
&lt;th&gt;"Be careful"&lt;/th&gt;
&lt;th&gt;Source row in prompt&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5&lt;/td&gt;
&lt;td&gt;4.5 %&lt;/td&gt;
&lt;td&gt;9.1 %&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.3 %&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3 Flash&lt;/td&gt;
&lt;td&gt;13.6 %&lt;/td&gt;
&lt;td&gt;2.3 %&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.5 %&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.1 Flash Lite&lt;/td&gt;
&lt;td&gt;22.7 %&lt;/td&gt;
&lt;td&gt;18.2 %&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.1 %&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 nano&lt;/td&gt;
&lt;td&gt;36.4 %&lt;/td&gt;
&lt;td&gt;20.5 %&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15.9 %&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;(believed-shipped rate on held-out non-shipped + placebo items)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The evidence roughly halves the error rate or better for every model. It &lt;em&gt;also&lt;/em&gt; raises&lt;br&gt;
accuracy on real items (nano's twin accuracy goes from 60 % to 97 %). The warning helps some&lt;br&gt;
weaker models but is unreliable: for Claude it made things slightly worse, though cells are&lt;br&gt;
small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Recency is the opposite failure and has to be separated.&lt;/strong&gt; My first pilot looked like&lt;br&gt;
the benchmark didn't work: models did &lt;em&gt;worse&lt;/em&gt; on real twins than on dead drafts. The cause&lt;br&gt;
was twins that shipped recently (Emoji 18.0 in September 2026, Python 3.14, RFCs from 2024–25),&lt;br&gt;
which models simply don't know yet. I moved those into a separate "fresh" slice and&lt;br&gt;
re-piloted. Post-cutoff staleness and premature belief are mirror images: one says real&lt;br&gt;
things don't exist, the other says dead things do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What surprised me&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How &lt;em&gt;specific&lt;/em&gt; the wrong answers are: DOIs, RFC numbers, exact draft syntax.&lt;/li&gt;
&lt;li&gt;That the reasoning model (DeepSeek R1) was among the more confident believers.&lt;/li&gt;
&lt;li&gt;That one cheap model could be right about a fact and wrong about using it, every single
time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I'd measure next&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Draft-detail questions at scale.&lt;/strong&gt; Mine every PEP / RFC whose syntax or field names
changed between a draft and the final text, using the git history of python/peps and
Datatracker revisions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agentic settings:&lt;/strong&gt; does a coding agent with a linter or test loop catch the dead
syntax, or rationalise it?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval of the &lt;em&gt;wrong&lt;/em&gt; document:&lt;/strong&gt; what if the context contains the old draft rather
than the final spec?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Honest caveats&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;My first pilot failed its pre-registered gate. The fixes (a freshness slice, two new item
families, a competence floor for the pooled gate) were chosen after seeing pilot results.
They are all dated in the pre-registration in the repo.&lt;/li&gt;
&lt;li&gt;The pilot's twin-vs-non-shipped gap for Claude (+16 pts on 81 items) &lt;strong&gt;did not replicate&lt;/strong&gt;
on the full set (≈ 0); only DeepSeek R1's gap is statistically clear (+18 pts, McNemar
p = 0.008).&lt;/li&gt;
&lt;li&gt;A few scorer bugs were found while auditing outputs; all were fixed with unit tests and
re-scored from cached responses.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;👉 &lt;strong&gt;Kaggle benchmark:&lt;/strong&gt; &lt;a href="https://www.kaggle.com/benchmarks/tasks/shs123/drafts-that-never-shipped" rel="noopener noreferrer"&gt;https://www.kaggle.com/benchmarks/tasks/shs123/drafts-that-never-shipped&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📦 &lt;strong&gt;Items dataset:&lt;/strong&gt; &lt;a href="https://www.kaggle.com/datasets/shs123/drafts-that-never-shipped-items" rel="noopener noreferrer"&gt;kaggle.com/datasets/shs123/drafts-that-never-shipped-items&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Built with Kaggle's &lt;a href="https://github.com/Kaggle/kaggle-benchmarks" rel="noopener noreferrer"&gt;&lt;code&gt;kaggle-benchmarks&lt;/code&gt;&lt;/a&gt; library&lt;br&gt;
(&lt;code&gt;@kbench.task&lt;/code&gt;, structured output via &lt;code&gt;llm.prompt(schema=...)&lt;/code&gt;, &lt;code&gt;.evaluate()&lt;/code&gt; with on-disk&lt;br&gt;
caching). The leaderboard score is &lt;code&gt;(correct, scored)&lt;/code&gt;; format and API errors are excluded&lt;br&gt;
and reported separately.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Note on numbers:&lt;/em&gt; the figures in this post come from my full local runs (278 items plus 3&lt;br&gt;
repeats on non-shipped items, code checked under Python 3.14); Gemini 3.7 Flash and Qwen3-Next&lt;br&gt;
come from one Kaggle run each, with code re-checked locally under 3.14. The Kaggle leaderboard runs&lt;br&gt;
each model once on all 364 items. Kaggle's runner is older than Python 3.14, so the few&lt;br&gt;
code-compile items are left out of its score there. Expect small differences between the two.&lt;/p&gt;

&lt;p&gt;Credits: ground truth from the Unicode Consortium, the Python PEPs index, the IETF Datatracker&lt;br&gt;
and IANA. Emoji draft history from Emojipedia and Emojiterra; rejected-proposal history from&lt;br&gt;
Charlotte Buff's catalogue.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>kagglechallenge</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Lake Lookout: Be the Eyes the Satellites Lack (Gemma 4, fully offline)</title>
      <dc:creator>Hassan Imam</dc:creator>
      <pubDate>Fri, 09 Oct 2026 19:10:27 +0000</pubDate>
      <link>https://dev.to/hassan_shah_733ea1eb37c88/lake-lookout-be-the-eyes-the-satellites-lack-gemma-4-fully-offline-493i</link>
      <guid>https://dev.to/hassan_shah_733ea1eb37c88/lake-lookout-be-the-eyes-the-satellites-lack-gemma-4-fully-offline-493i</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Lake Lookout turns a hike past glacial lakes into a field log that researchers can use. On the trail you only take photos and short voice notes, and your phone stays in your pocket. That evening, with no internet, &lt;strong&gt;Gemma 4 runs on your own laptop&lt;/strong&gt;, listens to every voice note, looks at every photo, and fills in a checklist drawn from published glacial-lake hazard indicators. Every &lt;em&gt;yes&lt;/em&gt; or &lt;em&gt;no&lt;/em&gt; has to name its evidence. Anything it can't actually see stays &lt;em&gt;unclear&lt;/em&gt;. It is open source (MIT), and it costs nothing to run.&lt;/p&gt;
&lt;/blockquote&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%2Fcyq6z7sdjm315o0rriz7.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%2Fcyq6z7sdjm315o0rriz7.png" alt="Lake Lookout: a processed day in Hunza with its route map, stats and stop cards" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;h3&gt;
  
  
  The lake nobody was watching
&lt;/h3&gt;

&lt;p&gt;On 16 August 2024, Thyanbo Tsho, a small glacial lake above the village of Thame in Nepal's Everest region, burst. The flood reached Thame about 22 to 25 minutes later. Homes, the school and the health post were damaged, and 135 people were displaced.&lt;/p&gt;

&lt;p&gt;Earlier this year I built a separate project that screened Himalayan glacial lakes from free satellite images. Its most uncomfortable finding wasn't about models at all; it was about the sky. In the pre-event satellite record for the four floods it studied, &lt;strong&gt;only 1 of 16 scenes got past the cloud-and-snow quality filter&lt;/strong&gt;. The monsoon hides these lakes exactly when they fill up.&lt;/p&gt;

&lt;p&gt;People walk past them all the time, though: trekkers, guides, porters, herders. Their eyes work under cloud. What they see just never gets written down in a form anyone can use.&lt;/p&gt;

&lt;p&gt;So, for a challenge called &lt;em&gt;Touch Grass&lt;/em&gt;, I built the opposite of a dashboard you stare at. &lt;strong&gt;Lake Lookout makes the hiker the sensor, and keeps the screen for the evening.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  How a day works
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;On the trail.&lt;/strong&gt; At each lake, take one wide photo and record 10 to 30 seconds of what you see: &lt;em&gt;"Grey milky water, a long ridge of loose rock between the lake and the glacier, ice on the far shore."&lt;/em&gt; That's all. Your phone's own camera and voice recorder; no app, no signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In the evening.&lt;/strong&gt; At the teahouse, the campsite or home, copy the files to a laptop and drop them into Lake Lookout. &lt;strong&gt;Gemma 4&lt;/strong&gt; (E4B, through Ollama) transcribes each voice note, reads each photo, and fills in a field checklist for every stop. It uses the GPS and time already in the photos to rebuild your route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share the log.&lt;/strong&gt; You get one offline page with your route, photos, words and checklist, plus a &lt;strong&gt;CSV&lt;/strong&gt; and a &lt;strong&gt;GeoJSON&lt;/strong&gt; file a researcher can load straight into a spreadsheet or a GIS.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftn2s83noz5epmrf4i2ua.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftn2s83noz5epmrf4i2ua.gif" alt="Dropping in a day's photos and voice notes and pressing Process; the model wait is sped up" width="600" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Who it's for
&lt;/h3&gt;

&lt;p&gt;Anyone who walks past mountain lakes: trekkers, and above all the guides, porters and herders who pass the same lakes every season. The logs are for the people who study these lakes and are qualified to interpret them. The screen time is a few minutes in the evening; the rest of the day is spent looking at the lake.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it records
&lt;/h3&gt;

&lt;p&gt;The checklist asks what a hiker can actually see, and where a question corresponds to a published hazard indicator, it says which one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Observation&lt;/th&gt;
&lt;th&gt;Published indicator it corresponds to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Glacier ice touching the water&lt;/td&gt;
&lt;td&gt;Rounce et al. 2016 (mother glacier within 600 m)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Icebergs or calving&lt;/td&gt;
&lt;td&gt;Sakai et al. 2009 (calving onset)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Steep slopes or hanging ice above the lake&lt;/td&gt;
&lt;td&gt;Fujita et al. 2013; Rounce et al. 2016&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fresh rockfall or landslide scars above&lt;/td&gt;
&lt;td&gt;Allen et al. 2019 (impulse-wave trigger)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Low freeboard (dam crest close to the water)&lt;/td&gt;
&lt;td&gt;Mergili &amp;amp; Schneider 2011&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Steep outer dam face&lt;/td&gt;
&lt;td&gt;Lv et al. 1999 (moderate confidence)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Water body · glacial setting · moraine dam · seepage · people downstream&lt;/td&gt;
&lt;td&gt;plain field observations, labelled as such&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;It deliberately gives no risk score and raises no alert.&lt;/strong&gt; It records observations for people qualified to interpret them. A wrong "this lake is safe" is worse than no app at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Taking it outside: a dry run first
&lt;/h3&gt;

&lt;p&gt;I haven't done my own walk yet as I write this, so I won't pretend I have. To see what a real day produces before I go, I built one. It has seven stops along the Karakoram Highway in Hunza, from Karimabad to Passu, assembled from geotagged Wikimedia photos with synthetic voice notes. The repo labels it as test data and credits every photographer. Then I processed it the way you would on an evening at a guesthouse: one click, and &lt;strong&gt;28 seconds&lt;/strong&gt; on my machine's GPU for all seven stops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it got right:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No lake, no claims.&lt;/strong&gt; At Baltit Fort, with no water in the photo, &lt;em&gt;water body&lt;/em&gt; came back &lt;strong&gt;no&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The hiker's words were enough.&lt;/strong&gt; A voice-only stop, &lt;em&gt;"a small muddy pond next to the road, no ice anywhere near it"&lt;/em&gt;, was logged as &lt;strong&gt;not glacial&lt;/strong&gt; from the words alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disagreements were shown, not resolved.&lt;/strong&gt; At Passu Glacier the photo and the voice note disagreed about water and ice. The log shows both and picks neither.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What it got wrong:&lt;/strong&gt; the heavily edited winter photo of Borith Lake. More on that under &lt;em&gt;How I Built It&lt;/em&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%2Fobevawkyr4itqebu1dcb.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%2Fobevawkyr4itqebu1dcb.png" alt="The Hunza dry run as a shareable, offline trip page" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My own walk is next, and I'll add it here.&lt;/p&gt;

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

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/axjGCMU2-k4" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The demo is the real app and the real local model. To show a realistic day, it uses a test day I assembled from freely licensed Wikimedia photos of Hunza, Pakistan, with synthetic voice notes; it is labelled as such in the repo. Processing is sped up in the video. It took about 30 seconds on my machine for 7 stops. Even the narration is local and open: it is &lt;a href="https://huggingface.co/hexgrad/Kokoro-82M" rel="noopener noreferrer"&gt;Kokoro-82M&lt;/a&gt;, an open-weight text-to-speech model running on the same laptop. I checked every line by having Gemma 4 transcribe it back, which is how I found that the voice said "Alama" for "Ollama".&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvaqyka2ovdwuwvycgjkf.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvaqyka2ovdwuwvycgjkf.gif" alt="Opening a stop from the map: every answer shows its evidence, and what it can't see stays unclear" width="600" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live demo (read-only):&lt;/strong&gt; &lt;a href="https://hassan-2050.github.io/lake-lookout/" rel="noopener noreferrer"&gt;https://hassan-2050.github.io/lake-lookout/&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;em&gt;The real app UI over the processed test days: map, evidence, voice notes, filters and downloads. Nothing is processed on the website. Processing a hike stays on the hiker's own laptop, by design.&lt;/em&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%2Fpswdrs3vli8q1sfai7uk.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%2Fpswdrs3vli8q1sfai7uk.png" alt="The read-only live demo on GitHub Pages" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/hassan-2050" rel="noopener noreferrer"&gt;
        hassan-2050
      &lt;/a&gt; / &lt;a href="https://github.com/hassan-2050/lake-lookout" rel="noopener noreferrer"&gt;
        lake-lookout
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Hike with your phone. In the evening, a local Gemma 4 turns your photos and voice notes into a glacial-lake field log, offline.
    &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;Lake Lookout&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Hike with your phone. At each lake, take a photo and say what you see. That evening, with no internet, a local open-weight model turns the day into a glacial-lake field log.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;On the trail the screen stays in your pocket. You use your phone's own camera
and voice recorder. Back at the teahouse, campsite or home, Lake Lookout reads
the day's photos and voice notes on your laptop, and Gemma 4 (via Ollama)
answers a short field checklist for every stop. The checklist is drawn from
published glacial-lake hazard indicators, and every answer has to name its
evidence. You get:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;trip.html&lt;/code&gt;: one self-contained page with your route, photos, what you said
and the checklist for each stop. It opens with the network off.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;field_log.csv&lt;/code&gt; and &lt;code&gt;field_log.geojson&lt;/code&gt;: the same observations in forms a
researcher can load straight into a spreadsheet or a GIS.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Nothing leaves the…&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/hassan-2050/lake-lookout" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Try it yourself in a few minutes; the repo includes test days:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull gemma4:e4b      &lt;span class="c"&gt;# or gemma4:e2b for a laptop without a GPU&lt;/span&gt;
git clone https://github.com/hassan-2050/lake-lookout &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;lake-lookout
python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; .venv/Scripts/pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt   &lt;span class="c"&gt;# Windows&lt;/span&gt;
&lt;span class="c"&gt;# python -m venv .venv &amp;amp;&amp;amp; .venv/bin/pip install -r requirements.txt     # macOS/Linux&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; lookout ui        &lt;span class="c"&gt;# then open the test-hunza day and press Process&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Licence:&lt;/strong&gt; MIT.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photos:&lt;/strong&gt; all evaluation and test-day photos are from Wikimedia Commons under CC BY / CC BY-SA licences, credited in &lt;code&gt;eval/photos/ATTRIBUTION.md&lt;/code&gt; and in each test day's README.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video:&lt;/strong&gt; recorded from the real app with Playwright (&lt;code&gt;tools/record_demo.py&lt;/code&gt;), narrated by Kokoro-82M (Apache-2.0) through kokoro-onnx, run locally.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Open-source AI at the core
&lt;/h3&gt;

&lt;p&gt;Everything that thinks runs locally on open weights:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 4 E4B&lt;/strong&gt; (or &lt;strong&gt;E2B&lt;/strong&gt; on a laptop without a GPU), served by &lt;strong&gt;Ollama&lt;/strong&gt;, does all three jobs: it transcribes the voice note from audio, reads the photo, and returns the checklist as JSON constrained to a schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kokoro-82M&lt;/strong&gt;, an open-weight text-to-speech model, voiced the demo video.&lt;/li&gt;
&lt;li&gt;Around them: Python (standard library plus Pillow), and ffmpeg for audio.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;phone photos (EXIF time + GPS) ─┐
phone voice notes (m4a/mp3/…) ──┼─► group into stops (time + distance, UTC-safe)
                                │
                                ▼
          ┌─────────── offline guard: only 127.0.0.1 ───────────┐
          │  ffmpeg → 16 kHz WAV ─► Gemma 4: transcript          │
          │  photo ─► Gemma 4: checklist JSON (evidence first)   │
          │  voice ─► Gemma 4: checklist JSON (evidence first)   │
          │  rules: evidence · in-view · transcript · keywords   │
          │  merge photo + voice (disagreement → shown, unclear) │
          └──────────────────────────────────────────────────────┘
                                ▼
          trip.html (self-contained) · field_log.csv · field_log.geojson
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The app&lt;/strong&gt; (&lt;code&gt;python -m lookout ui&lt;/code&gt;) is a small local server, listening only on your own machine, with a plain HTML/CSS/JS front end and no libraries. It has drag-and-drop days, live progress per stop, an offline route map, filters and one-click evidence. It works in dark mode and at phone width.&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%2Fv450huvrolz3xh5n9m6g.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%2Fv450huvrolz3xh5n9m6g.png" alt="Lake Lookout in dark mode" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feiolm87uhe2gqr5v8vqt.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feiolm87uhe2gqr5v8vqt.gif" alt="Lake Lookout at phone width" width="300" height="649"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three levels of testing:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;62 unit and API tests.&lt;/strong&gt; The API tests run the real server with a stand-in model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A checker&lt;/strong&gt; that compares each generated trip page with its CSV.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A browser end-to-end test&lt;/strong&gt; that drives headless Chrome through the whole app with the real model, with about two dozen checks. It creates a day, uploads through the file picker and processes it. Then it uses the map, filters, evidence and lightbox, checks every photo decodes and that there's no sideways scroll on a phone, and expects zero console errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The interesting part: making a small model unable to overclaim
&lt;/h3&gt;

&lt;p&gt;Getting Gemma to answer a checklist took ten minutes. Getting it to answer &lt;strong&gt;only what it could actually see&lt;/strong&gt; took the rest of the build. A field log with a confident wrong answer is worse than an empty one, so I treated this as the real problem and measured every change.&lt;/p&gt;

&lt;p&gt;I picked 11 freely licensed lake photos: seven glacial lakes, one glacier with no lake in frame, and three controls that aren't glacial at all (a landslide lake, a reservoir and a village pond). Each photo has a label for every question, which gives 89 scored answers per version. My coding agent drafted the labels by looking at each photo, then re-checked all of them in a second pass, which changed four ambiguous ones to "skip". No person has independently reviewed them yet, so treat the numbers as indicative.&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%2Fazz7egqw3qbdgca40yf3.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%2Fazz7egqw3qbdgca40yf3.png" alt="Five measured versions: false no answers spike to 18 in v2 and fall to 3; accuracy rises from 74% to 88-89%" width="800" height="312"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;version&lt;/th&gt;
&lt;th&gt;change&lt;/th&gt;
&lt;th&gt;accuracy&lt;/th&gt;
&lt;th&gt;false "yes"&lt;/th&gt;
&lt;th&gt;false "no"&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;v1&lt;/td&gt;
&lt;td&gt;answer, then evidence&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v2&lt;/td&gt;
&lt;td&gt;evidence, then answer&lt;/td&gt;
&lt;td&gt;72%&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;18&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v3&lt;/td&gt;
&lt;td&gt;"is the dam in view?" questions&lt;/td&gt;
&lt;td&gt;84%&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v4&lt;/td&gt;
&lt;td&gt;final prompt&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;89%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v5&lt;/td&gt;
&lt;td&gt;v4 plus bug fixes found on real photos&lt;/td&gt;
&lt;td&gt;88%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;v1: the silent "no".&lt;/strong&gt; The model kept answering &lt;em&gt;no&lt;/em&gt; and then writing &lt;code&gt;"none"&lt;/code&gt; as its evidence. An unsupported "no" is the most dangerous thing a hazard log can say, because it reads as "nothing there" and never looks like a failure. So a small rule-based verifier turns any yes or no without evidence into &lt;em&gt;unclear&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v2: the fix that made it worse.&lt;/strong&gt; Ollama's structured output generates JSON fields in the order the schema lists them. So I moved &lt;code&gt;evidence&lt;/code&gt; in front of &lt;code&gt;answer&lt;/code&gt;, which forces the model to say what it sees &lt;em&gt;before&lt;/em&gt; it commits. Unsupported answers dropped to zero... and false "no" answers jumped from 4 to 18. Now it wrote, with a straight face and real evidence, &lt;em&gt;"no visible seepage through the dam"&lt;/em&gt; about dams that were &lt;strong&gt;behind the camera&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v3: absence needs a view.&lt;/strong&gt; Two questions now come first: &lt;em&gt;is the dam or outlet in view?&lt;/em&gt; and &lt;em&gt;is the land downstream in view?&lt;/em&gt; The verifier won't let a photo claim anything about the dam or the valley below unless that part is confirmed in view. If it isn't sure, it fails safe and the answer stays &lt;em&gt;unclear&lt;/em&gt;. False "no" answers fell to 5, then to 3 with the final prompt.&lt;/p&gt;

&lt;p&gt;Then voice notes broke it in a new way. Given a photo &lt;strong&gt;and&lt;/strong&gt; a voice note together, the model stopped looking at the photo. It also quoted one sentence, &lt;em&gt;"a long ridge of loose rock between the lake and the glacier"&lt;/em&gt;, as evidence for six unrelated answers, including "no fresh rockfall". So now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;photo and voice are asked about &lt;strong&gt;separately&lt;/strong&gt;, then merged;&lt;/li&gt;
&lt;li&gt;when they &lt;strong&gt;disagree&lt;/strong&gt;, the log shows both and marks the item &lt;em&gt;unclear&lt;/em&gt; instead of picking a side;&lt;/li&gt;
&lt;li&gt;voice evidence must actually appear in the transcript, and must mention something the question is about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is that working on a real test stop. The hiker's &lt;em&gt;"concrete dam"&lt;/em&gt; correctly answers &lt;strong&gt;moraine dam: no&lt;/strong&gt;. Whether the dam is in view ("…behind me") is shown as a disagreement, not resolved:&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%2Ff4q4mkdioaeypw5m4l0t.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%2Ff4q4mkdioaeypw5m4l0t.png" alt="A stop card: the hiker's words answer the moraine-dam question; photo and voice disagree about whether the dam is in view" width="800" height="594"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fct7egtmb5klwaiwdjd8x.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fct7egtmb5klwaiwdjd8x.gif" alt="Filtering to disagreements: the photo and the hiker disagree, and the log shows both" width="600" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of these rules needed a bigger model. They are a few dozen lines of plain Python, and each one exists because a measurement showed the failure it prevents. Every downgrade is kept, with its reason, so you can see what the model said as well as what the log kept:&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%2F8dlq35z4itr4qjkntn6o.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%2F8dlq35z4itr4qjkntn6o.png" alt="Answers the model could not back up, downgraded with their reasons" width="800" height="666"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What real photos taught me
&lt;/h3&gt;

&lt;p&gt;Labelled photos only go so far, so I also built two realistic test days in Gilgit-Baltistan (Hunza and Skardu) from geotagged Wikimedia photos. They include glaciers with no lake in frame, villages with no water at all, a dammed reservoir and one heavily edited winter photo. They found things my tests hadn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A confident hallucination.&lt;/strong&gt; On the heavily edited, HDR-style winter photo of Borith Lake, Gemma "saw" a moraine dam, low freeboard and the outlet, each with plausible evidence. No rule can catch that: the evidence reads fine and the model says the dam is in view. Plain phone photos are what this is built for, and the README says so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rule that had never worked.&lt;/strong&gt; While editing, I let a script write the regex &lt;code&gt;\b&lt;/code&gt; in the "I can't see it" rule as a literal backspace character. The rule matched nothing, and its tests still passed because a &lt;em&gt;different&lt;/em&gt; rule downgraded the same answers. The tests for that rule now check the &lt;em&gt;reason&lt;/em&gt; for the downgrade, and I confirmed they fail on the old code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A bug I found by watching my own demo video.&lt;/strong&gt; At a stop with a fort and no lake, the photo pass claimed the dam was in view and the voice pass disagreed. The merge correctly marked "dam in view" as a disagreement, but the photo's &lt;em&gt;"steep dam face: yes"&lt;/em&gt; had already passed its own check. The in-view rule now runs again after merging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Place names get misheard.&lt;/strong&gt; "Attabad" came back as "A bad lake", and "Satpara" as "Sapporo". The observations survive; the names don't.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Honest limits
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Small evaluation.&lt;/strong&gt; 11 labelled photos, labelled by my coding agent (two passes) and not yet reviewed by a person. Scores move a few points between runs even at temperature 0, so treat small differences as noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Is the dam in view?" is the model's weak spot&lt;/strong&gt; (27 to 47% across the Gemma 4 runs). The rule fails safe, so a wrong "not in view" costs answers rather than producing false ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edited photos can fool it,&lt;/strong&gt; as the winter Borith photo showed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Voice in other languages:&lt;/strong&gt; the transcription prompt asks for the original language plus an English line, but I have only tested English so far.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It is not a hazard assessment, and it won't become one by adding a score.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What's next
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Voice notes in &lt;strong&gt;Urdu and Nepali&lt;/strong&gt; from the guides and porters who walk past these lakes most often, with English in the log.&lt;/li&gt;
&lt;li&gt;A way to hand a season of logs to the people who study these lakes, in the format they actually want. I'd rather ask them than guess.&lt;/li&gt;
&lt;li&gt;Running E2B on the phone itself, so the evening step needs no laptop.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;There is no signal where the lakes are.&lt;/strong&gt; Above the tree line in the Himalaya and the Karakoram, mobile data can be gone for days. A cloud API is useless exactly where this tool is needed. Lake Lookout runs entirely on the laptop and enforces it: while it processes a day, &lt;strong&gt;every network connection except the one to the local model raises an error&lt;/strong&gt;. Offline isn't a setting you can forget to turn on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The data shouldn't leave.&lt;/strong&gt; A field log is a list of exact coordinates of lakes, trails and villages, plus a person's voice. With open weights running locally, none of it goes to a server I don't control. The page it produces has no scripts, no web fonts and no map tiles, and it opens with the network off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I could measure, swap and iterate for free.&lt;/strong&gt; Five prompt versions × 11 photos × several models is a lot of calls. Locally, each one cost nothing and took about two seconds. That is what made the measurement loop above possible. I ran the same photos through three local models:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;model&lt;/th&gt;
&lt;th&gt;accuracy&lt;/th&gt;
&lt;th&gt;false "yes"&lt;/th&gt;
&lt;th&gt;false "no"&lt;/th&gt;
&lt;th&gt;per photo (RTX 4090)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;gemma4:e4b&lt;/td&gt;
&lt;td&gt;89%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemma4:e2b&lt;/td&gt;
&lt;td&gt;86%&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;1.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemma3:4b (v3 prompt)&lt;/td&gt;
&lt;td&gt;65%&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;not comparable*&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;E4B is the default because it made the fewest false "no" answers in every run. &lt;strong&gt;No GPU? &lt;code&gt;--cpu&lt;/code&gt; switches to E2B&lt;/strong&gt;, which took about 20 s per photo on a desktop CPU (Intel i7-12700F), roughly three minutes for an eight-stop day. A laptop chip will be slower, but it's still an evening task, not a cloud bill.&lt;/p&gt;

&lt;p&gt;*Another program held most of the GPU's memory during that run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One small open model does the whole job.&lt;/strong&gt; Gemma 4's E2B and E4B take audio &lt;em&gt;and&lt;/em&gt; images. The same local model transcribes the voice note, reads the photo, and returns JSON constrained to a schema. There is no separate speech model to install and no second runtime: one &lt;code&gt;ollama pull&lt;/code&gt; and you have the whole pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What a closed API wouldn't have allowed:&lt;/strong&gt; working with no signal at all, keeping people's locations and voices on their own laptop, and running hundreds of measured experiments at no cost per call. Each of those is something the project depends on, not a nice extra.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;I built Lake Lookout with &lt;strong&gt;Claude Code&lt;/strong&gt; as my coding agent. The repository was created during the challenge week, and the commit history shows it going from an empty folder to this. The &lt;code&gt;eval/results/&lt;/code&gt; folder is the loop we actually worked in: hypothesis, change, measured result. Each version in the table above is a saved result file you can re-score with &lt;code&gt;eval/rescore.py&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The satellite project mentioned at the top is earlier, separate work: it's the reason this one exists, not part of it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Best Use of Gemma.&lt;/strong&gt; Gemma 4 is the core of Lake Lookout, not an add-on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Speech:&lt;/strong&gt; Gemma 4 transcribes each voice note directly from audio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vision:&lt;/strong&gt; Gemma 4 reads each photo against the checklist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured output:&lt;/strong&gt; answers come back as schema-constrained JSON with evidence and source for every item, and field order was used deliberately to make the model ground itself before answering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-device class:&lt;/strong&gt; E4B on a GPU and E2B on a CPU-only laptop, both fully offline through Ollama.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measured, not assumed:&lt;/strong&gt; a labelled evaluation, a three-model comparison, and the honest failure cases above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading. If you live or walk near glacial lakes and want to try it, I'd love to hear what it gets wrong.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>ai</category>
    </item>
    <item>
      <title>The Overhead Ratio Is Lying to You — I Built an AI Tool to Prove It</title>
      <dc:creator>Hassan Imam</dc:creator>
      <pubDate>Sun, 06 Sep 2026 21:23:26 +0000</pubDate>
      <link>https://dev.to/hassan_shah_733ea1eb37c88/the-overhead-ratio-is-lying-to-you-i-built-an-ai-tool-to-prove-it-2684</link>
      <guid>https://dev.to/hassan_shah_733ea1eb37c88/the-overhead-ratio-is-lying-to-you-i-built-an-ai-tool-to-prove-it-2684</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;&lt;strong&gt;GlassPocket&lt;/strong&gt; — a tool that argues &lt;em&gt;against&lt;/em&gt; the "overhead ratio," the dominant heuristic people use to judge charities (what % of donations go to "programs" vs. "overhead" like staff and infrastructure). That heuristic punishes exactly the investment that makes a charity effective, and it drives what nonprofit finance people call the "starvation cycle" — orgs under pressure to look lean end up under-staffed and under-resourced.&lt;/p&gt;

&lt;p&gt;You search a US 501(c)(3), and instead of a single overhead percentage, GlassPocket pulls their IRS Form 990 history (via ProPublica's Nonprofit Explorer API) and shows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reserve months&lt;/strong&gt; — how long the org could run on savings alone (low reserves = fragile, not "lean")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operating margin&lt;/strong&gt; trends across up to 13 years of filings&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staff-investment share&lt;/strong&gt; — reframed as capacity, not waste&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fundraising cost per dollar raised&lt;/strong&gt; — a narrower, more honest efficiency metric than the classic ratio&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;peer-percentile chart&lt;/strong&gt; against ~70 similar organizations in the same category&lt;/li&gt;
&lt;li&gt;A Gemini-written "myth-buster" card pairing each overhead-ratio assumption with what the numbers actually show&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;grounded chat box&lt;/strong&gt; — ask follow-up questions about that specific org's finances, answered only from its own filing data&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://glasspocket.vercel.app/" rel="noopener noreferrer"&gt;https://glasspocket.vercel.app/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffderq6sexwsdr85p76js.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffderq6sexwsdr85p76js.gif" alt="GlassPocket demo — searching GiveDirectly, the myth-buster card, trend and peer-percentile charts, and the grounded chat answering a follow-up question" width="560" height="315"&gt;&lt;/a&gt;&lt;/p&gt;

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


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/hassan-2050" rel="noopener noreferrer"&gt;
        hassan-2050
      &lt;/a&gt; / &lt;a href="https://github.com/hassan-2050/glasspocket" rel="noopener noreferrer"&gt;
        glasspocket
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Overhead-ratio myth buster for US charities — Form 990 data via ProPublica, Gemini narrative generator
    &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;GlassPocket — Overhead Myth Buster&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://glasspocket.vercel.app/" rel="nofollow noopener noreferrer"&gt;glasspocket.vercel.app&lt;/a&gt;
&lt;strong&gt;Category:&lt;/strong&gt; Overall Winner + Best Use of Google AI (Gemini-powered narrative generator and chat)&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The Hook&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Most charity-rating tools reinforce the harmful "overhead ratio" myth. This contrarian tool argues
against that dominant heuristic by reframing efficiency around outcomes and reserves.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What It Does&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;You search a US charity by name, and it pulls their IRS Form 990 history to generate a plain-English
context-aware financial explainer that debunks the overhead myth and shows why a high program-expense
ratio can actually indicate starved infrastructure. Instead of the overhead ratio, it surfaces reserve
months, operating margin trends, staff-investment share, fundraising cost-per-dollar, and how the org
compares to ~70 peers in its category. A Gemini-backed "myth-buster" card writes the narrative (with a
one-click regenerate), and a grounded chat box lets you ask follow-up questions about that org's
numbers specifically — it only answers from the org's own…&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/hassan-2050/glasspocket" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


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

&lt;p&gt;The interesting part of this build wasn't the UI — it was discovering that the premise of the "overhead ratio" is shakier than I expected, right down at the data layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The data problem is the thesis.&lt;/strong&gt; I went in planning to compute the classic program/management/fundraising expense split and rank charities by it. ProPublica's own API docs say that breakdown is "formtype dependent" across the different 990 variants (990 vs. 990-EZ vs. 990-PF) — meaning you &lt;em&gt;can't&lt;/em&gt; reliably compute the overhead ratio from clean, normalized filing data in the first place. That's not a workaround I found around a limitation; it became the actual argument. Instead I built five metrics from fields ProPublica &lt;em&gt;does&lt;/em&gt; normalize consistently: reserve months, operating margin, staff-investment share (officer comp + wages + payroll tax as % of spending), fundraising cost per dollar (from the actual "professional fundraising fees" line item), and revenue concentration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Peer percentiles, built at build-time.&lt;/strong&gt; To compare an org against similar ones, I sample ~75 organizations per NTEE major category (10 categories total) via ProPublica's search API, compute each one's latest-year metrics, and cache the resulting distributions as static JSON. That means percentile lookups are instant at request time and don't depend on ProPublica's API being up during a demo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemini does the writing, not the arithmetic.&lt;/strong&gt; The narrative card hands Gemini 2.5 Flash a fully pre-computed set of numbers and a strict JSON response schema — its job is phrasing and framing, never inventing a figure. I hit a real bug here: Gemini 2.5 Flash spends hidden "thinking" tokens out of the same &lt;code&gt;maxOutputTokens&lt;/code&gt; budget by default, which was silently truncating longer prompts mid-JSON. Disabling &lt;code&gt;thinkingConfig.thinkingBudget: 0&lt;/code&gt; fixed it instantly — this is pure formatting, not a reasoning task, so there was nothing to think about anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The chat needed the opposite fix.&lt;/strong&gt; For the free-form chat feature, I first tried the same &lt;code&gt;thinkingBudget: 0&lt;/code&gt; setting and got a subtle but real bug: the model would cite our exact, correct numbers but draw wrong conclusions from them — e.g. claiming a year had a "negative operating margin" when it actually had a +25% margin and falling reserves for an unrelated reason (expenses scaling up faster than reserves). Chat questions require live arithmetic across multiple derived fields, not just rephrasing a precomputed conclusion, so I left thinking enabled there and added explicit guardrails in the prompt about not conflating reserve-month drops with deficits. The chat is scoped to one org's filing data via a dossier built from the same computed metrics, and it's instructed to say "I don't know" rather than invent anything about a charity's programs, leadership, or controversies — none of which is in the data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two smaller bugs worth mentioning&lt;/strong&gt;, because they're the kind that only show up when you actually test the numbers instead of trusting that the code "looks right": ProPublica's fuzzy name search resolved "charity: water" and "ACLU Foundation" to unrelated same-ish-named orgs (I hardcoded the correct EINs after verifying against Charity Navigator), and a percentile field for "fundraising cost" was showing a spurious "100% more efficient than peers" for orgs that report zero professional fundraising fees, because a value of exactly &lt;code&gt;0&lt;/code&gt; was being treated as a real 0th-percentile cost instead of "not applicable."&lt;/p&gt;

&lt;p&gt;Stack: Next.js 16 (App Router) + TypeScript + Tailwind + Recharts, deployed on Vercel, with a small Node build script that fetches and caches the ProPublica data and peer cohorts ahead of time so the demo never depends on a live third-party API mid-presentation.&lt;/p&gt;

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

&lt;p&gt;Submitting for &lt;strong&gt;Best Use of Google AI&lt;/strong&gt; — Gemini 2.5 Flash powers both the myth-buster narrative generator (strict JSON schema, zero invented numbers, deterministic rubric-engine fallback if the API is ever unavailable) and the grounded multi-turn chat.&lt;/p&gt;

&lt;p&gt;Solo submission.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Two AI debuggers found the same bug. Both stopped one function short.</title>
      <dc:creator>Hassan Imam</dc:creator>
      <pubDate>Sat, 22 Aug 2026 02:31:33 +0000</pubDate>
      <link>https://dev.to/hassan_shah_733ea1eb37c88/two-ai-debuggers-found-the-same-bug-both-stopped-one-function-short-n10</link>
      <guid>https://dev.to/hassan_shah_733ea1eb37c88/two-ai-debuggers-found-the-same-bug-both-stopped-one-function-short-n10</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ChatGoogleGenerativeAI&lt;/code&gt; computed your Gemini token counts correctly, then threw&lt;br&gt;
half of them away. Any cost tracker built on LangChain's callback system read&lt;br&gt;
&lt;strong&gt;zero tokens&lt;/strong&gt; on every call.&lt;/p&gt;

&lt;p&gt;I reproduced it on the current release, fixed it, and shipped&lt;br&gt;
&lt;a href="https://github.com/langchain-ai/langchain-google/pull/1954" rel="noopener noreferrer"&gt;PR #1954&lt;/a&gt; to&lt;br&gt;
&lt;code&gt;langchain-google&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The interesting part: I pointed two AI debugging tools at the same code — Google's&lt;br&gt;
Gemini CLI and Sentry's Seer. Both correctly identified the same function. Seer&lt;br&gt;
even independently validated the trickiest design decision in the patch. &lt;strong&gt;Neither&lt;br&gt;
of them found the second half of the fix&lt;/strong&gt; — and without it, the bug is still there.&lt;/p&gt;


&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/langchain-ai/langchain-google" rel="noopener noreferrer"&gt;&lt;code&gt;langchain-google-genai&lt;/code&gt;&lt;/a&gt; is&lt;br&gt;
the official LangChain integration for Google's Gemini models — the package you&lt;br&gt;
install to use &lt;code&gt;ChatGoogleGenerativeAI&lt;/code&gt; in a LangChain app. It lives in the&lt;br&gt;
&lt;code&gt;langchain-google&lt;/code&gt; monorepo alongside the Vertex AI and community packages, and&lt;br&gt;
ships roughly every couple of weeks.&lt;/p&gt;

&lt;p&gt;Token counts are not a nice-to-have in that stack. They are how you bill&lt;br&gt;
customers, enforce per-tenant quotas, populate observability dashboards, and&lt;br&gt;
notice that a prompt change quietly tripled your spend. LangChain exposes them in&lt;br&gt;
two places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;AIMessage.usage_metadata&lt;/code&gt; — the modern, per-message standard&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LLMResult.llm_output["token_usage"]&lt;/code&gt; — the older convention that the
&lt;strong&gt;callback system&lt;/strong&gt; reads, and that every other chat integration still populates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Callbacks are what production apps hook into, because they fire for every call&lt;br&gt;
without touching business logic. Which is exactly what made this bug expensive:&lt;br&gt;
the counts were right, the callbacks read zero, and nothing raised an error.&lt;/p&gt;


&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;
&lt;h3&gt;
  
  
  The bug
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/langchain-ai/langchain-google/issues/957" rel="noopener noreferrer"&gt;Issue #957&lt;/a&gt; reports&lt;br&gt;
that &lt;code&gt;BaseCallbackHandler.on_llm_end&lt;/code&gt; sees no token usage for&lt;br&gt;
&lt;code&gt;ChatGoogleGenerativeAI&lt;/code&gt;. The issue was closed with no linked PR and no fix commit,&lt;br&gt;
and the package was rewritten onto the unified &lt;code&gt;google-genai&lt;/code&gt; SDK in 4.0.0 — so my&lt;br&gt;
first job was to find out whether the bug still existed at all. In other words: a&lt;br&gt;
bug the tracker considered resolved, in a package that had since been rewritten&lt;br&gt;
from the ground up.&lt;/p&gt;

&lt;p&gt;It does. Here it is on &lt;code&gt;langchain-google-genai&lt;/code&gt; &lt;strong&gt;4.3.4&lt;/strong&gt;, the current release:&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;TokenProbe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseCallbackHandler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_llm_end&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;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LLMResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llm_output:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llm_output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message.usage_metadata:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage_metadata&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;llm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChatGoogleGenerativeAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-2.5-flash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;callbacks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;TokenProbe&lt;/span&gt;&lt;span class="p"&gt;()])&lt;/span&gt;
&lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;([[&lt;/span&gt;&lt;span class="nc"&gt;HumanMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Say hello in 3 words.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;llm_output&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="err"&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;message.usage_metadata:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;'input_tokens':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'output_tokens':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;592&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'total_tokens':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;600&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;The counts exist. They're just not where callback-based token tracking looks for&lt;br&gt;
them. Every &lt;code&gt;get_openai_callback&lt;/code&gt;-style cost tracker, every LangSmith-style&lt;br&gt;
accounting layer reading &lt;code&gt;llm_output["token_usage"]&lt;/code&gt;, reports zero for Gemini.&lt;/p&gt;


&lt;h3&gt;
  
  
  Root cause
&lt;/h3&gt;

&lt;p&gt;It's in &lt;code&gt;libs/genai/langchain_google_genai/chat_models.py&lt;/code&gt;, in&lt;br&gt;
&lt;code&gt;_response_to_result&lt;/code&gt;. The function builds &lt;code&gt;llm_output&lt;/code&gt; at line 1298:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;llm_output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt_feedback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_feedback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_feedback&lt;/span&gt;
    &lt;span class="k"&gt;else&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;Then it computes the token counts into &lt;code&gt;lc_usage&lt;/code&gt;, attaches them to the message&lt;br&gt;
at line 1404:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage_metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lc_usage&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and returns at line 1456:&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;return&lt;/span&gt; &lt;span class="nc"&gt;ChatResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;generations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;llm_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;llm_output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;llm_output&lt;/code&gt; is never touched in between. &lt;code&gt;grep&lt;/code&gt; confirms it: the identifier&lt;br&gt;
appears at exactly &lt;strong&gt;two lines in the entire file&lt;/strong&gt; — where it's created and where&lt;br&gt;
it's returned.&lt;/p&gt;

&lt;p&gt;The package migrated to the modern &lt;code&gt;usage_metadata&lt;/code&gt; standard on the message and&lt;br&gt;
left the &lt;code&gt;llm_output["token_usage"]&lt;/code&gt; convention — which every other LangChain chat&lt;br&gt;
integration still populates, and which the callback API still reads — empty.&lt;/p&gt;


&lt;h3&gt;
  
  
  Why the obvious fix is incomplete
&lt;/h3&gt;

&lt;p&gt;This is the part both AI tools missed, and it's the reason a one-line patch&lt;br&gt;
doesn't work.&lt;/p&gt;

&lt;p&gt;Populating &lt;code&gt;llm_output&lt;/code&gt; inside &lt;code&gt;_response_to_result&lt;/code&gt; fixes the &lt;strong&gt;callback&lt;/strong&gt; path.&lt;br&gt;
It does &lt;strong&gt;not&lt;/strong&gt; fix the value you get back from &lt;code&gt;generate()&lt;/code&gt;. Here's why, from&lt;br&gt;
&lt;code&gt;langchain_core/language_models/chat_models.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;flattened_outputs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;LLMResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generations&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;llm_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llm_output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;llm_output&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;_combine_llm_outputs&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llm_output&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LLMResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;generations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;llm_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;llm_output&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;run_managers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flattened_output&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_managers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flattened_outputs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on_llm_end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flattened_output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;- gets res.llm_output DIRECTLY
&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;                                  &lt;span class="c1"&gt;# &amp;lt;- built via _combine_llm_outputs
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two different objects. &lt;code&gt;on_llm_end&lt;/code&gt; receives &lt;code&gt;flattened_output&lt;/code&gt;, built from&lt;br&gt;
&lt;code&gt;res.llm_output&lt;/code&gt; directly. The &lt;strong&gt;return value&lt;/strong&gt; is built through&lt;br&gt;
&lt;code&gt;_combine_llm_outputs&lt;/code&gt; — whose &lt;code&gt;BaseChatModel&lt;/code&gt; default is:&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;_combine_llm_outputs&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;_llm_outputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ChatGoogleGenerativeAI&lt;/code&gt; never overrode it. So patch only&lt;br&gt;
&lt;code&gt;_response_to_result&lt;/code&gt; and &lt;code&gt;result.llm_output&lt;/code&gt; is &lt;em&gt;still&lt;/em&gt; &lt;code&gt;{}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My reproduction printed both paths, which is the only reason I noticed.&lt;/p&gt;


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

&lt;p&gt;Two changes. First, mirror the counts into &lt;code&gt;llm_output&lt;/code&gt; using the same key&lt;br&gt;
convention as the other integrations:&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;if&lt;/span&gt; &lt;span class="n"&gt;lc_usage&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;llm_output&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_usage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lc_usage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;completion_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lc_usage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lc_usage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;llm_output&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;usage_metadata&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lc_usage&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;llm_output&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model_version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note it sources from &lt;code&gt;lc_usage&lt;/code&gt;, not the raw response. &lt;code&gt;lc_usage&lt;/code&gt; has already had&lt;br&gt;
&lt;code&gt;prev_usage&lt;/code&gt; subtracted, so on the streaming path each chunk carries its own delta&lt;br&gt;
— consistent with how &lt;code&gt;message.usage_metadata&lt;/code&gt; already behaves.&lt;/p&gt;

&lt;p&gt;Second, the override that makes the return value work, mirroring&lt;br&gt;
&lt;code&gt;langchain-openai&lt;/code&gt;:&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;_combine_llm_outputs&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;llm_outputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;token_usage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;llm_output&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;llm_outputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;llm_output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;llm_output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_usage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;items&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;value&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;token_usage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token_usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;llm_output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;llm_output&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model_name&lt;/span&gt;&lt;span class="sh"&gt;"&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;token_usage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_usage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token_usage&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;combined&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&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="err"&gt;llm_output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;'token_usage':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;'prompt_tokens':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'completion_tokens':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;775&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="err"&gt;'total_tokens':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;783&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="err"&gt;'usage_metadata':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&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;'model_name':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'gemini&lt;/span&gt;&lt;span class="mf"&gt;-2.5&lt;/span&gt;&lt;span class="err"&gt;-flash'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;RESULT.llm_output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;'model_name':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'gemini&lt;/span&gt;&lt;span class="mf"&gt;-2.5&lt;/span&gt;&lt;span class="err"&gt;-flash'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'token_usage':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&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;&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%2Fbwk5yd0od90rj2on8svf.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%2Fbwk5yd0od90rj2on8svf.png" alt="The PR diff showing both changes — the token_usage block in _response_to_result and the _combine_llm_outputs override" width="800" height="700"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deliberately out of scope:&lt;/strong&gt; streaming aggregation. &lt;code&gt;_response_to_result&lt;/code&gt; is&lt;br&gt;
shared with the streaming path, and the new &lt;code&gt;llm_output&lt;/code&gt; carries the same&lt;br&gt;
per-chunk delta as &lt;code&gt;message.usage_metadata&lt;/code&gt;, so the two stay consistent. Changing&lt;br&gt;
how chunks &lt;em&gt;aggregate&lt;/em&gt; is a separate problem with real double-counting risk&lt;br&gt;
(see langchainjs #8266) and deserves its own PR.&lt;/p&gt;


&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Tests and CI
&lt;/h3&gt;

&lt;p&gt;Four unit tests covering both sites plus the no-usage-metadata edge case. They&lt;br&gt;
assert the invariant that matters — that &lt;code&gt;llm_output&lt;/code&gt; agrees with the message:&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;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llm_output&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_usage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;usage_metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full suite: &lt;strong&gt;377 passing&lt;/strong&gt;, no regressions. &lt;code&gt;ruff&lt;/code&gt;, &lt;code&gt;ruff format&lt;/code&gt; and &lt;code&gt;mypy&lt;/code&gt;&lt;br&gt;
clean across all 35 source files. CI green on &lt;strong&gt;Python 3.10, 3.11, 3.12, 3.13&lt;br&gt;
and 3.14&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%2F7fxqcsd4wl3vi87ikpo6.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%2F7fxqcsd4wl3vi87ikpo6.png" alt="GitHub CI checks all passing: make lint on Python 3.10 and 3.14, make test on 3.10 through 3.14" width="798" height="314"&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%2F7w2uafz0q0bcmy7qgfho.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%2F7w2uafz0q0bcmy7qgfho.png" alt="Pull request 1954 on langchain-ai/langchain-google, 149 additions and 1 deletion across 2 files" width="800" height="1125"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h3&gt;
  
  
  What I took away
&lt;/h3&gt;

&lt;p&gt;Two AI tools, different vendors, different architectures, different access —&lt;br&gt;
Seer had my actual repository — reached the same correct-but-partial answer. Both&lt;br&gt;
found the function where the tokens &lt;em&gt;should&lt;/em&gt; be written. Neither traced what&lt;br&gt;
happens to &lt;code&gt;llm_output&lt;/code&gt; after &lt;code&gt;_response_to_result&lt;/code&gt; returns.&lt;/p&gt;

&lt;p&gt;That's not a knock on either. Both were genuinely useful, and Seer's streaming&lt;br&gt;
observation was sharper than I expected. But the gap is instructive: they answered&lt;br&gt;
the question as posed — &lt;em&gt;why is &lt;code&gt;llm_output&lt;/code&gt; empty?&lt;/em&gt; — and stopped. They didn't&lt;br&gt;
ask the follow-up: &lt;em&gt;does fixing that actually make the value reach the caller?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The reproduction script is what caught it, because it printed both paths and only&lt;br&gt;
one of them changed.&lt;/p&gt;


&lt;h2&gt;
  
  
  Best Use of Sentry
&lt;/h2&gt;

&lt;p&gt;I ran the instrumented probe against the unpatched fork and against the patched&lt;br&gt;
one, tagging each run &lt;code&gt;before&lt;/code&gt; / &lt;code&gt;after&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error monitoring.&lt;/strong&gt; A cost-tracker assertion failing on the empty &lt;code&gt;llm_output&lt;/code&gt;,&lt;br&gt;
captured across eight runs. Each event carries the package versions and a context&lt;br&gt;
block naming the suspect file and function, so the issue page alone tells you&lt;br&gt;
where to look.&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%2F4bjb6j170bb5kamgfb9f.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%2F4bjb6j170bb5kamgfb9f.png" alt="Sentry issues list showing TokenAccountingError with 8 events, culprit gemini-token-probe-before" width="800" height="186"&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%2Frgl8xnmmjbrmgt15xl2v.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%2Frgl8xnmmjbrmgt15xl2v.png" alt="Sentry issue detail showing tags for langchain-core 1.6.0 and langchain-google-genai 4.3.4, plus the issue 957 context block naming the suspect file and function" width="800" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tracing.&lt;/strong&gt; The &lt;code&gt;gen_ai.chat&lt;/code&gt; span, before and after:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tokens&lt;/td&gt;
&lt;td&gt;8 in + 29 out = &lt;strong&gt;37 total&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;8 in + 741 out = &lt;strong&gt;749 total&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Issues on trace&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr0y94d409h38e6k83brw.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%2Fr0y94d409h38e6k83brw.png" alt="Sentry trace before the fix: gen_ai.chat span showing 8 in, 29 out, 37 total tokens, with 1 issue on the trace" width="800" height="289"&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%2Fkg2zkipnq3evdnyw5htg.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%2Fkg2zkipnq3evdnyw5htg.png" alt="Sentry trace after the fix: gen_ai.chat span showing 8 in, 741 out, 749 total tokens, with 0 issues on the trace" width="799" height="319"&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%2Ftnfaps0ysubayesa0z9c.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%2Ftnfaps0ysubayesa0z9c.png" alt="Sentry error list filtered to environment after, showing no issues match the search" width="799" height="242"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent monitoring and conversation traces.&lt;/strong&gt; Because the probe ran with&lt;br&gt;
&lt;code&gt;include_prompts=True&lt;/code&gt;, Sentry captured the full exchange on the &lt;code&gt;gen_ai.chat&lt;/code&gt;&lt;br&gt;
span — prompt, completion, model, latency, cost and token breakdown.&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%2F62xg0iint8o4h9ufnffs.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%2F62xg0iint8o4h9ufnffs.png" alt="Sentry gen_ai.chat span Output tab showing the response " width="800" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This one span makes the cost argument better than any explanation of mine. The&lt;br&gt;
prompt was &lt;em&gt;"Say hello in 3 words."&lt;/em&gt; The response was &lt;strong&gt;"Hello, my friend."&lt;/strong&gt;&lt;br&gt;
Three words. It cost &lt;strong&gt;749 tokens&lt;/strong&gt;, because &lt;code&gt;gemini-2.5-flash&lt;/code&gt; spent &lt;strong&gt;736&lt;/strong&gt; of&lt;br&gt;
them reasoning before answering:&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="err"&gt;'output_token_details':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;'reasoning':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;736&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;That is exactly the kind of spend you cannot eyeball from the response text, and&lt;br&gt;
exactly what token accounting exists to surface. Before this fix, a&lt;br&gt;
&lt;code&gt;BaseCallbackHandler&lt;/code&gt;-based cost tracker watching that call reported zero tokens&lt;br&gt;
and zero cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And here's where I have to be honest about something.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I expected to write "Sentry showed zero Gemini tokens." It would have been a&lt;br&gt;
great line. It's also false, and my own traces prove it — the token counts are&lt;br&gt;
correct in &lt;em&gt;both&lt;/em&gt; runs.&lt;/p&gt;

&lt;p&gt;I checked the SDK source before publishing. &lt;code&gt;sentry_sdk/integrations/langchain.py&lt;/code&gt;&lt;br&gt;
resolves tokens like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;possible_names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;usage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token_usage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;usage_metadata&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_get_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# checked FIRST
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;message&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;possible_names&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="n"&gt;llm_output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_get_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llm_output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# only if message had nothing
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sentry checks the &lt;strong&gt;message&lt;/strong&gt; before &lt;code&gt;llm_output&lt;/code&gt;, and falls back to&lt;br&gt;
&lt;code&gt;_extract_tokens_from_generations&lt;/code&gt; when the &lt;code&gt;LLMResult&lt;/code&gt; has nothing. In other&lt;br&gt;
words: &lt;strong&gt;Sentry ships a defensive workaround for exactly this class of bug.&lt;/strong&gt; It&lt;br&gt;
reports Gemini tokens correctly &lt;em&gt;despite&lt;/em&gt; the defect, because it doesn't trust&lt;br&gt;
&lt;code&gt;llm_output&lt;/code&gt; to be populated.&lt;/p&gt;

&lt;p&gt;That's a better story than the one I expected to tell. The fix doesn't repair&lt;br&gt;
Sentry's numbers — it removes the need for the workaround.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Logs.&lt;/strong&gt; Structured logs recording &lt;code&gt;llm_output&lt;/code&gt; on every &lt;code&gt;on_llm_end&lt;/code&gt;, empty&lt;br&gt;
before and fully populated after.&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%2Fqrhsk9v9bgjofvwki4xp.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%2Fqrhsk9v9bgjofvwki4xp.png" alt="Sentry structured logs under environment before, showing on_llm_end llm_output equals an empty dict" width="800" height="399"&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%2Fwwd0rotaw85za4kdumg2.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%2Fwwd0rotaw85za4kdumg2.png" alt="Sentry structured logs under environment after, showing on_llm_end llm_output populated with token_usage, usage_metadata and model_name" width="800" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Seer.&lt;/strong&gt; Sentry's AI debugger, connected to my fork. It read &lt;code&gt;chat_models.py&lt;/code&gt;&lt;br&gt;
straight from the repo, identified &lt;code&gt;_response_to_result&lt;/code&gt;, and produced a&lt;br&gt;
two-step plan. Step 2 said to use the delta-adjusted &lt;code&gt;lc_usage&lt;/code&gt; rather than the&lt;br&gt;
raw response values, so streaming chunks stay correct — independently arriving at&lt;br&gt;
the same conclusion I'd flagged for reviewers in the PR description.&lt;/p&gt;

&lt;p&gt;Its cited evidence covers &lt;code&gt;chat_models.py&lt;/code&gt; L1292–L1489. &lt;code&gt;_combine_llm_outputs&lt;/code&gt;&lt;br&gt;
lives at L3370. It never got there.&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%2F9vxcgjqa4f7sfjn8250u.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%2F9vxcgjqa4f7sfjn8250u.png" alt="Sentry Seer root cause analysis and solution plan, showing it read chat_models.py from the fork and identified _response_to_result" width="800" height="768"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One practical note for anyone instrumenting LangChain: &lt;strong&gt;it swallows exceptions&lt;br&gt;
raised inside callbacks.&lt;/strong&gt; My first probe asserted inside &lt;code&gt;on_llm_end&lt;/code&gt; and nothing&lt;br&gt;
ever reached Sentry. Assert on the returned &lt;code&gt;LLMResult&lt;/code&gt; instead.&lt;/p&gt;


&lt;h2&gt;
  
  
  Best Use of Google AI
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Gemini CLI as an agentic bisect.&lt;/strong&gt; I ran &lt;code&gt;@google/gemini-cli&lt;/code&gt; v0.56.0 with&lt;br&gt;
&lt;code&gt;gemini-2.5-flash&lt;/code&gt; against the &lt;em&gt;unpatched&lt;/em&gt; tree, so the diagnosis couldn't be&lt;br&gt;
contaminated by my fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gemini &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;prompt&amp;gt;"&lt;/span&gt; &lt;span class="nt"&gt;--model&lt;/span&gt; gemini-2.5-flash &lt;span class="nt"&gt;--skip-trust&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It found &lt;code&gt;_response_to_result&lt;/code&gt;, cited lines 1298–1302 for the initialization,&lt;br&gt;
1404 for &lt;code&gt;message.usage_metadata = lc_usage&lt;/code&gt;, and 1456 for the return, then&lt;br&gt;
proposed &lt;code&gt;token_usage&lt;/code&gt; / &lt;code&gt;usage_metadata&lt;/code&gt; / &lt;code&gt;model_name&lt;/code&gt; with the correct key&lt;br&gt;
names — essentially the patch I'd written, at the same insertion point.&lt;/p&gt;

&lt;p&gt;It did not mention &lt;code&gt;_combine_llm_outputs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemini API as a correctness oracle.&lt;/strong&gt; Self-consistency isn't enough — matching&lt;br&gt;
numbers could both be wrong. So I validated the patched &lt;code&gt;llm_output&lt;/code&gt; against the&lt;br&gt;
raw &lt;code&gt;google-genai&lt;/code&gt; SDK's own &lt;code&gt;prompt_token_count&lt;/code&gt; for the same prompt, which is&lt;br&gt;
deterministic for a fixed model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prompt                                                SDK in   LC in  match   sums
Say hello in 3 words.                                      8       8   PASS   PASS
Name one primary color. Answer with a single word.        12      12   PASS   PASS
Reply with exactly the word: ok                            8       8   PASS   PASS

RESULT: 3/3 passed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PR:&lt;/strong&gt; &lt;a href="https://github.com/langchain-ai/langchain-google/pull/1954" rel="noopener noreferrer"&gt;https://github.com/langchain-ai/langchain-google/pull/1954&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Issue:&lt;/strong&gt; &lt;a href="https://github.com/langchain-ai/langchain-google/issues/957" rel="noopener noreferrer"&gt;https://github.com/langchain-ai/langchain-google/issues/957&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fork:&lt;/strong&gt; &lt;a href="https://github.com/hassan-2050/langchain-google" rel="noopener noreferrer"&gt;https://github.com/hassan-2050/langchain-google&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Environment: &lt;code&gt;langchain-google-genai&lt;/code&gt; 4.3.4, &lt;code&gt;langchain-core&lt;/code&gt; 1.6.0,&lt;br&gt;
&lt;code&gt;sentry-sdk&lt;/code&gt; 2.68.0, &lt;code&gt;gemini-2.5-flash&lt;/code&gt;, Python 3.13.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <item>
      <title>bugsmash</title>
      <dc:creator>Hassan Imam</dc:creator>
      <pubDate>Wed, 19 Aug 2026 02:15:04 +0000</pubDate>
      <link>https://dev.to/hassan_shah_733ea1eb37c88/bugsmash-1cag</link>
      <guid>https://dev.to/hassan_shah_733ea1eb37c88/bugsmash-1cag</guid>
      <description></description>
    </item>
    <item>
      <title>AccessLens — A persistent on-device visual interpreter for the blind</title>
      <dc:creator>Hassan Imam</dc:creator>
      <pubDate>Sun, 24 May 2026 06:46:43 +0000</pubDate>
      <link>https://dev.to/hassan_shah_733ea1eb37c88/accesslens-a-persistent-on-device-visual-interpreter-for-the-blind-8h6</link>
      <guid>https://dev.to/hassan_shah_733ea1eb37c88/accesslens-a-persistent-on-device-visual-interpreter-for-the-blind-8h6</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/google-gemma-2026-05-06"&gt;Gemma 4 Challenge: Build with Gemma 4&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;AccessLens&lt;/strong&gt; is an Android app that turns a Pixel 8 worn on a lanyard into a persistent visual interpreter for blind and low-vision users. Rear camera forward, bone-conduction headphones in, the phone describes the world — and &lt;em&gt;remembers&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The problem with existing visual-assist apps (Be My Eyes, Seeing AI, Envision) is that they are screen-bound, stateless, and cloud-bound. A blind person navigates by sound; an app that needs you to hold up a phone, tap a screen, and wait on a datacenter interrupts that signal stream. AccessLens is different on three axes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Worn, not held:&lt;/strong&gt; Two physical buttons drive everything. Volume Up → read text in front of me, verbatim. Volume Down → describe this room with memory from earlier today and recent days. A gyroscope-based &lt;code&gt;SettleTrigger&lt;/code&gt; also fires a description &lt;em&gt;automatically&lt;/em&gt; when the user stops walking.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Persistent memory across days/weeks:&lt;/strong&gt; Every gesture writes a &lt;code&gt;SessionEvent&lt;/code&gt; to a SQLCipher database. A nightly Gemma 4 worker compresses each day into a &lt;code&gt;DailySummary&lt;/code&gt;; Sundays roll into a &lt;code&gt;WeeklyMemory&lt;/code&gt;. LONG-press prompts splice that history into the Gemma call, so the model has a world model of &lt;em&gt;this specific apartment, this specific day&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;100% on-device:&lt;/strong&gt; No image, audio, embedding, or location leaves the phone. SQLCipher + Android KeyStore (AES-256-GCM wrapping a &lt;code&gt;SecureRandom&lt;/code&gt; DB key) protect everything at rest. A &lt;code&gt;SelfTest&lt;/code&gt; on first launch opens a probe DB with the wrong key and asserts the read fails before the app reports encryption healthy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Face recognition uses MediaPipe FaceLandmarker to produce a 192-dim L2-normalized landmark vector per enrolled person. At identify time, cosine-similar matches inject &lt;strong&gt;only the names&lt;/strong&gt; into the Gemma prompt — Gemma never sees a face crop or an embedding, code-review-verified.&lt;/p&gt;

&lt;p&gt;Three gestures, three target latencies (Pixel 8, Tensor G3): SINGLE ≤14 s end-to-end, DOUBLE scales with text length, LONG adds memory retrieval. Voice fillers ("I'm looking…", "Still looking…") cover the prefill gap so the user hears acoustic progress, not dead air. Everything runs with airplane mode on after the model is pushed once.&lt;/p&gt;

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

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/4ZlaVqXlAc4"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

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


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/hassaninnovate" rel="noopener noreferrer"&gt;
        hassaninnovate
      &lt;/a&gt; / &lt;a href="https://github.com/hassaninnovate/AccessLens" rel="noopener noreferrer"&gt;
        AccessLens
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A blind person's lanyard, powered by Gemma 4 E2B on a Pixel 8. 100% on-device visual assistant with persistent memory and face recognition.
    &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;AccessLens&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;An always-on, on-device visual interpreter for blind and low-vision users — built for the DEV.to "Build with Gemma 4" challenge.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/hassaninnovate/AccessLens/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/5b60841bea9e11d9d0b0950d690c9bc554e06385634056a7d5d62a15d1a4eabe/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4170616368655f322e302d626c75652e737667" alt="License: Apache 2.0"&gt;&lt;/a&gt;
&lt;a href="https://github.com/hassaninnovate/AccessLens#" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/89ea7be8eadb64987fcd6f39c332b8850d3f1b411750f72dcfc6b907ec73aeac/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f706c6174666f726d2d416e64726f696425323031332532422d677265656e2e737667" alt="Platform"&gt;&lt;/a&gt;
&lt;a href="https://github.com/hassaninnovate/AccessLens#" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/aad418e159887ad6a1a78f63f4092bfe5749f2d8ebcefa4e3187d02db161396f/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6d6f64656c2d47656d6d615f345f4532422d6f72616e67652e737667" alt="Model"&gt;&lt;/a&gt;
&lt;a href="https://github.com/hassaninnovate/AccessLens#privacy-invariants" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/0a8d8b5a8c05c0d263eae2efe64ce37bb841d10d59e9ec1d73cf64b8dce0ac4a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f696e666572656e63652d3130302532355f6f6e2d2d6465766963652d627269676874677265656e2e737667" alt="Privacy"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Pitch&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;A phone worn on a lanyard becomes the user's "eyes." The rear camera is always on; the gyroscope watches for motion. &lt;strong&gt;When the user stops walking, AccessLens describes what's in front of them.&lt;/strong&gt; When a friend whose face has been enrolled walks into frame, the phone says their name. When the user wants to read what's in front of them, they press &lt;em&gt;Volume Up&lt;/em&gt;; for a richer description of the room, &lt;em&gt;Volume Down&lt;/em&gt;. Bluetooth bone-conduction headphones carry the audio — the user's ears stay free for the world.&lt;/p&gt;
&lt;p&gt;What separates AccessLens from existing apps like Be My Eyes, Seeing AI, and Envision is &lt;strong&gt;persistent on-device memory + 100% on-device inference&lt;/strong&gt;. Existing tools are stateless and cloud-bound. AccessLens runs Gemma 4 E2B locally via LiteRT-LM, encrypts…&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/hassaninnovate/AccessLens" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Apache 2.0. The repo includes the full Kotlin/Compose source, the encryption self-test, the nightly compression WorkManager job, and a README documenting which file enforces each of the six privacy invariants.&lt;/p&gt;

&lt;p&gt;Reference implementation that taught me the LiteRT-LM API: &lt;a href="https://github.com/google-ai-edge/gallery" rel="noopener noreferrer"&gt;google-ai-edge/gallery&lt;/a&gt; — adapted patterns are cited inline in &lt;code&gt;inference/LiteRtLmRuntime.kt&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Used Gemma 4
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Model Chosen:&lt;/strong&gt; &lt;strong&gt;Gemma 4 E2B&lt;/strong&gt; (&lt;code&gt;litert-community/gemma-4-E2B-it-litert-lm&lt;/code&gt;, ~2.59 GB int4), loaded once at service start via LiteRT-LM 0.12.0 with &lt;code&gt;Backend.GPU()&lt;/code&gt; for the vision adapter. &lt;/p&gt;

&lt;p&gt;This model was the perfect fit for AccessLens for three core reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Multimodal in one model, on-device:&lt;/strong&gt; Image input goes in as &lt;code&gt;Content.ImageBytes&lt;/code&gt;, text as &lt;code&gt;Content.Text&lt;/code&gt;, in that order (per the Gallery's "for accurate last token" comment), all through one &lt;code&gt;Engine.generate&lt;/code&gt; call. No separate vision encoder + decoder to stitch, no second model to keep resident. That fits the latency budget &lt;em&gt;and&lt;/em&gt; the memory budget on Pixel-class 8 GB RAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;E2B is the smallest competent multimodal Gemma 4:&lt;/strong&gt; It fits in RAM alongside MediaPipe FaceLandmarker, a CameraX pipeline, and the Compose UI without OOM-ing on a Pixel 8. I prototyped against E4B (the brief's "quality path") and measured the latency lift on one-sentence scene descriptions — not worth doubling the prefill cost for a use case where the user is waiting in real time, lanyard-mounted, with no screen feedback. The architecture is &lt;em&gt;parametric&lt;/em&gt; on the model path (&lt;code&gt;InferenceRuntime.load(modelPath, Modality)&lt;/code&gt;), so a future LONG-press branch could swap to E4B in one line. I documented the tradeoff in the README and shipped E2B for all three gestures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma is the only practical way to do nightly memory compression on-device:&lt;/strong&gt; The 03:00 &lt;code&gt;CompressionWorker&lt;/code&gt; calls Gemma in JSON mode to compress the day's &lt;code&gt;SessionEvent&lt;/code&gt; rows into a single &lt;code&gt;DailySummary&lt;/code&gt;, and on Sundays into a &lt;code&gt;WeeklyMemory&lt;/code&gt;. That's a real LLM task — extracting persistent facts, deduplicating recurring observations, distinguishing "the blue mug is mine" from "I saw a blue mug today" — and it has to happen without a network. E2B handles it in under a minute per day on Tensor G3 while the phone is on the charger.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Production fixes discovered during implementation:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;  The LiteRT-LM Android artifact must be &lt;strong&gt;0.12.0 or later&lt;/strong&gt; — 0.11.0 fails vision init inside &lt;code&gt;vision_litert_compiled_model_executor.cc:273&lt;/code&gt; on Tensor G3.&lt;/li&gt;
&lt;li&gt;  AndroidManifest needs &lt;code&gt;&amp;lt;uses-native-library&amp;gt;&lt;/code&gt; declarations for &lt;code&gt;libOpenCL.so&lt;/code&gt;, &lt;code&gt;libOpenCL-car.so&lt;/code&gt;, &lt;code&gt;libOpenCL-pixel.so&lt;/code&gt; (all &lt;code&gt;android:required="false"&lt;/code&gt;). Without them, Android 12+ silently denies GPU OpenCL access and the vision backend fails to initialize. Documented at &lt;a href="https://ai.google.dev/edge/litert-lm/android" rel="noopener noreferrer"&gt;ai.google.dev/edge/litert-lm/android&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The thing I'm proudest of:&lt;/strong&gt; when you uninstall AccessLens, the KeyStore wrapping key is destroyed with it. The encrypted DB on disk becomes cryptographically unrecoverable. The user can throw the phone away and their memories — kitchen layout, friends' faces, places they've been — go with it. That's what on-device privacy is supposed to mean, and Gemma 4 + LiteRT-LM made it possible without compromising the assistant on quality.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>gemmachallenge</category>
      <category>gemma</category>
    </item>
    <item>
      <title>AccessLens — a blind person's lanyard, powered by Gemma 4 on-device</title>
      <dc:creator>Hassan Imam</dc:creator>
      <pubDate>Sat, 23 May 2026 17:27:35 +0000</pubDate>
      <link>https://dev.to/hassan_shah_733ea1eb37c88/accesslens-a-blind-persons-lanyard-powered-by-gemma-4-on-device-3l8b</link>
      <guid>https://dev.to/hassan_shah_733ea1eb37c88/accesslens-a-blind-persons-lanyard-powered-by-gemma-4-on-device-3l8b</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/google-gemma-2026-05-06"&gt;Gemma 4 Challenge: Build with Gemma 4&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;AccessLens&lt;/strong&gt; is an Android app that turns a Pixel 8 worn on a lanyard into a persistent visual interpreter for blind and low-vision users. Rear camera forward, bone-conduction headphones in, the phone describes the world — and &lt;em&gt;remembers&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The problem with existing visual-assist apps (Be My Eyes, Seeing AI, Envision) is that they are screen-bound, stateless, and cloud-bound. A blind person navigates by sound; an app that needs you to hold up a phone, tap a screen, and wait on a datacenter interrupts that signal stream. AccessLens is different on three axes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Worn, not held.&lt;/strong&gt; Two physical buttons drive everything. Volume Up → read text in front of me, verbatim. Volume Down → describe this room with memory from earlier today and recent days. A gyroscope-based &lt;code&gt;SettleTrigger&lt;/code&gt; also fires a description &lt;em&gt;automatically&lt;/em&gt; when the user stops walking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistent memory across days/weeks.&lt;/strong&gt; Every gesture writes a &lt;code&gt;SessionEvent&lt;/code&gt; to a SQLCipher database. A nightly Gemma 4 worker compresses each day into a &lt;code&gt;DailySummary&lt;/code&gt;; Sundays roll into a &lt;code&gt;WeeklyMemory&lt;/code&gt;. LONG-press prompts splice that history into the Gemma call, so the model has a world model of &lt;em&gt;this specific apartment, this specific day&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;100% on-device.&lt;/strong&gt; No image, audio, embedding, or location leaves the phone. SQLCipher + Android KeyStore (AES-256-GCM wrapping a &lt;code&gt;SecureRandom&lt;/code&gt; DB key) protect everything at rest. A &lt;code&gt;SelfTest&lt;/code&gt; on first launch opens a probe DB with the wrong key and asserts the read fails before the app reports encryption healthy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Face recognition uses MediaPipe FaceLandmarker to produce a 192-dim L2-normalized landmark vector per enrolled person. At identify time, cosine-similar matches inject &lt;strong&gt;only the names&lt;/strong&gt; into the Gemma prompt — Gemma never sees a face crop or an embedding, code-review-verified.&lt;/p&gt;

&lt;p&gt;Three gestures, three target latencies (Pixel 8, Tensor G3): SINGLE ≤14 s end-to-end, DOUBLE scales with text length, LONG adds memory retrieval. Voice fillers ("I'm looking…", "Still looking…") cover the prefill gap so the user hears acoustic progress, not dead air. Everything runs with airplane mode on after the model is pushed once.&lt;/p&gt;

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

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/4ZlaVqXlAc4"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

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


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/hassaninnovate" rel="noopener noreferrer"&gt;
        hassaninnovate
      &lt;/a&gt; / &lt;a href="https://github.com/hassaninnovate/AccessLens" rel="noopener noreferrer"&gt;
        AccessLens
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A blind person's lanyard, powered by Gemma 4 E2B on a Pixel 8. 100% on-device visual assistant with persistent memory and face recognition.
    &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;AccessLens&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;An always-on, on-device visual interpreter for blind and low-vision users — built for the DEV.to "Build with Gemma 4" challenge.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/hassaninnovate/AccessLens/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/5b60841bea9e11d9d0b0950d690c9bc554e06385634056a7d5d62a15d1a4eabe/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4170616368655f322e302d626c75652e737667" alt="License: Apache 2.0"&gt;&lt;/a&gt;
&lt;a href="https://github.com/hassaninnovate/AccessLens#" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/89ea7be8eadb64987fcd6f39c332b8850d3f1b411750f72dcfc6b907ec73aeac/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f706c6174666f726d2d416e64726f696425323031332532422d677265656e2e737667" alt="Platform"&gt;&lt;/a&gt;
&lt;a href="https://github.com/hassaninnovate/AccessLens#" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/aad418e159887ad6a1a78f63f4092bfe5749f2d8ebcefa4e3187d02db161396f/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6d6f64656c2d47656d6d615f345f4532422d6f72616e67652e737667" alt="Model"&gt;&lt;/a&gt;
&lt;a href="https://github.com/hassaninnovate/AccessLens#privacy-invariants" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/0a8d8b5a8c05c0d263eae2efe64ce37bb841d10d59e9ec1d73cf64b8dce0ac4a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f696e666572656e63652d3130302532355f6f6e2d2d6465766963652d627269676874677265656e2e737667" alt="Privacy"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Pitch&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;A phone worn on a lanyard becomes the user's "eyes." The rear camera is always on; the gyroscope watches for motion. &lt;strong&gt;When the user stops walking, AccessLens describes what's in front of them.&lt;/strong&gt; When a friend whose face has been enrolled walks into frame, the phone says their name. When the user wants to read what's in front of them, they press &lt;em&gt;Volume Up&lt;/em&gt;; for a richer description of the room, &lt;em&gt;Volume Down&lt;/em&gt;. Bluetooth bone-conduction headphones carry the audio — the user's ears stay free for the world.&lt;/p&gt;
&lt;p&gt;What separates AccessLens from existing apps like Be My Eyes, Seeing AI, and Envision is &lt;strong&gt;persistent on-device memory + 100% on-device inference&lt;/strong&gt;. Existing tools are stateless and cloud-bound. AccessLens runs Gemma 4 E2B locally via LiteRT-LM, encrypts…&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/hassaninnovate/AccessLens" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Apache 2.0. The repo includes the full Kotlin/Compose source, the encryption self-test, the nightly compression WorkManager job, and a README documenting which file enforces each of the six privacy invariants.&lt;/p&gt;

&lt;p&gt;Reference implementation that taught me the LiteRT-LM API: &lt;a href="https://github.com/google-ai-edge/gallery" rel="noopener noreferrer"&gt;google-ai-edge/gallery&lt;/a&gt; — adapted patterns are cited inline in &lt;code&gt;inference/LiteRtLmRuntime.kt&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Used Gemma 4
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Model: Gemma 4 E2B&lt;/strong&gt; (&lt;code&gt;litert-community/gemma-4-E2B-it-litert-lm&lt;/code&gt;, ~2.59 GB int4), loaded once at service start via LiteRT-LM 0.12.0 with &lt;code&gt;Backend.GPU()&lt;/code&gt; for the vision adapter. Three reasons E2B was the right fit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Multimodal in one model, on-device.&lt;/strong&gt; Image input goes in as &lt;code&gt;Content.ImageBytes&lt;/code&gt;, text as &lt;code&gt;Content.Text&lt;/code&gt;, in that order (per the Gallery's "for accurate last token" comment), all through one &lt;code&gt;Engine.generate&lt;/code&gt; call. No separate vision encoder + decoder to stitch, no second model to keep resident. That fits the latency budget &lt;em&gt;and&lt;/em&gt; the memory budget on Pixel-class 8 GB RAM.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;E2B is the smallest competent multimodal Gemma 4.&lt;/strong&gt; It fits in RAM alongside MediaPipe FaceLandmarker, a CameraX pipeline, and the Compose UI without OOM-ing on a Pixel 8. I prototyped against E4B (the brief's "quality path") and measured the latency lift on one-sentence scene descriptions — not worth doubling the prefill cost for a use case where the user is waiting in real time, lanyard-mounted, with no screen feedback. The architecture is &lt;em&gt;parametric&lt;/em&gt; on the model path (&lt;code&gt;InferenceRuntime.load(modelPath, Modality)&lt;/code&gt;), so a future LONG-press branch could swap to E4B in one line. I documented the tradeoff in the README and shipped E2B for all three gestures.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Gemma is the only practical way to do nightly memory compression on-device.&lt;/strong&gt; The 03:00 &lt;code&gt;CompressionWorker&lt;/code&gt; calls Gemma in JSON mode to compress the day's &lt;code&gt;SessionEvent&lt;/code&gt; rows into a single &lt;code&gt;DailySummary&lt;/code&gt;, and on Sundays into a &lt;code&gt;WeeklyMemory&lt;/code&gt;. That's a real LLM task — extracting persistent facts, deduplicating recurring observations, distinguishing "the blue mug is mine" from "I saw a blue mug today" — and it has to happen without a network. E2B handles it in under a minute per day on Tensor G3 while the phone is on the charger.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Two production fixes the brief didn't cover, in case they help someone else:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The LiteRT-LM Android artifact must be &lt;strong&gt;0.12.0 or later&lt;/strong&gt; — 0.11.0 fails vision init inside &lt;code&gt;vision_litert_compiled_model_executor.cc:273&lt;/code&gt; on Tensor G3.&lt;/li&gt;
&lt;li&gt;AndroidManifest needs &lt;code&gt;&amp;lt;uses-native-library&amp;gt;&lt;/code&gt; declarations for &lt;code&gt;libOpenCL.so&lt;/code&gt;, &lt;code&gt;libOpenCL-car.so&lt;/code&gt;, &lt;code&gt;libOpenCL-pixel.so&lt;/code&gt; (all &lt;code&gt;android:required="false"&lt;/code&gt;). Without them, Android 12+ silently denies GPU OpenCL access and the vision backend fails to initialize. Documented at &lt;a href="https://ai.google.dev/edge/litert-lm/android" rel="noopener noreferrer"&gt;ai.google.dev/edge/litert-lm/android&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The thing I'm proudest of:&lt;/strong&gt; when you uninstall AccessLens, the KeyStore wrapping key is destroyed with it. The encrypted DB on disk becomes cryptographically unrecoverable. The user can throw the phone away and their memories — kitchen layout, friends' faces, places they've been — go with it. That's what on-device privacy is supposed to mean, and Gemma 4 + LiteRT-LM made it possible without compromising the assistant on quality.&lt;/p&gt;

</description>
      <category>gemmachallenge</category>
      <category>gemma</category>
      <category>android</category>
      <category>kotlin</category>
    </item>
  </channel>
</rss>
