<?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: Matías Hernández</title>
    <description>The latest articles on DEV Community by Matías Hernández (@matiashz).</description>
    <link>https://dev.to/matiashz</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%2F3908274%2F707d4a2a-d7ef-418c-8058-9311dde5e065.jpg</url>
      <title>DEV Community: Matías Hernández</title>
      <link>https://dev.to/matiashz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/matiashz"/>
    <language>en</language>
    <item>
      <title>Two identical runs, two different conversations</title>
      <dc:creator>Matías Hernández</dc:creator>
      <pubDate>Thu, 06 Aug 2026 15:39:05 +0000</pubDate>
      <link>https://dev.to/matiashz/two-identical-runs-two-different-conversations-ikn</link>
      <guid>https://dev.to/matiashz/two-identical-runs-two-different-conversations-ikn</guid>
      <description>&lt;p&gt;&lt;em&gt;What "reproducible" means when the system you're measuring has memory.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Turn 3 of a scripted conversation. The question was "What's my brother's name?".&lt;/p&gt;

&lt;p&gt;Run A answered "His name is Nicolás." Run B answered "Nicolás."&lt;/p&gt;

&lt;p&gt;Same fact, same configuration, same pinned clock, same seed, temperature zero. Two processes that were supposed to be identical, and from that turn on they weren't, because my system stores every response as a memory and that memory feeds the next turn. From turn 3 onward, the two conversations lived different lives.&lt;/p&gt;

&lt;p&gt;I had gotten there to verify something I thought was obvious, that my measuring instrument was reproducible. What I verified was something else: that the reproducibility I was looking for is physically impossible, and that &lt;strong&gt;defining what "the same" means was going to be the real work&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I was measuring
&lt;/h2&gt;

&lt;p&gt;I build a memory system for a conversational agent. It remembers what you tell it, consolidates it, and retrieves the relevant context on every turn. It runs on Node, against hosted models.&lt;/p&gt;

&lt;p&gt;After months of building I wanted to start measuring. There are questions you can't avoid once the system stops being a toy. How much does each turn cost as the conversation grows? Does the sophisticated memory ranking earn its place over plain cosine similarity? Those are experiment questions, and an experiment needs an instrument.&lt;/p&gt;

&lt;p&gt;That's where I set the first rule of the process: &lt;strong&gt;calibrate the instrument before measuring with it&lt;/strong&gt;. The instrument here isn't the system in the abstract, it's the harness — the simulator that runs a scripted conversation, the pinned clock that makes two runs comparable, and the traces that record what the system did on each turn. None of it had ever been put under measurement.&lt;/p&gt;

&lt;p&gt;Before running a single experiment, I audited the harness.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before measuring, the instrument was lying
&lt;/h2&gt;

&lt;p&gt;The central finding wasn't a suspicion, it was a query against the production trace database: &lt;strong&gt;49 of 208 chat turns, 23.6%, carried the exact signature of a code fallback.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The component that classifies user intent has a fallback path. If the model call fails, it returns "this is chit-chat, do nothing". As a defense that seems reasonable to me. What isn't reasonable is that it's silent: in nearly one out of every four turns, the system retrieved no memory, stored nothing, executed no action, and not because the user was making small talk but because a call had failed.&lt;/p&gt;

&lt;p&gt;"Silent" is the exact word. The trace recorded &lt;code&gt;confidence: 0&lt;/code&gt;, which is exactly what you see when the classifier actually runs and isn't sure. &lt;strong&gt;Nothing distinguished "the classifier hesitated" from "the classifier never ran".&lt;/strong&gt; I had been staring at those traces for months.&lt;/p&gt;

&lt;p&gt;I could only detect it statistically, from the same triple of fields repeating across messages of 4 characters and of 22,000, across greetings and long reflections. No real classifier lands on the exact same point a quarter of the time over inputs that heterogeneous. And of those 49 turns, &lt;strong&gt;22 never even completed a response&lt;/strong&gt;: the whole request fell over, and in the trace that looked normal too.&lt;/p&gt;

&lt;p&gt;My first hypothesis about the cause was elegant and it was wrong. I ran it against the real API and it refuted itself. The actual mechanism was different and considerably dumber: the classifier received recent context without budgeting it. There was a guardrail, the query pulls at most the last fifty messages, but &lt;strong&gt;a message cap is not a token cap&lt;/strong&gt;. And it isn't a threshold the conversation crosses once it gets long, it's a ceiling that's always in place: any conversation past fifty messages inside the 72-hour window hands the classifier exactly that maximum, unbudgeted. With long messages that overflows the model's window, the API returns a 400, and a &lt;code&gt;catch&lt;/code&gt; block turned it into "the user was just making small talk".&lt;/p&gt;

