<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Atishay Kasliwal — Writing</title>
    <link>https://atishaykasliwal.com/blog</link>
    <description>Notes on building AI systems that survive contact with production.</description>
    <language>en-us</language>
    <lastBuildDate>Tue, 11 Aug 2026 00:00:00 GMT</lastBuildDate>
    <atom:link href="https://atishaykasliwal.com/rss.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Atriveo: build capture before analytics</title>
      <link>https://atishaykasliwal.com/blog/atriveo-capture-before-analytics</link>
      <guid isPermaLink="true">https://atishaykasliwal.com/blog/atriveo-capture-before-analytics</guid>
      <pubDate>Tue, 11 Aug 2026 00:00:00 GMT</pubDate>
      <description>Atriveo started from a simple premise: if a job tracker depends on manual entry, it fails. This is how I designed passive capture, per-board heuristics, and the analytics layer on top.</description>
      <author>hire@atishaykasliwal.com (Atishay Kasliwal)</author>
      <category>Product</category>
      <category>Chrome Extension</category>
      <category>Systems Design</category>
      <category>Analytics</category>
      <content:encoded><![CDATA[<p>The first thing I learned building Atriveo was that job-search analytics are not the hard part. Capture is.</p>
<p>Almost every tracker in this category starts from the same assumption: the user will log each application manually, keep the spreadsheet tidy, and update statuses as replies come in. That assumption collapses fast. The people who need a tracker most are applying under time pressure, juggling portals, and already context-switching across dozens of roles. The system falls apart exactly when the user needs it most.</p>
<p>Atriveo exists because I wanted the tracking step to disappear.</p>
<h2 id="the-real-problem-was-adherence" class="post-h2"><a class="heading-anchor" href="#the-real-problem-was-adherence" aria-label="Link to The real problem was adherence">#</a>The real problem was adherence</h2>
<p>The surface problem looks like organization: people want a better way to see applications, interviews, rejections, and response rates. The real problem underneath it is adherence. If capture is manual, the data goes stale in a week. Once the data is stale, every chart on top of it becomes decorative.</p>
<p>That drove the entire product direction. I was less interested in making a prettier dashboard than in making sure the dashboard had a chance to stay true.</p>
<h2 id="why-the-browser-extension-came-first" class="post-h2"><a class="heading-anchor" href="#why-the-browser-extension-came-first" aria-label="Link to Why the browser extension came first">#</a>Why the browser extension came first</h2>
<p>The key move was pushing collection to the point where the action actually happens: the job board itself.</p>
<p>Atriveo&#39;s Chrome extension watches for submission events in the page, extracts the structured payload, and sends it to the backend without asking the user to do anything else. The point was not novelty for its own sake. It was to turn tracking into a byproduct of applying.</p>
<p>That decision made the rest of the system possible:</p>
<ul>
<li>analytics could assume fresh data instead of reminding users to update it</li>
<li>the backend could deduplicate and normalize centrally instead of trusting local notes</li>
<li>the product could compete on signal quality rather than on how disciplined the user felt that week</li>
</ul>
<p>It was also the part that created the most product leverage. A dashboard can be copied. Reliable passive capture is much harder.</p>
<h2 id="generic-parsing-was-the-wrong-abstraction" class="post-h2"><a class="heading-anchor" href="#generic-parsing-was-the-wrong-abstraction" aria-label="Link to Generic parsing was the wrong abstraction">#</a>Generic parsing was the wrong abstraction</h2>
<p>One lesson from the early builds was that a &quot;universal&quot; parser sounds elegant and performs badly.</p>
<p>Job boards look similar at a glance and wildly different in the DOM. The long tail is especially messy: custom components, inconsistent field names, and submission flows that change without warning. A one-size-fits-all parser degraded exactly where users were counting on it.</p>
<p>I moved to explicit per-board heuristics for the highest-traffic sites instead. That raised maintenance cost, but the trade was worth it. Precision matters more than architectural neatness when a false positive means someone&#39;s pipeline history is wrong.</p>
<p>That same trade showed up elsewhere too. I kept as much logic as possible server-side because a fix in the API can ship immediately, while a fix in the extension waits on Chrome Web Store review and then waits again for users to update.</p>
<h2 id="the-backend-exists-to-make-the-analytics-honest" class="post-h2"><a class="heading-anchor" href="#the-backend-exists-to-make-the-analytics-honest" aria-label="Link to The backend exists to make the analytics honest">#</a>The backend exists to make the analytics honest</h2>
<p>Once passive capture worked, the rest of the product could focus on what the data meant.</p>
<p>The API validates incoming events, deduplicates repeated applications, and persists them into a normalized PostgreSQL schema. That structure mattered because the questions users actually ask are relational:</p>
<ul>
<li>which sources convert best</li>
<li>which companies respond fastest</li>
<li>which stages stall</li>
<li>whether application volume is turning into interviews at all</li>
</ul>
<p>Those queries become simple once companies, roles, sources, and stages are modeled cleanly. They become painful if everything is stored as free-form documents and fixed later in application code.</p>
<p>The result is a system that now serves 100+ active customers, handles 2K+ daily queries, and stays up at 99.9% uptime. Those numbers matter, but they are downstream of the more important decision: make the input automatic.</p>
<h2 id="what-i-would-keep-and-what-i-would-change" class="post-h2"><a class="heading-anchor" href="#what-i-would-keep-and-what-i-would-change" aria-label="Link to What I would keep and what I would change">#</a>What I would keep and what I would change</h2>
<p>I would absolutely keep the principle that capture comes before analytics. It was the right product call and the right technical one.</p>
<p>I would also keep the discipline of responding closely to users early. The 5.0 rating on the Chrome Web Store did not come from feature count. It came from closing feedback loops quickly and treating small product friction as serious.</p>
<p>What I would change is the tooling around detector health. The most dangerous failures in Atriveo are silent ones: a board changes its DOM, capture rate drops, and the user assumes nothing is wrong. Health checks around board-specific detection should have existed earlier and should probably be even more aggressive now.</p>
<p>The main thing Atriveo taught me is simple: in products like this, the moat is not the chart. The moat is the boring, reliable system that makes the chart worth believing.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Legal RAG: citations before answers</title>
      <link>https://atishaykasliwal.com/blog/legal-rag-citations-before-answers</link>
      <guid isPermaLink="true">https://atishaykasliwal.com/blog/legal-rag-citations-before-answers</guid>
      <pubDate>Sun, 09 Aug 2026 00:00:00 GMT</pubDate>
      <description>In legal retrieval, fluent text is not the product. The product is traceability. This is why I inverted the usual RAG contract and made retrieval quality the gate.</description>
      <author>hire@atishaykasliwal.com (Atishay Kasliwal)</author>
      <category>RAG</category>
      <category>LLMs</category>
      <category>Legal Tech</category>
      <category>AI Systems</category>
      <content:encoded><![CDATA[<p>Most RAG demos optimize for the wrong applause line.</p>
<p>They answer quickly, sound fluent, and make the model feel confident. In legal work, that is not enough. A legal answer without a source somebody can inspect is not useful just because it reads well. It is often worse than no answer, because it invites trust without giving the reader any way to verify the claim.</p>
<p>That was the starting constraint for this project: the answer is not the product unless the citation holds up.</p>
<h2 id="why-i-inverted-the-usual-rag-contract" class="post-h2"><a class="heading-anchor" href="#why-i-inverted-the-usual-rag-contract" aria-label="Link to Why I inverted the usual RAG contract">#</a>Why I inverted the usual RAG contract</h2>
<p>The default RAG pattern is straightforward: retrieve some chunks, hand them to the model, and ask for a summary. The failure mode is straightforward too. If retrieval is weak, the model smooths over the gaps and produces something that sounds coherent anyway.</p>
<p>That is tolerable in some domains. It is not tolerable here.</p>
<p>So I flipped the contract. Instead of &quot;answer if you can,&quot; the system behaves more like &quot;retrieve first, answer only from what retrieval can support, and refuse when the evidence is weak.&quot; That is a less magical user experience on the surface, but it is a much better trust contract.</p>
<p>The goal was never to make the model feel smart. The goal was to make the system auditable.</p>
<h2 id="structural-chunking-mattered-more-than-model-choice" class="post-h2"><a class="heading-anchor" href="#structural-chunking-mattered-more-than-model-choice" aria-label="Link to Structural chunking mattered more than model choice">#</a>Structural chunking mattered more than model choice</h2>
<p>One of the earliest design choices was to chunk legal documents on structural boundaries instead of fixed token windows.</p>
<p>Fixed windows are easy to implement and usually wrong for this kind of material. Clauses get split in the middle, headings detach from the text they govern, and the citation lands on a fragment that cannot stand on its own. The retrieval score may look acceptable while the evidence is still unusable.</p>
<p>Chunking by sections, clauses, and other document boundaries costs more preprocessing work, but it pays back in the only place that matters: when a reader opens the cited span, it reads like a real unit of meaning instead of a clipped excerpt.</p>
<p>That single choice improved answer quality more than swapping models did.</p>
<h2 id="retrieval-quality-is-the-whole-product" class="post-h2"><a class="heading-anchor" href="#retrieval-quality-is-the-whole-product" aria-label="Link to Retrieval quality is the whole product">#</a>Retrieval quality is the whole product</h2>
<p>This project reinforced something I keep running into with LLM systems: many &quot;generation&quot; problems are retrieval problems in disguise.</p>
<p>If the wrong chunk comes back, the answer is doomed before the prompt even starts. If the right chunks come back but in the wrong order, the answer drifts. If two relevant passages disagree and the system hides the disagreement, the model looks smoother while the product gets less trustworthy.</p>
<p>So most of the real work ended up in the layers before generation:</p>
<ul>
<li>better chunk boundaries</li>
<li>ranking that respected document structure</li>
<li>metadata filters for narrowing by document and jurisdiction</li>
<li>a response contract that forbids unsupported claims</li>
</ul>
<p>Once those pieces were right, the model had a fair chance to behave. Before that, it was just papering over retrieval mistakes.</p>
<h2 id="refusal-is-a-feature-not-a-fallback" class="post-h2"><a class="heading-anchor" href="#refusal-is-a-feature-not-a-fallback" aria-label="Link to Refusal is a feature, not a fallback">#</a>Refusal is a feature, not a fallback</h2>
<p>One decision I feel strongly about is refusal under weak retrieval.</p>
<p>People sometimes see refusal as a sign that the system is failing. In legal contexts it is often the system behaving correctly. If the evidence is thin, approximate confidence is the dangerous outcome. A refusal that says, in effect, &quot;I do not have enough support for this answer&quot; is much closer to what a careful human would do.</p>
<p>That makes the product less theatrical and more dependable.</p>
<p>It also changes how the interface should feel. A good legal assistant is not trying to hide uncertainty. It is trying to surface the boundary between what the source supports and what the model is tempted to infer.</p>
<h2 id="what-this-project-changed-in-how-i-think-about-rag" class="post-h2"><a class="heading-anchor" href="#what-this-project-changed-in-how-i-think-about-rag" aria-label="Link to What this project changed in how I think about RAG">#</a>What this project changed in how I think about RAG</h2>
<p>The main shift for me was seeing retrieval not as plumbing around the model, but as the system&#39;s actual core logic.</p>
<p>Improving chunking, ranking, and refusal behavior moved output quality far more than changing the model did. The answer contract only works if the retrieval layer deserves that power.</p>
<p>If I extended this system further, I would keep pushing in the same direction: stronger handling of contradictory passages, better provenance on every claim, and more explicit UI around what came from source text versus what came from synthesis.</p>
<p>That is the version of RAG I trust most now. Not the version that tries hardest to answer, but the one that makes it hardest to answer casually.</p>
]]></content:encoded>
    </item>
    <item>
      <title>MRI Tumor Viewer: ship the model to the browser</title>
      <link>https://atishaykasliwal.com/blog/mri-viewer-model-in-the-browser</link>
      <guid isPermaLink="true">https://atishaykasliwal.com/blog/mri-viewer-model-in-the-browser</guid>
      <pubDate>Fri, 07 Aug 2026 00:00:00 GMT</pubDate>
      <description>The biggest gain in this viewer did not come from another point of accuracy. It came from moving inference to the reviewer’s machine and removing the upload step.</description>
      <author>hire@atishaykasliwal.com (Atishay Kasliwal)</author>
      <category>Machine Learning</category>
      <category>Healthcare</category>
      <category>TensorFlow.js</category>
      <category>Privacy</category>
      <content:encoded><![CDATA[<p>The most important choice in this project was not the model architecture. It was where the model ran.</p>
<p>In medical imaging, people tend to focus first on raw accuracy. That matters, of course. But in practice a workflow can still be slow, awkward, and hard to trust even with a strong model if it adds friction in the wrong place. What delayed value here was not only classification quality. It was time-to-first-read.</p>
<p>That pushed me toward a less common deployment target: the browser.</p>
<h2 id="the-queue-matters-more-than-the-headline-metric" class="post-h2"><a class="heading-anchor" href="#the-queue-matters-more-than-the-headline-metric" aria-label="Link to The queue matters more than the headline metric">#</a>The queue matters more than the headline metric</h2>
<p>Reviewing scans is a throughput problem as much as an ML problem.</p>
<p>If clinicians wait on uploads, remote inference, or tool-switching before they can even inspect a result, the model may be technically accurate and operationally unhelpful. A smaller improvement that fits the workflow cleanly can matter more than another benchmark win that arrives late.</p>
<p>That is why I treated the viewer as part of the model, not as a shell around it. The product needed to shorten the path from opening a scan to seeing something useful.</p>
<h2 id="why-the-browser-was-the-right-deployment-target" class="post-h2"><a class="heading-anchor" href="#why-the-browser-was-the-right-deployment-target" aria-label="Link to Why the browser was the right deployment target">#</a>Why the browser was the right deployment target</h2>
<p>Running inference client-side solved two problems at once.</p>
<p>First, it reduced privacy exposure. MRI data is not the kind of thing you casually move around just because a hosted endpoint is easier to maintain. Keeping pixels on the reviewer&#39;s machine removes an entire category of risk and compliance overhead.</p>
<p>Second, it cut latency in the workflow. There is no upload wait and no server round-trip before the reviewer can start interacting with the scan. That matters when the alternative is sending large volumes to a service first and only then rendering the result.</p>
<p>The core path became:</p>
<ul>
<li>train offline on GCP</li>
<li>export and quantize for TensorFlow.js</li>
<li>run inference in the browser</li>
<li>render slice-by-slice overlays the reviewer can inspect immediately</li>
</ul>
<p>That design choice is what turned the system into a practical tool instead of only a research artifact.</p>
<h2 id="quantization-was-a-product-decision" class="post-h2"><a class="heading-anchor" href="#quantization-was-a-product-decision" aria-label="Link to Quantization was a product decision">#</a>Quantization was a product decision</h2>
<p>Getting the model into the browser was not a mechanical export step. It was a trade study.</p>
<p>A model that is too large to load or too slow to run interactively is functionally unavailable, even if it is more accurate on paper. Quantization was therefore not just an optimization task. It was part of product design: decide how much model complexity the real workflow can afford.</p>
<p>This is where a lot of ML projects quietly fall apart. They treat deployment as a packaging problem after the &quot;real work&quot; is done. In reality, the deployment target changes what counts as a good model.</p>
<p>For this viewer, a slightly leaner model that loads reliably and responds quickly is often the better clinical experience than a heavier model that wins the metric table but loses the room.</p>
<h2 id="accuracy-needed-the-right-framing" class="post-h2"><a class="heading-anchor" href="#accuracy-needed-the-right-framing" aria-label="Link to Accuracy needed the right framing">#</a>Accuracy needed the right framing</h2>
<p>The reported classification accuracy is useful, but it is not enough on its own.</p>
<p>Tumor voxels are a small fraction of any given volume, which means naive aggregate accuracy can flatter a model that mostly predicts background correctly. That is why the evaluation had to care about recall and segmentation behavior, not just a broad percentage.</p>
<p>The viewer design also reflects that mindset. The segmentation appears as an overlay, not a replacement for the original scan. The scan stays authoritative. The model assists interpretation; it does not pretend to become the image.</p>
<p>That is a small interface detail with a big implication: the system is built to support clinical judgment, not to obscure it.</p>
<h2 id="what-this-project-changed-for-me" class="post-h2"><a class="heading-anchor" href="#what-this-project-changed-for-me" aria-label="Link to What this project changed for me">#</a>What this project changed for me</h2>
<p>This was one of the clearest examples I have seen of workflow placement beating raw model glamour.</p>
<p>The throughput gain came from meeting the clinician where the work actually happens. Reducing the path from scan to first useful visual mattered more than squeezing for an extra talking point in evaluation.</p>
<p>If I pushed this further, I would keep working on browser-side performance and richer interaction around overlays, but I would protect the same core principle: the most valuable model is the one that arrives early enough, privately enough, and clearly enough to fit the real workflow.</p>
<p>That is the version that turns ML from a result into a tool.</p>
]]></content:encoded>
    </item>
    <item>
      <title>PolicyFabric: make the contract graph visible</title>
      <link>https://atishaykasliwal.com/blog/policy-fabric-contract-graph-visible</link>
      <guid isPermaLink="true">https://atishaykasliwal.com/blog/policy-fabric-contract-graph-visible</guid>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
      <description>Data contracts usually fail far from the change that caused them. PolicyFabric came out of wanting the contract graph itself to be the product, not a hidden implementation detail.</description>
      <author>hire@atishaykasliwal.com (Atishay Kasliwal)</author>
      <category>Developer Tools</category>
      <category>Data Engineering</category>
      <category>Visualization</category>
      <category>TypeScript</category>
      <content:encoded><![CDATA[<p>Most data contract failures are obvious only after they are expensive.</p>
<p>A producer changes a field. Nothing breaks where the change happened. Hours later a downstream consumer fails in a different service, with a message that points nowhere near the real cause. By the time somebody traces it back, the team has already spent time debugging the wrong layer.</p>
<p>PolicyFabric started from wanting that failure mode to be visible earlier and in the right shape.</p>
<h2 id="contract-failures-are-topology-problems" class="post-h2"><a class="heading-anchor" href="#contract-failures-are-topology-problems" aria-label="Link to Contract failures are topology problems">#</a>Contract failures are topology problems</h2>
<p>The usual tooling around schema changes tends to flatten the problem into logs, reports, or lists of violations. That is useful up to a point, but it loses the most important piece of context: who depends on whom.</p>
<p>Contract failures are inherently topological. The core question is never just &quot;is this field change breaking.&quot; The real question is &quot;which downstream edges does this break, and how far does the blast radius travel?&quot;</p>
<p>Once I framed the problem that way, the product direction became clearer. The graph itself had to stop being a hidden implementation detail. It had to become the surface.</p>
<h2 id="why-i-chose-a-graph-over-a-report" class="post-h2"><a class="heading-anchor" href="#why-i-chose-a-graph-over-a-report" aria-label="Link to Why I chose a graph over a report">#</a>Why I chose a graph over a report</h2>
<p>PolicyFabric parses services, schemas, and producer-consumer relationships into a directed graph, then evaluates changes against that graph before they ship.</p>
<p>That sounds like an implementation choice, but it was really a UI choice too. I did not want the output to be a block of text somebody needed to interpret after the fact. I wanted the system to answer the downstream question visually:</p>
<ul>
<li>what changed</li>
<li>what depends on it</li>
<li>where the violation sits</li>
<li>which path through the system is affected</li>
</ul>
<p>The visual layer is the difference between &quot;something somewhere might break&quot; and &quot;this edge here is the problem.&quot;</p>
<h2 id="the-full-graph-is-often-the-wrong-view" class="post-h2"><a class="heading-anchor" href="#the-full-graph-is-often-the-wrong-view" aria-label="Link to The full graph is often the wrong view">#</a>The full graph is often the wrong view</h2>
<p>One challenge showed up quickly: graph interfaces become unreadable faster than people expect.</p>
<p>Rendering every service and every contract at once sounds comprehensive and often produces a diagram nobody can parse. Past a certain size, the graph stops explaining the system and starts advertising its complexity.</p>
<p>That is why the more useful view is usually the affected subgraph, not the whole topology. When a change is evaluated, the interesting question is the local consequence set. Collapse everything else and let the reader focus on the edges that matter right now.</p>
<p>That sounds like a visual convenience. It is actually a correctness feature. A readable warning is more enforceable than a perfect but overwhelming one.</p>
<h2 id="enforcement-only-works-when-it-is-cheap-to-obey" class="post-h2"><a class="heading-anchor" href="#enforcement-only-works-when-it-is-cheap-to-obey" aria-label="Link to Enforcement only works when it is cheap to obey">#</a>Enforcement only works when it is cheap to obey</h2>
<p>I keep coming back to this idea in developer tools: enforcement is only effective when the cost of compliance stays low.</p>
<p>If the system catches a breaking contract only after deployment, it is already too late. If it catches the issue earlier but explains it poorly, people work around it instead of trusting it. If it explains the problem clearly at change time, the right fix is usually the easiest fix.</p>
<p>That is the real value PolicyFabric aims for. Not just detecting breakage, but detecting it early enough and clearly enough that teams can act before the incident exists.</p>
<h2 id="what-i-would-push-next" class="post-h2"><a class="heading-anchor" href="#what-i-would-push-next" aria-label="Link to What I would push next">#</a>What I would push next</h2>
<p>If I kept extending this system, I would invest in richer rule authoring and deeper change previews: not just &quot;this breaks,&quot; but &quot;this breaks because this consumer expects this field contract, and here is the exact edge path.&quot;</p>
<p>I would also keep protecting the same product instinct that shaped the first version: make the architecture legible. Most systems already contain the information you need to reason about contract risk. The problem is that the information is scattered across definitions and repositories and logs.</p>
<p>PolicyFabric is my attempt to pull that structure into one place and make it visible enough to act on.</p>
<p>When a tool like this works, the best outcome is boring. The breaking change never ships, the incident never starts, and nobody has to figure out at 2 a.m. which service quietly depended on the field you just renamed.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Your backtest is lying to you</title>
      <link>https://atishaykasliwal.com/blog/point-in-time-correctness</link>
      <guid isPermaLink="true">https://atishaykasliwal.com/blog/point-in-time-correctness</guid>
      <pubDate>Tue, 14 Jul 2026 00:00:00 GMT</pubDate>
      <description>Point-in-time correctness is the difference between a financial ML pipeline that works and one that only appears to. Here is where the leaks hide and how to design them out.</description>
      <author>hire@atishaykasliwal.com (Atishay Kasliwal)</author>
      <category>Machine Learning</category>
      <category>Data Engineering</category>
      <category>NLP</category>
      <category>Finance</category>
      <content:encoded><![CDATA[<p>The first version of my FOMC pipeline returned 27% in backtest. I spent an afternoon feeling clever, and then most of a week finding the leak.</p>
<p>There was no bug in the model. There was a bug in <em>time</em>.</p>
<h2 id="the-shape-of-the-problem" class="post-h2"><a class="heading-anchor" href="#the-shape-of-the-problem" aria-label="Link to The shape of the problem">#</a>The shape of the problem</h2>
<p>The pipeline ingests Federal Reserve communications — statements, minutes, speeches — extracts a stance signal from each, and aligns that signal against market data to see whether the language moves anything. Standard applied NLP, structurally.</p>
<p>The trouble is that every one of those inputs has two timestamps, and it is very easy to use the wrong one:</p>
<ul>
<li><strong>Event time</strong> — when the thing happened. A meeting concluded on March 19.</li>
<li><strong>Publication time</strong> — when you could actually have known about it. Minutes from that meeting are released three weeks later.</li>
</ul>
<p>Join on event time and you have built a machine that trades on documents from the future. It will look extraordinary.</p>
<h2 id="where-the-leaks-actually-hide" class="post-h2"><a class="heading-anchor" href="#where-the-leaks-actually-hide" aria-label="Link to Where the leaks actually hide">#</a>Where the leaks actually hide</h2>
<p>Look-ahead bias is not one bug you fix once. It is a category, and it reappears anywhere the pipeline touches time.</p>
<p><strong>Revised data.</strong> Macroeconomic series get restated after initial publication. Pull GDP for Q1 2024 today and you get the revised figure, not the one that was on the screen in Q1 2024. Your model trains on numbers nobody had.</p>
<p><strong>Survivorship in the document set.</strong> Scraping the Fed&#39;s current site gives you documents that still exist. Anything moved, superseded, or quietly re-filed is gone. The archive you get is the one that survived, which is not the one a contemporaneous reader saw.</p>
<p><strong>Normalization across the whole set.</strong> This is the subtle one. Fitting a scaler, computing a mean, or building a vocabulary across the <em>entire</em> corpus bakes global statistics into every individual sample. Standardizing sentiment scores against the full-period mean tells each training row something about the distribution of a period that had not happened yet. No single row looks wrong. The aggregate is poisoned.</p>
<p><strong>Model artifacts.</strong> Embeddings from a model trained on text through 2025, applied to documents from 2021, means the representation itself encodes four years of subsequent language. The leak is inside the weights.</p>
<h2 id="the-fix-is-architectural-not-analytical" class="post-h2"><a class="heading-anchor" href="#the-fix-is-architectural-not-analytical" aria-label="Link to The fix is architectural, not analytical">#</a>The fix is architectural, not analytical</h2>
<p>You cannot test your way out of this reliably, because a leak produces results that look <em>better</em>, and nobody debugs a good number. The constraint has to be structural — the pipeline should make the wrong join awkward to express.</p>
<p>Three things did most of the work:</p>
<p><strong>1. Every record carries <code>available_at</code>.</strong></p>
<p>Not <code>date</code>. Not <code>timestamp</code>. An explicit field meaning: the earliest wall-clock moment this row could have been known. Ingestion computes it once, at the boundary, and it is never derived later.</p>
<figure class="code-block" data-lang="python" data-code="QGRhdGFjbGFzcyhmcm96ZW49VHJ1ZSkKY2xhc3MgRG9jdW1lbnQ6CiAgICBjb250ZW50OiBzdHIKICAgIGV2ZW50X3RpbWU6IGRhdGV0aW1lICAgICAgIyB3aGVuIGl0IGhhcHBlbmVkCiAgICBhdmFpbGFibGVfYXQ6IGRhdGV0aW1lICAgICMgd2hlbiBpdCB3YXMga25vd2FibGUg4oCUIHRoZSBvbmx5IG9uZSBqb2lucyBtYXkgdXNlCiAgICBzb3VyY2U6IHN0cgogICAgcmV2aXNpb246IGludCA9IDA="><pre class="shiki shiki-themes github-light github-dark-dimmed" style="--shiki-light:#24292e;--shiki-dark:#adbac7;--shiki-light-bg:#fff;--shiki-dark-bg:#22272e" tabindex="0"><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#DCBDFB">@dataclass</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">(</span><span style="--shiki-light:#E36209;--shiki-dark:#F69D50">frozen</span><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF">True</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">class</span><span style="--shiki-light:#6F42C1;--shiki-dark:#F69D50"> Document</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">:</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">    content: </span><span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF">str</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">    event_time: datetime      </span><span style="--shiki-light:#6A737D;--shiki-dark:#768390"># when it happened</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">    available_at: datetime    </span><span style="--shiki-light:#6A737D;--shiki-dark:#768390"># when it was knowable — the only one joins may use</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">    source: </span><span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF">str</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">    revision: </span><span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF">int</span><span style="--shiki-light:#D73A49;--shiki-dark:#F47067"> =</span><span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF"> 0</span></span></code></pre></figure>
<p>Making it <code>frozen</code> matters more than it looks. A mutable timestamp is a timestamp somebody will quietly overwrite three transformations downstream.</p>
<p><strong>2. Feature lookups take an <code>as_of</code> argument, and it is required.</strong></p>
<figure class="code-block" data-lang="python" data-code="ZGVmIGZlYXR1cmVzX2FzX29mKHNlbGYsIGFzX29mOiBkYXRldGltZSkgLT4gcGQuRGF0YUZyYW1lOgogICAgIiIiRXZlcnkgZmVhdHVyZSBrbm93YWJsZSBhdCBgYXNfb2ZgLiBOb3RoaW5nIGVsc2UsIGJ5IGNvbnN0cnVjdGlvbi4iIiIKICAgIHZpc2libGUgPSBzZWxmLl9zdG9yZVtzZWxmLl9zdG9yZS5hdmFpbGFibGVfYXQgPD0gYXNfb2ZdCiAgICByZXR1cm4gKAogICAgICAgIHZpc2libGUKICAgICAgICAuc29ydF92YWx1ZXMoImF2YWlsYWJsZV9hdCIpCiAgICAgICAgLmdyb3VwYnkoImZlYXR1cmVfa2V5IikKICAgICAgICAubGFzdCgpCiAgICAp"><pre class="shiki shiki-themes github-light github-dark-dimmed" style="--shiki-light:#24292e;--shiki-dark:#adbac7;--shiki-light-bg:#fff;--shiki-dark-bg:#22272e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">def</span><span style="--shiki-light:#6F42C1;--shiki-dark:#DCBDFB"> features_as_of</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">(self, as_of: datetime) -> pd.DataFrame:</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">    """Every feature knowable at `as_of`. Nothing else, by construction."""</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">    visible </span><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF"> self</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">._store[</span><span style="--shiki-light:#005CC5;--shiki-dark:#6CB6FF">self</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">._store.available_at </span><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">&#x3C;=</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7"> as_of]</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F47067">    return</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7"> (</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">        visible</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">        .sort_values(</span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">"available_at"</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">        .groupby(</span><span style="--shiki-light:#032F62;--shiki-dark:#96D0FF">"feature_key"</span><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">        .last()</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#ADBAC7">    )</span></span></code></pre></figure>
<p>No default value. A caller who has not thought about what time it is cannot get data out. That single design choice caught more mistakes than any test I wrote.</p>
<p><strong>3. Append-only storage with revisions.</strong></p>
<p>Restatements insert a new row with a later <code>available_at</code> rather than updating the old one. The original stays queryable, because reconstructing what was known on a given date is the entire job. Updating in place destroys exactly the history you need.</p>
<h2 id="what-happened-to-the-27" class="post-h2"><a class="heading-anchor" href="#what-happened-to-the-27" aria-label="Link to What happened to the 27%">#</a>What happened to the 27%</h2>
<p>It got much smaller, and it became real.</p>
<p>The signal that survived was narrower than the one I started with: not &quot;the Fed sounded hawkish&quot; but &quot;the Fed sounded <em>more</em> hawkish than in its previous statement, and the change was concentrated in the forward-guidance paragraph.&quot; Absolute sentiment on central bank prose is almost constant — it is engineered to be. Nearly all the information is in the delta.</p>
<p>That is a less exciting finding. It is also one I believe, which the first number was not.</p>
<h2 id="what-i-would-tell-myself-at-the-start" class="post-h2"><a class="heading-anchor" href="#what-i-would-tell-myself-at-the-start" aria-label="Link to What I would tell myself at the start">#</a>What I would tell myself at the start</h2>
<p>Treat any backtest result that surprises you as a bug report against your data pipeline, not as a discovery. The prior should be overwhelming: you have made a mistake with time. Financial data is adversarial toward the careless in a way that most ML data is not, because the errors flatter you.</p>
<p>The modeling was maybe a fifth of this project. Correct time handling was the rest, and it was where all the risk lived.</p>
<hr>
<p><em>The FOMC Intelligence pipeline is research work at Stony Brook University. Reported figures describe a simulation, not a trading record.</em></p>
]]></content:encoded>
    </item>
  </channel>
</rss>