&lt;p&gt;How many of those 49 turns fell over for exactly that reason, I don't know and I never will, because the trace didn't record the fallback's cause. &lt;strong&gt;That field was part of the fix, not part of the diagnosis.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The second finding was in the harness itself. The simulator had eight assertions about memory consolidation, gated on a trace field that &lt;strong&gt;no code ever wrote&lt;/strong&gt;. The condition was never true, so all eight skipped silently and the simulator reported success with exit code 0. The instrument I was planning to measure with had eight checks that never checked anything.&lt;/p&gt;

&lt;p&gt;The audit found &lt;strong&gt;seven blocking defects across twenty-seven findings&lt;/strong&gt;. And the lesson I liked learning least is that &lt;strong&gt;observability that lies is worse than no observability&lt;/strong&gt;, because on top of everything it charges you confidence for the service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fixes, and the bet
&lt;/h2&gt;

&lt;p&gt;Seven fixes, all surgical, none of them re-architecture. The character of the list says a fair amount about what kind of problem this was: unify the clocks so a single timestamp governs the whole turn, label every fallback with its cause so it stops being indistinguishable from a genuine result, cost and latency accounting per call instead of only the last one, pinnable sampling parameters, honest exit codes.&lt;/p&gt;

&lt;p&gt;Then came the bet, which was the exit gate. Run the same scripted conversation twice with everything pinned — temperature zero, fixed seed, pinned clock, a clean database per run, against real models and real embeddings — and verify that the traces came out equivalent.&lt;/p&gt;

&lt;p&gt;I expected them to come out identical. I'm writing that down knowing how it sounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  "The same" doesn't exist
&lt;/h2&gt;

&lt;p&gt;Turn 3, "His name is Nicolás." against "Nicolás."&lt;/p&gt;

&lt;p&gt;The seed the provider offers is &lt;em&gt;best-effort&lt;/em&gt; by contract, it doesn't guarantee byte-for-byte identity even at temperature zero. I knew that, in fact it was written down as a footnote before I ran anything. What I hadn't internalized is what happens next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Divergence compounds&lt;/strong&gt;, in the compound-interest sense: each divergence becomes the base for the next one. The mechanic is three steps — the response is persisted as a memory, that memory enters the next turn's context, and the two processes stop receiving the same input. From turn 3 on, runs A and B were no longer looking at the same world. The divergence wasn't in wording but in &lt;strong&gt;conversational state&lt;/strong&gt;, and by that I mean something concrete: the set of memories each run was accumulating. A stored "His name is Nicolás." and B stored "Nicolás.". That difference reaches the next turn unmediated, because the recent-context block carries the last exchanges exactly as they were stored. And it comes back later through another door: once that memory falls out of the recent window, they are two texts with different embeddings, competing differently to get back into the prompt. From that point on the two runs didn't just say different things, they had different material to decide with.&lt;/p&gt;

&lt;p&gt;What I found interesting is how long the system held. The intent classifier absorbed about ten turns of textual drift without changing behavior, because the inputs were different but not different enough to cross any decision boundary. The first hard difference showed up only at turn 13, and it was barely a tremor in the classifier's confidence, 0.9 against 0.95, with the same intent in both runs. Behavior proper diverged only at turn 22, when one run decided to retrieve memory and the other didn't.&lt;/p&gt;

&lt;p&gt;From there it cascaded: different intents dispatch different actions, which write different memories, which change the next turn's context. I ended up with &lt;strong&gt;34 hard differences&lt;/strong&gt; by the end of the run — by hard difference I mean a difference in a field that should never vary, like which intent was classified or which actions were executed. They were spread across 12 turns, and several were the same event seen from different fields: a single routing flip showed up at once as intent, confidence, action count and executed actions.&lt;/p&gt;

&lt;p&gt;The comparator's formal verdict was &lt;code&gt;NOT EQUIVALENT&lt;/code&gt;, exit code 1.&lt;/p&gt;

&lt;p&gt;And here comes the number that matters most to me, which isn't the one I planned to use. The genuinely clean turns, with identical inputs in both runs, were &lt;strong&gt;two out of thirty-three&lt;/strong&gt;: drift starts far too early and leaves no margin. Saying "zero hard differences in clean turns" sounds forceful, but on a base of two turns it doesn't prove much.&lt;/p&gt;

&lt;p&gt;What does prove something is a different measurement. There were &lt;strong&gt;18 turns where the context assembled for each run was demonstrably different&lt;/strong&gt; — the prompt block lengths and the router's character budget differ, and that's measured — and every field that must not vary still agreed: intent, confidence, temporal scope, policy, action dispatch, model-call sequence. &lt;strong&gt;Eighteen real opportunities to fail, eighteen agreements.&lt;/strong&gt; That is what licenses the claim that the deterministic layer always does the same thing given the same input, and that where it stopped agreeing, it was because the input was already different. What doesn't license it is the absence of failures in two turns where nothing could have failed.&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%2F8ib9bo324q9cqalrbkdy.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%2F8ib9bo324q9cqalrbkdy.png" alt="Cycle diagram: the model answers with slightly different text, that response is persisted as a memory, the memory enters the next turn's context, and the input stops being identical, which feeds the cycle again. Below: turn 3 textual drift, turn 13 first hard difference, turn 22 behavioral divergence, and 18 turns with already-different inputs and full agreement in the deterministic layer." width="799" height="297"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Redefining "the same" without cheating
&lt;/h2&gt;

&lt;p&gt;Here's the part of the method that matters to me, because it's where you can cheat without anyone noticing. The easy way out was to lower the bar by feel and say "34 differences, sure, but they all come from a wording difference at turn 3, so they're cascade, so they're noise". The problem is that without a rule written in advance, that sentence doesn't distinguish a genuine cascade from a defect I find convenient to call a cascade. I don't think that qualifies as a criterion, it's a hunch in the shape of one.&lt;/p&gt;

&lt;p&gt;What I did, before looking at a single data point, was classify every trace field into three tiers.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Exact equality required&lt;/strong&gt;, for the deterministic spine: which intent was classified, which temporal policy applied, which actions were dispatched, which sequence of model calls occurred.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Difference tolerated, but only with attribution&lt;/strong&gt; to one of two noise mechanisms named in advance: the model's sampling variance, and embedding jitter (the same string embedded twice doesn't always return the same vector, and that's enough to flip two candidates sitting on a tie).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excluded by design&lt;/strong&gt;, the things that can never match: identifiers, latencies, durations.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The governing rule was &lt;strong&gt;zero unattributed differences&lt;/strong&gt;. A global tolerance, the "it passes if 95% of fields agree" kind, lets a real defect hide behind the noise budget. Attribution doesn't allow that, because it demands every difference belong to a mechanism I already understand.&lt;/p&gt;

&lt;p&gt;I also added a cascade rule. From the turn where textual drift starts, in this case turn 3, any hard difference appearing afterwards gets flagged separately, because a benign explanation is already available: the inputs stopped being equal. It still counts, but it's kept apart from a difference in a turn whose inputs really were identical, which is the only place where a divergence has no possible explanation outside the system itself.&lt;/p&gt;

&lt;p&gt;Now the part that was hard to write.&lt;/p&gt;

&lt;p&gt;The classification was pre-registered and it worked as designed. All 34 hard differences landed after turn 3, so the cascade rule applied to every one of them; every tolerated difference was attributed to one of the two named noise mechanisms; and nothing forced me to invent a new category after seeing the data, which is the real test of a pre-registered taxonomy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I amended after seeing the results was the pass criterion.&lt;/strong&gt; The original said any hard difference fails, and with that the gate was impossible to pass for any multi-turn conversation against a hosted model. I wrote a dated addendum redefining it in one line: it passes if no hard difference appears in a turn whose inputs were identical, and every residual is attributed to a named noise mechanism.&lt;/p&gt;

&lt;p&gt;I'm aware that amending the bar after seeing the data is exactly the move that invalidates an experiment. That's why the addendum carries a date and says out loud that it came after the run, because the distinction between what was registered before and what was decided after is part of the data. And I think the justification stands on its own: &lt;strong&gt;a criterion no system can satisfy tells you nothing about that system.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  If you build agents
&lt;/h2&gt;

&lt;p&gt;Two things I'm taking away that I think transfer.&lt;/p&gt;

&lt;p&gt;Instrument first, experiment second: audit your traces against production before you believe them.&lt;/p&gt;

&lt;p&gt;And the one that surprised me most, which is that &lt;strong&gt;divergence compounds through persisted state&lt;/strong&gt;. In a system with memory, two "identical" runs don't just diverge in wording but in conversational state, and from there in behavior. A system holding for several turns before it shows isn't robustness, it's delay.&lt;/p&gt;

&lt;p&gt;I'll also state what none of this claims. It doesn't claim text-to-text reproducibility, in fact it demonstrates that's impossible. And it doesn't validate any experiment yet, it only validates that the instrument that will measure them doesn't lie about its own behavior.&lt;/p&gt;

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

&lt;p&gt;With the instrument calibrated, the experiments begin, to understand which memory features earn their place and what each one costs.&lt;/p&gt;

&lt;p&gt;If you build agents and measured something similar, or something different, or reached the opposite conclusion, I'd like to compare notes. Especially if you found a way to bound that compounding effect that I might be missing.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>observability</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
