<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  <generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator>
  <link href="https://nadas.ro/feed.xml" rel="self" type="application/atom+xml" />
  <link href="https://nadas.ro/" rel="alternate" type="text/html" hreflang="en" />
  <updated>2026-10-17T20:18:11+03:00</updated>
  
  <id>https://mihainadas.github.io/feed.xml</id>

  
  <title type="html">Mihai Nadăș</title>
  <subtitle>AI, software-led differentiation, and business decisions. Mihai Nadăș connects technology leadership to public engineering work and language-model research.</subtitle>
  
  <author>
    <name>Mihai Nadăș</name>
    <email>mihai.nadas@ubbcluj.ro</email>
  </author>
  
  
  
  
  
  <entry xml:lang="en">
    
    
    
    <title type="html">Deep Research Needs an Evidence Budget</title>
    <link href="https://nadas.ro/2026/10/17/deep-research-needs-evidence-budget.html" rel="alternate" type="text/html" title="Deep Research Needs an Evidence Budget" />
    <published>2026-10-17T12:58:00+03:00</published>
    <updated>2026-10-17T13:42:57+03:00</updated>
    <id>https://mihainadas.github.io/2026/10/17/deep-research-needs-evidence-budget</id>
    <content type="html" xml:base="https://nadas.ro/2026/10/17/deep-research-needs-evidence-budget.html"><![CDATA[<p>A deep-research agent turns a question into a report by searching, reading sources, keeping evidence, and deciding when to stop. Consider using one to compare three software vendors for a regulated workflow. The report may be fluent and well organized. Before choosing a vendor, though, someone still needs to check where each important claim came from.</p>

<p>That is the problem I want the research process to solve: make the path from a source to a decision inspectable. A useful agent must find the relevant material, read it, resolve conflicts and duplicates, and show what remains uncertain. More searches or a longer report do not establish that it has done those jobs.</p>

<p>The papers below study different parts of this process. I use them to build a practical design proposal: give discovery, inspection, verification, and synthesis their own evidence budgets. This is an operating model to test in a business workflow, not a claim that the cited benchmarks have already validated vendor due diligence.</p>

<h2 id="start-with-a-decision-someone-has-to-defend">Start with a decision someone has to defend</h2>

<p>For the vendor comparison, the request needs a decision date, a hierarchy of acceptable sources, and a stopping rule. Otherwise the agent has no reliable way to distinguish a finished investigation from a polished draft.</p>

<p>The discovery budget might require official product documentation, security and privacy materials, contractual terms, public incident records, and the relevant regulator’s guidance. The inspection budget requires the actual documents to be opened, dated, and quoted at the point supporting each claim. The verification budget assigns a second source or reviewer to claims about certifications, data location, breach history, and contractual liability. The synthesis budget produces both a comparison and an exception register.</p>

<p>The resulting artifact should contain at least:</p>

<ol>
  <li>the exact question and decision date;</li>
  <li>the search plan and sources consulted;</li>
  <li>an evidence ledger linking claims to inspected passages;</li>
  <li>a contradiction and missing-information queue;</li>
  <li>the inclusion, exclusion, and stopping rules;</li>
  <li>the recommendation, with assumptions kept separate from findings.</li>
</ol>

<p>The intended benefit is faster human review. A reviewer can concentrate on weak evidence and judgment calls instead of reconstructing the entire browsing session. The same record gives the next run a place to check which sources or facts changed.</p>

<h2 id="finding-a-page-is-different-from-reading-it">Finding a page is different from reading it</h2>

<p><a href="https://arxiv.org/abs/2503.09516">Search-R1</a> and <a href="https://arxiv.org/abs/2503.19470">ReSearch</a> train language models to interleave reasoning with multiple search calls. The point is not simply to attach a search API. The policy learns when a knowledge gap justifies a query and how retrieved material should change the next reasoning step.</p>

<p>That is a necessary capability, but a search result is not evidence yet. A result page contains rankings, titles, URLs, and snippets selected by another system. It may omit the qualifying sentence, point to an inaccessible page, or combine text that was not adjacent in the source. Treating the snippet as the source makes the research process fast and fragile.</p>

<p><a href="https://arxiv.org/abs/2603.20278">OpenResearcher</a> makes this distinction explicit. Its offline environment exposes three browser operations: <code class="language-plaintext highlighter-rouge">search</code>, <code class="language-plaintext highlighter-rouge">open</code>, and <code class="language-plaintext highlighter-rouge">find</code>. The authors report more than 97,000 synthesized trajectories over a fixed 15-million-document corpus, including trajectories with more than 100 tool calls. In their ablations, retrieving a relevant document did not guarantee a correct answer. Opening and inspecting it materially improved performance.</p>

<p>The same distinction appears in different forms elsewhere. <a href="https://arxiv.org/abs/2509.06283">SFR-DeepResearch</a> gives a single agent web search, page browsing, and Python. <a href="https://arxiv.org/abs/2504.03160">DeepResearcher</a> trains against live web interactions rather than assuming a clean closed corpus. <a href="https://arxiv.org/abs/2510.24701">Tongyi DeepResearch</a> maintains an evolving report as compressed memory instead of carrying the entire history unchanged. These are different implementations of one operational fact: finding, reading, computing, and remembering are separate actions with separate failure modes.</p>

<h2 id="four-budgets-make-the-process-inspectable">Four budgets make the process inspectable</h2>

<p>An evidence budget can be recorded as four related limits.</p>

<table>
  <thead>
    <tr>
      <th>Budget</th>
      <th>What it controls</th>
      <th>Example completion test</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Discovery</td>
      <td>Queries, result pages, repositories, and databases searched</td>
      <td>Every required market, jurisdiction, and synonym has a documented search path.</td>
    </tr>
    <tr>
      <td>Inspection</td>
      <td>Full sources opened and relevant passages checked</td>
      <td>Every material claim cites an inspected source rather than a search snippet.</td>
    </tr>
    <tr>
      <td>Verification</td>
      <td>Cross-checks, contradiction searches, calculations, and entity resolution</td>
      <td>High-impact claims have independent support or an explicit conflict note.</td>
    </tr>
    <tr>
      <td>Synthesis</td>
      <td>Space for comparing evidence, recording uncertainty, and writing the result</td>
      <td>The report distinguishes findings, assumptions, unresolved questions, and recommendations.</td>
    </tr>
  </tbody>
</table>

<p>The budgets do not have to be fixed counts. A low-risk product comparison may permit one strong primary source per specification. A regulatory interpretation may require the rule text, the effective date, the responsible authority, later amendments, and jurisdiction-specific guidance. The important property is that the allocation follows the consequence of being wrong.</p>

<p>Separate budgets also make the cost discussion concrete. A team can ask whether another ten searches add coverage, whether another source inspection resolves a material uncertainty, or whether the synthesis stage is consuming tokens without adding evidence. Counting all tool calls together hides those differences.</p>

<h2 id="stopping-is-a-claim-about-coverage">Stopping is a claim about coverage</h2>

<p><a href="https://arxiv.org/abs/2601.20975">DeepSearchQA</a> contains 900 multi-step information-seeking tasks across 17 fields. It evaluates complete answer sets, not only a single fact. The reported failure modes include premature stopping, which sacrifices recall, and hedging, which adds low-confidence items to widen coverage at the expense of precision.</p>

<p>That trade-off is familiar in business research. A market map can miss a small supplier that satisfies a critical constraint. It can also become unusable if every remotely related company is included. The right stopping rule therefore depends on the set definition. Before the search begins, the owner should specify:</p>

<ul>
  <li>inclusion and exclusion criteria;</li>
  <li>the dimensions along which completeness matters;</li>
  <li>acceptable evidence for each item;</li>
  <li>how duplicates and subsidiaries are resolved;</li>
  <li>the residual uncertainty that may remain at the deadline.</li>
</ul>

<p>The agent can then stop because the declared coverage test is satisfied, not because it has produced a plausible number of paragraphs. If the budget expires first, the output should state which branch remains incomplete.</p>

<h2 id="the-evaluation-must-follow-the-decision">The evaluation must follow the decision</h2>

<p><a href="https://arxiv.org/abs/2602.11685">DRACO</a> builds 100 complex tasks across 10 domains and sources from 40 countries. Its task-specific rubrics cover factual accuracy, breadth and depth, presentation quality, and citation quality. This is stronger than one overall judge score because a system can write clearly while missing evidence, or cite extensively while misunderstanding the sources.</p>

<p><a href="https://arxiv.org/abs/2511.19399">DR Tulu</a> goes further during training. Its rubrics evolve with retrieved evidence and contrasting model responses, so the feedback can continue to distinguish outputs as the policy changes. <a href="https://arxiv.org/abs/2510.15862">Rethinking the Design of Reinforcement Learning-Based Deep Research Agents</a>, the paper behind PokeeResearch, separately tests reward design, RL algorithm, data curation, and error-tolerant execution. The authors report gains from LLM feedback, on-policy RLOO, filtering weak training questions, and allowing malformed actions to be repaired at test time.</p>

<p>None of those benchmark results transfers automatically to procurement or compliance. The lesson is structural. A business acceptance test should preserve separate axes for source validity, factual support, required coverage, contradiction handling, citation placement, and presentation. If the organization collapses them into one score, a gain on formatting can conceal a loss on evidence.</p>

<h2 id="build-the-smallest-loop-that-exposes-failure">Build the smallest loop that exposes failure</h2>

<p><a href="https://arxiv.org/abs/2603.15594">OpenSeeker</a> generates difficult questions by traversing a web graph and obscuring entities that would permit keyword shortcuts. It also uses retrospective summarization to compress older observations while keeping recent evidence in detail. Both ideas are useful beyond model training: test cases should require real evidence traversal, and memory compression should preserve provenance rather than only conclusions.</p>

<p>A first implementation does not need a frontier model or a complicated multi-agent hierarchy. It needs a traceable loop:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>plan -&gt; search -&gt; open -&gt; extract -&gt; record -&gt; check coverage
                         ^                    |
                         +---- revise --------+
</code></pre></div></div>

<p>Each extracted claim receives a source URL, retrieval time, quoted or structured evidence, and a confidence or conflict state. Search results that were never opened stay marked as leads. The coverage check chooses the next unresolved branch or stops with a reason. A separate synthesis step writes only from the evidence ledger.</p>

<p>This design can use one model, several models, or deterministic tools. <a href="https://arxiv.org/abs/2509.06283">SFR-DeepResearch</a> is a useful reminder that the control surface can remain small. Adding roles does not repair an undefined evidence standard.</p>

<p>At review time, start with a material claim and try to follow it back to an inspected source. Then check the unresolved questions and the reason the agent stopped. If that trail is missing, the report is not ready to support the decision, however convincing its prose.</p>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="agents" /><category term="retrieval" /><category term="evaluation" /><category term="decision-support" /><category term="business" />
    
    
    <summary type="html"><![CDATA[Deep-research agents need explicit budgets for discovery, inspection, verification, and stopping before their reports can support business decisions.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">Merging LoRA Removes a Branch, Not Parameters</title>
    <link href="https://nadas.ro/2026/10/16/merging-lora-removes-branch-not-parameters.html" rel="alternate" type="text/html" title="Merging LoRA Removes a Branch, Not Parameters" />
    <published>2026-10-16T09:32:53+03:00</published>
    <updated>2026-10-17T13:42:57+03:00</updated>
    <id>https://mihainadas.github.io/2026/10/16/merging-lora-removes-branch-not-parameters</id>
    <content type="html" xml:base="https://nadas.ro/2026/10/16/merging-lora-removes-branch-not-parameters.html"><![CDATA[<p>TinyFabulist’s TF2 system adapts open-weight language models for English-Romanian literary translation. It uses LoRA, short for low-rank adaptation: training small added matrices while keeping the model’s original weights fixed. For TF2, those updates have rank 32 and target selected attention and feed-forward projections.</p>

<p>A small adapter is useful during training, but the deployed model is another matter. TF2 merges the learned update into the original weights before serving it. My earlier note on <a href="/2025/08/04/lora-finetuning-translation.html">the TF2 adapter</a> described the update as \(W’ = W + (\alpha/r)BA\) and separated that adaptation from later quantization. One phrase still needs a sharper boundary: merging does not turn the full model into a low-rank model, and it does not make the deployed checkpoint contain only the adapter parameters.</p>

<p>Follow one adapted matrix from training to deployment and the trade-off becomes concrete. In exact arithmetic, the merged and unmerged forms compute the same linear map. They differ in storage, serving flexibility, and numerical behavior once finite precision or quantization enters the path. Keeping those properties separate prevents “parameter-efficient” from becoming an ambiguous claim about training, checkpoints, and inference at once.</p>

<h2 id="the-update-is-low-rank-not-the-resulting-layer">The update is low-rank, not the resulting layer</h2>

<p>Let the frozen matrix be \(W_0 \in \mathbb{R}^{d \times k}\). LoRA trains</p>

<p>\[
A \in \mathbb{R}^{r \times k}, \qquad B \in \mathbb{R}^{d \times r}
\]</p>

<p>with \(r \ll \min(d,k)\). With scale \(s = \alpha/r\), the adapted matrix is</p>

<p>\[
W’ = W_0 + sBA.
\]</p>

<p>The <a href="https://arxiv.org/html/2106.09685v2#S4.SS1">original LoRA paper</a> freezes \(W_0\), optimizes \(A\) and \(B\), and identifies \(BA\) as the constrained update. Because</p>

<p>\[
\operatorname{rank}(BA) \le r,
\]</p>

<p>the difference \(W’ - W_0\) is low-rank. No corresponding statement says that \(W’\) is low-rank. If \(W_0\) has full rank, adding a rank-32 update will normally leave the resulting dense matrix full-rank.</p>

<p>That distinction controls the parameter count. A dense \(d\)-by-\(k\) matrix contains \(dk\) values. Its LoRA pair contains \(r(k+d)\) trainable values. For a square \(4096 \times 4096\) projection with rank 32:</p>

<table>
  <thead>
    <tr>
      <th>Artifact</th>
      <th style="text-align: right">Values</th>
      <th style="text-align: right">FP16 storage</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Frozen dense matrix \(W_0\)</td>
      <td style="text-align: right">16,777,216</td>
      <td style="text-align: right">32 MiB</td>
    </tr>
    <tr>
      <td>LoRA matrices \(A\) and \(B\)</td>
      <td style="text-align: right">262,144</td>
      <td style="text-align: right">0.5 MiB</td>
    </tr>
    <tr>
      <td>Merged dense matrix \(W’\)</td>
      <td style="text-align: right">16,777,216</td>
      <td style="text-align: right">32 MiB</td>
    </tr>
  </tbody>
</table>

<p>The adapter has 1.5625% as many values as this base matrix. That is a real reduction in trainable, optimizer-managed, and task-specific parameters. It is not a 64-fold reduction in the size of the model required for inference. The base weights remain necessary either as a shared artifact or inside each merged checkpoint.</p>

<h2 id="merging-changes-the-evaluation-graph">Merging changes the evaluation graph</h2>

<p>Without merging, an evaluation-mode forward pass applies two branches:</p>

<p>\[
h = W_0x + sB(Ax).
\]</p>

<p>The low-rank order matters computationally. Forming \(BA\) on every forward pass would create a dense matrix and discard the main benefit of keeping an adapter separate. Computing \(Ax\) and then \(B(Ax)\) adds roughly \(rk+dr\) multiply-accumulate operations per input vector, compared with \(dk\) for the base projection.</p>

<p>After merging, deployment first computes</p>

<p>\[
W’ \leftarrow W_0 + sBA
\]</p>

<p>once, then uses</p>

<p>\[
h = W’x.
\]</p>

<p>Distributivity makes the two expressions equal in exact arithmetic. Adapter dropout does not contradict this equivalence because dropout is disabled in evaluation mode. The <a href="https://arxiv.org/html/2106.09685v2#S4.SS1">LoRA paper’s deployment construction</a> explicitly stores the sum to avoid additional inference latency, and the current <a href="https://huggingface.co/docs/peft/main/en/conceptual_guides/lora#merge-lora-weights-into-the-base-model">PEFT LoRA guide</a> exposes the same operation through <code class="language-plaintext highlighter-rouge">merge_and_unload()</code>.</p>

<p>The practical gain is not the small arithmetic ratio alone. A separate adapter can add kernel launches, wrapper logic, extra memory reads, and synchronization around the base projection. Their effect depends on the runtime, batch size, hardware, and whether it has fused adapter kernels. A merge removes that branch entirely and returns the original dense architecture to the serving system.</p>

<p>The cost is flexibility. A shared base plus several small adapters can switch tasks without storing several full models. A merged checkpoint is one task-specific copy of the full backbone. PEFT’s <a href="https://huggingface.co/docs/peft/main/en/developer_guides/checkpoint#merge-the-weights">checkpoint documentation</a> also notes that <code class="language-plaintext highlighter-rouge">merge_and_unload()</code> removes PEFT-specific behavior: the result cannot disable, replace, or unmerge the adapter through those APIs.</p>

<p>Merging is therefore deployment repackaging. It trades adapter identity and switching for a simpler inference graph. Compression would require a separate operation such as quantization, pruning, or a smaller architecture.</p>

<h2 id="where-the-algebra-meets-floating-point">Where the algebra meets floating point</h2>

<p>The algebra does not promise bitwise equality on a computer. The unmerged path evaluates \(W_0x + sB(Ax)\); the merged path first rounds the entries of \(W_0 + sBA\), then multiplies the rounded matrix by \(x\). Floating-point addition and multiplication can round at different points even when both paths use the same nominal dtype.</p>

<p>For an ordinary FP16 or BF16 merge, the resulting differences should be judged against a declared tolerance and task-level output checks. A useful pre-deployment test compares logits from the adapter path and the merged path on fixed inputs before any generation sampling. Greedy decoded text alone is too coarse: two logit tensors can differ while producing the same tokens, or a tiny difference near a tie can change one token and everything after it.</p>

<p>The test also needs the model in evaluation mode. Otherwise adapter dropout can make repeated unmerged calls differ from each other, which says nothing about whether merging was implemented correctly.</p>

<p>Modules outside the LoRA matrices deserve explicit accounting. Biases or task heads may be trained and stored through configuration such as PEFT’s <code class="language-plaintext highlighter-rouge">bias</code> or <code class="language-plaintext highlighter-rouge">modules_to_save</code>. A checkpoint that preserves only \(A\) and \(B\) while omitting those trained values is not the same adapted model, and merging the LoRA layers cannot reconstruct them.</p>

<h2 id="quantized-merging-is-a-new-approximation">Quantized merging is a new approximation</h2>

<p>Quantization makes the boundary more visible. If the base weights are already stored in a quantized format, a merge cannot generally add a floating-point \(BA\) directly to their integer codes. The implementation must reconstruct approximate real values, add the adapter update, and quantize the sum again.</p>

<p>PEFT’s current <a href="https://huggingface.co/docs/peft/main/en/developer_guides/quantization#merging-adapters-into-quantized-weights">quantization guide</a> describes exactly that sequence for supported methods: dequantize, add, then requantize. It also warns that unmerging performs another round trip and does not restore the original quantized weights. Repeated merge and unmerge cycles can accumulate rounding error.</p>

<p>This produces three distinct objects:</p>

<ol>
  <li>the quantized approximation of the frozen base, \(Q(W_0)\);</li>
  <li>the runtime combination of that approximation with an unmerged adapter;</li>
  <li>the quantized approximation of the merged matrix, \(Q(W_0 + sBA)\).</li>
</ol>

<p>There is no general identity that makes the second and third objects numerically equal. Quantization is nonlinear because scales, zero points, groups, clipping, and rounding can change when the update is included. Some quantization methods do not support merging at all, and support can depend on the layer type and configuration.</p>

<p>For a release like TF2, the reproducible statement is not merely “LoRA was merged and the model was quantized.” The order matters. Merging into a higher-precision base and then calibrating or quantizing the result is a different export path from loading a previously quantized base and asking an adapter library to merge into it.</p>

<h2 id="preserve-both-the-recipe-and-the-product">Preserve both the recipe and the product</h2>

<p>A small adapter is attractive because the base can remain shared. That benefit depends on recording which base it modifies. Adapter provenance should include the exact backbone revision, tokenizer, target modules, per-layer ranks, scaling convention, trained biases or saved modules, and adapter weights.</p>

<p>A merged artifact needs a different record:</p>

<ul>
  <li>the base and adapter revisions used to create it;</li>
  <li>the merge library and version, dtype, device, and selected adapters;</li>
  <li>whether the saved result still contains adapter wrappers;</li>
  <li>the quantization method, order, calibration data, group settings, and output dtype;</li>
  <li>a fixed-input logit comparison before and after merge;</li>
  <li>checksums for the adapter and final checkpoint.</li>
</ul>

<p>The adapter recipe explains how the task-specific difference was encoded. The merged checkpoint records the exact dense product that was deployed. One cannot substitute for the other after quantization or when the original base revision disappears.</p>

<p>LoRA saves training state and task-specific storage because it represents the update with two small matrices. Merging trades that separability for the base model’s ordinary inference graph. The deployed matrix still has \(dk\) values; only its difference from the frozen base came through a rank-\(r\) path.</p>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="language-models" /><category term="lora" /><category term="deployment" /><category term="reproducibility" />
    
    
    <summary type="html"><![CDATA[A merged LoRA checkpoint removes adapter computation at inference, but it stores a full dense model and preserves only the low-rank origin of the update.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">A Test Set Cannot Choose Its Own Threshold</title>
    <link href="https://nadas.ro/2026/10/14/test-set-cannot-choose-threshold.html" rel="alternate" type="text/html" title="A Test Set Cannot Choose Its Own Threshold" />
    <published>2026-10-14T09:33:01+03:00</published>
    <updated>2026-10-14T09:33:01+03:00</updated>
    <id>https://mihainadas.github.io/2026/10/14/test-set-cannot-choose-threshold</id>
    <content type="html" xml:base="https://nadas.ro/2026/10/14/test-set-cannot-choose-threshold.html"><![CDATA[<p>A binary classifier produces a score for each case, then a threshold converts that score into a positive or negative decision. My earlier note on <a href="/2026/09/28/ranking-metric-cannot-choose-threshold.html">ranking and thresholds</a> separated those two operations: ROC AUC evaluates the score order, while recall, precision, and error cost depend on the chosen cutoff.</p>

<p>Selecting the cutoff does not change the fitted coefficients, but it still uses labels to choose one rule from several candidates. If the same final test labels choose the threshold and measure its performance, the reported score includes the noise that made this threshold win. The test set has become part of model selection.</p>

<p>This note makes that bias exact with ten fixed scores that contain no label information. Across every balanced assignment of five positive labels, the best same-sample threshold averages 65.3% accuracy. Apply the selected threshold to an independent balanced assignment and its expected accuracy is 50%. The apparent improvement comes entirely from reusing the labels.</p>

<h2 id="eleven-cutoffs-search-one-random-ordering">Eleven cutoffs search one random ordering</h2>

<p>Number ten cases from lowest score to highest score. The score order is fixed before any labels are assigned. Exactly five cases are positive, but every one of the \(\binom{10}{5}=252\) balanced labelings is equally likely. Under this construction, the scores carry no information about the labels.</p>

<p>A threshold between adjacent scores can predict the highest \(k\) cases as positive, for \(k=0,1,\ldots,10\). These are eleven distinct decision rules. For each labeling, choose the value of \(k\) with the largest accuracy on those same ten cases.</p>

<p>Exact enumeration gives this distribution:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">Best correct predictions</th>
      <th style="text-align: right">Labelings</th>
      <th style="text-align: right">Fraction of all labelings</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">5 of 10</td>
      <td style="text-align: right">42</td>
      <td style="text-align: right">1/6</td>
    </tr>
    <tr>
      <td style="text-align: right">6 of 10</td>
      <td style="text-align: right">90</td>
      <td style="text-align: right">5/14</td>
    </tr>
    <tr>
      <td style="text-align: right">7 of 10</td>
      <td style="text-align: right">75</td>
      <td style="text-align: right">25/84</td>
    </tr>
    <tr>
      <td style="text-align: right">8 of 10</td>
      <td style="text-align: right">35</td>
      <td style="text-align: right">5/36</td>
    </tr>
    <tr>
      <td style="text-align: right">9 of 10</td>
      <td style="text-align: right">9</td>
      <td style="text-align: right">1/28</td>
    </tr>
    <tr>
      <td style="text-align: right">10 of 10</td>
      <td style="text-align: right">1</td>
      <td style="text-align: right">1/252</td>
    </tr>
  </tbody>
</table>

<p>The mean of the selected accuracies is</p>

<p>\[
\frac{823}{1260} \approx 0.6532.
\]</p>

<p>One of the 252 random labelings can even be separated perfectly by a threshold: all five negatives happen to receive the lower scores and all five positives the higher scores. That event is rare, but every labeling lets the selection procedure keep its most favorable cutoff.</p>

<p>The enumeration needs no machine-learning library:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">itertools</span> <span class="kn">import</span> <span class="n">combinations</span>
<span class="kn">from</span> <span class="nn">statistics</span> <span class="kn">import</span> <span class="n">mean</span>


<span class="n">n</span> <span class="o">=</span> <span class="mi">10</span>
<span class="n">all_cases</span> <span class="o">=</span> <span class="nb">set</span><span class="p">(</span><span class="nb">range</span><span class="p">(</span><span class="n">n</span><span class="p">))</span>
<span class="n">best_accuracies</span> <span class="o">=</span> <span class="p">[]</span>

<span class="k">for</span> <span class="n">positive_tuple</span> <span class="ow">in</span> <span class="n">combinations</span><span class="p">(</span><span class="nb">range</span><span class="p">(</span><span class="n">n</span><span class="p">),</span> <span class="n">n</span> <span class="o">//</span> <span class="mi">2</span><span class="p">):</span>
    <span class="n">positive</span> <span class="o">=</span> <span class="nb">set</span><span class="p">(</span><span class="n">positive_tuple</span><span class="p">)</span>
    <span class="n">accuracies</span> <span class="o">=</span> <span class="p">[]</span>

    <span class="k">for</span> <span class="n">k</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">n</span> <span class="o">+</span> <span class="mi">1</span><span class="p">):</span>
        <span class="n">predicted_positive</span> <span class="o">=</span> <span class="nb">set</span><span class="p">(</span><span class="nb">range</span><span class="p">(</span><span class="n">n</span> <span class="o">-</span> <span class="n">k</span><span class="p">,</span> <span class="n">n</span><span class="p">))</span>
        <span class="n">true_positive</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">positive</span> <span class="o">&amp;</span> <span class="n">predicted_positive</span><span class="p">)</span>
        <span class="n">true_negative</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span>
            <span class="p">(</span><span class="n">all_cases</span> <span class="o">-</span> <span class="n">positive</span><span class="p">)</span> <span class="o">-</span> <span class="n">predicted_positive</span>
        <span class="p">)</span>
        <span class="n">accuracies</span><span class="p">.</span><span class="n">append</span><span class="p">((</span><span class="n">true_positive</span> <span class="o">+</span> <span class="n">true_negative</span><span class="p">)</span> <span class="o">/</span> <span class="n">n</span><span class="p">)</span>

    <span class="n">best_accuracies</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="nb">max</span><span class="p">(</span><span class="n">accuracies</span><span class="p">))</span>

<span class="k">print</span><span class="p">(</span><span class="n">mean</span><span class="p">(</span><span class="n">best_accuracies</span><span class="p">))</span>  <span class="c1"># 0.6531746031746032
</span></code></pre></div></div>

<p>This value is not a universal correction for threshold tuning. It belongs to ten cases, balanced labels, accuracy, and this set of eleven nested rules. Its role is to show the mechanism without simulation error.</p>

<h2 id="a-fixed-cutoff-has-no-advantage-here">A fixed cutoff has no advantage here</h2>

<p>For any fixed \(k\), the highest-scored group contains \(k/2\) positives on average. The remaining \(10-k\) cases contain \((10-k)/2\) negatives on average. The expected number of correct predictions is therefore</p>

<p>\[
\frac{k}{2} + \frac{10-k}{2} = 5,
\]</p>

<p>so the expected accuracy is 50% for every cutoff chosen without seeing the evaluated labels.</p>

<p>The selected cutoff is also fixed before an independent second labeling is revealed. Conditional on any \(k\) chosen from the first sample, its expected accuracy on that second balanced sample is still 50%. Averaging over the threshold-selection procedure does not change the result.</p>

<p>The gap between 65.3% and 50% is selection bias. Each individual accuracy is an unbiased estimate for its fixed rule under this construction. The maximum of the eleven noisy estimates is not an unbiased estimate of the selected rule’s future accuracy.</p>

<p>The thresholds are strongly dependent, so the example is not equivalent to taking the maximum of eleven independent measurements. Dependence reduces the search space, but it does not remove selection. A run of positives near one end of the random score order can still make one cutoff look useful on the sample that exposed the run.</p>

<h2 id="threshold-tuning-is-part-of-fitting">Threshold tuning is part of fitting</h2>

<p>The same boundary applies to a real classifier. Choosing a cutoff to maximize F1, balanced accuracy, recall under a precision constraint, or measured utility adapts a decision rule to labeled data. The operation may leave the score function unchanged, but the deployed predictor now contains a learned threshold.</p>

<p>Scikit-learn’s <a href="https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TunedThresholdClassifierCV.html"><code class="language-plaintext highlighter-rouge">TunedThresholdClassifierCV</code></a> represents that threshold as fitted state. Its documentation describes cross-validated cutoff selection and explicitly warns that using <code class="language-plaintext highlighter-rouge">cv="prefit"</code> with the same data for estimator fitting and threshold tuning is subject to overfitting.</p>

<p>That warning covers one reuse boundary. A second boundary appears at evaluation time. If cross-validation chooses the cutoff and the same cross-validation score is then presented as the final performance estimate, the selection criterion is being used to assess its own winner. Cawley and Talbot’s paper on <a href="https://www.jmlr.org/papers/volume11/cawley10a/cawley10a.pdf">over-fitting in model selection</a> treats model selection as part of fitting and shows why performance evaluation must include that complete procedure.</p>

<p>A clean single-split experiment assigns three roles:</p>

<ol>
  <li>Fit preprocessing and score-model parameters on training data.</li>
  <li>Choose the threshold and its objective on separate validation data.</li>
  <li>Freeze both, then evaluate once on the test data.</li>
</ol>

<p>The <a href="https://scikit-learn.org/stable/modules/cross_validation.html">scikit-learn cross-validation guide</a> gives the same separation for hyperparameter selection: settings chosen to optimize test performance leak test information into the model, so the final test set remains held out after validation or cross-validation.</p>

<p>Thresholds deserve the same treatment as regularization strengths, feature choices, and calibration methods. They are all decisions informed by labels.</p>

<h2 id="the-outer-split-evaluates-the-procedure">The outer split evaluates the procedure</h2>

<p>When one validation split would waste too much data or make the result depend heavily on one partition, nested cross-validation can estimate the complete procedure. In each outer fold:</p>

<ol>
  <li>Use only the outer training portion to fit preprocessing and the score model.</li>
  <li>Use an inner split or inner cross-validation to choose the threshold.</li>
  <li>Apply the resulting model and fixed threshold to the untouched outer test portion.</li>
</ol>

<p>The outer predictions can then be collected for an overall confusion matrix or scored within folds according to a declared aggregation rule. Threshold selection must restart inside every outer fold. Choosing one global threshold from all out-of-fold labels before scoring those same labels would recreate the original reuse in a less visible form.</p>

<p>After evaluation, a final deployment model may be refit on all available development data and receive a threshold selected by the declared inner procedure. The outer-fold score estimates that procedure’s performance; it does not certify that one final numeric threshold is invariant across samples.</p>

<p>Threshold variability is itself useful evidence. If small changes to the validation set move the selected cutoff widely while the objective barely changes, reporting only one threshold hides an unstable choice. The metric curve around the optimum, fold-level thresholds, and confusion counts can distinguish a broad plateau from a sharp, sample-specific maximum.</p>

<h2 id="preserve-the-selection-path">Preserve the selection path</h2>

<p>A reproducible threshold result needs more than the final number. I would retain:</p>

<ul>
  <li>the score source, positive-class mapping, and fitted preprocessing;</li>
  <li>the candidate threshold rule, including ties and endpoint cutoffs;</li>
  <li>the selection objective and any constraints or error costs;</li>
  <li>the sample or inner folds used to choose the threshold;</li>
  <li>the independent sample or outer folds used for evaluation;</li>
  <li>the chosen threshold, objective curve, and final confusion counts.</li>
</ul>

<p>Those fields separate the rule that was searched from the data that judged the search. A threshold can be tuned aggressively when the application requires it. The final test labels must remain unavailable until that tuning is finished.</p>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="machine-learning" /><category term="evaluation" /><category term="model-selection" /><category term="experimental-design" />
    
    
    <summary type="html"><![CDATA[Choosing a classifier cutoff on the final test labels turns evaluation noise into an optimistic result, even when the score order contains no signal.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">A Distillation Weight Needs Its Temperature</title>
    <link href="https://nadas.ro/2026/10/09/distillation-weight-needs-temperature.html" rel="alternate" type="text/html" title="A Distillation Weight Needs Its Temperature" />
    <published>2026-10-09T09:33:16+03:00</published>
    <updated>2026-10-09T09:33:16+03:00</updated>
    <id>https://mihainadas.github.io/2026/10/09/distillation-weight-needs-temperature</id>
    <content type="html" xml:base="https://nadas.ro/2026/10/09/distillation-weight-needs-temperature.html"><![CDATA[<p>TF3-RO trains a 51.65M-parameter Romanian language model, then compresses it into a 26.45M-parameter student. Both models use the same 32,000-entry Unigram tokenizer and predict the next token over the same vocabulary. This alignment lets the student learn from the teacher’s complete probability distribution at each token position, not only from the one token present in the corpus.</p>

<p>The <a href="https://arxiv.org/html/2601.10410v1#S7.SS2">TF3 paper</a> describes a hybrid objective: teacher-student KL divergence receives weight 1.0, while ordinary next-token cross-entropy receives weight 0.1. Those coefficients look like a complete mixing rule. They are not, because the magnitude and meaning of the KL term also depend on how logits become probabilities and how token losses are reduced.</p>

<p>This note isolates that reproducibility boundary. It does not re-evaluate the TF3 student or show that its reported result is wrong. It shows why a distillation record needs the softmax temperature, any temperature compensation, the KL implementation, and the exact valid-token denominator before <code class="language-plaintext highlighter-rouge">1.0</code> versus <code class="language-plaintext highlighter-rouge">0.1</code> can be reconstructed.</p>

<h2 id="temperature-changes-the-target">Temperature changes the target</h2>

<p>For teacher logits \(v_i\) over a vocabulary and temperature \(T\), the soft target is</p>

<p>\[
p_i^{(T)} = \frac{\exp(v_i/T)}{\sum_j \exp(v_j/T)}.
\]</p>

<p>The student distribution \(q_i^{(T)}\) is computed from student logits with the same temperature. A larger \(T\) moves both distributions toward uniform, exposing relative preferences that may be nearly invisible at \(T=1\).</p>

<p>The original <a href="https://arxiv.org/abs/1503.02531">knowledge-distillation paper</a> makes this temperature part of the method. It generates teacher targets at a raised temperature, trains the student at that same temperature, then returns the student to \(T=1\) for inference. Temperature is therefore not an inference sampling option pasted onto training. It defines the target distribution being imitated.</p>

<p>Consider teacher logits <code class="language-plaintext highlighter-rouge">[4, 2, 0]</code> and student logits <code class="language-plaintext highlighter-rouge">[3, 2, 1]</code> for three possible next tokens:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">\(T\)</th>
      <th>Teacher probabilities</th>
      <th>Student probabilities</th>
      <th style="text-align: right">\(\mathrm{KL}(p^{(T)}|q^{(T)})\)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">1</td>
      <td>[0.8668, 0.1173, 0.0159]</td>
      <td>[0.6652, 0.2447, 0.0900]</td>
      <td style="text-align: right">0.1156</td>
    </tr>
    <tr>
      <td style="text-align: right">2</td>
      <td>[0.6652, 0.2447, 0.0900]</td>
      <td>[0.5065, 0.3072, 0.1863]</td>
      <td style="text-align: right">0.0603</td>
    </tr>
    <tr>
      <td style="text-align: right">4</td>
      <td>[0.5065, 0.3072, 0.1863]</td>
      <td>[0.4192, 0.3265, 0.2543]</td>
      <td style="text-align: right">0.0191</td>
    </tr>
  </tbody>
</table>

<p>The logits and the coefficient on KL did not change. The numerical loss fell by a factor of six because the probability distributions did. More importantly, the student received a different learning signal about the two lower-ranked tokens.</p>

<p>At \(T=1\), the teacher assigns the third token only 1.6% probability. At \(T=4\), it assigns 18.6%. The higher-temperature target asks the student to preserve more of the teacher’s ranking among alternatives. Whether those alternatives contain useful structure or noise is an empirical question, which is why \(T\) belongs in the experiment record.</p>

<h2 id="the-gradient-brings-a-second-temperature-effect">The gradient brings a second temperature effect</h2>

<p>For soft-target cross-entropy, the derivative with respect to student logit \(z_i\) is</p>

<p>\[
\frac{\partial \mathcal{L}_{\mathrm{soft}}}{\partial z_i}
= \frac{1}{T}\left(q_i^{(T)}-p_i^{(T)}\right).
\]</p>

<p>Raising \(T\) makes the two distributions softer and adds the explicit \(1/T\) factor. At sufficiently high temperature, the gradient magnitude scales approximately as \(1/T^2\). Hinton, Vinyals, and Dean therefore multiply the soft-target term by \(T^2\) when combining it with the hard-target term. The compensation keeps the two gradient contributions on roughly the same scale while \(T\) is varied.</p>

<p>The three-logit example shows the effect. Its unscaled soft-target gradient norm is 0.2497 at \(T=1\), 0.0980 at \(T=2\), and 0.0281 at \(T=4\). Multiplying the loss by \(T^2\) changes those norms to 0.2497, 0.3918, and 0.4490. The compensation is asymptotic, not a promise that finite-temperature norms will be identical.</p>

<p>A complete hybrid objective might therefore be written as</p>

<p>\[
\mathcal{L}
= \alpha T^2\,\mathrm{KL}\!\left(p_T^{(T)}\|p_S^{(T)}\right) +
\beta\,\mathrm{CE}(y,p_S^{(1)}).
\]</p>

<p>If the implementation uses \(T=1\), the factor is one and can disappear harmlessly. If it uses \(T=4\), omitting the factor makes the soft-target gradient much smaller relative to the hard-target gradient even though \(\alpha\) and \(\beta\) are unchanged. Reporting only those two coefficients does not distinguish the cases.</p>

<h2 id="reduction-chooses-the-token-denominator">Reduction chooses the token denominator</h2>

<p>Language-model distillation produces one vocabulary distribution for every scored token. A batch can have shape <code class="language-plaintext highlighter-rouge">[batch, sequence, vocabulary]</code>, plus a mask for padding or other ignored positions. The KL divergence first sums over vocabulary entries to give one value per token. Only then should the declared token reduction be applied.</p>

<p>This order matters with PyTorch. The current <a href="https://docs.pytorch.org/docs/stable/generated/torch.nn.KLDivLoss.html"><code class="language-plaintext highlighter-rouge">KLDivLoss</code> documentation</a> says that its input must contain log probabilities and warns that the default <code class="language-plaintext highlighter-rouge">reduction="mean"</code> does not return the mathematical KL divergence. That reduction averages over every tensor element, including vocabulary entries. <code class="language-plaintext highlighter-rouge">batchmean</code> sums all elements and divides by <code class="language-plaintext highlighter-rouge">input.size(0)</code>.</p>

<p>For a classification tensor shaped <code class="language-plaintext highlighter-rouge">[B, V]</code>, <code class="language-plaintext highlighter-rouge">batchmean</code> gives a sum over \(V\) followed by a mean over \(B\). For a language-model tensor shaped <code class="language-plaintext highlighter-rouge">[B, L, V]</code>, it divides only by \(B\), so the result grows with the number of token positions \(L\). Flattening the same valid positions to <code class="language-plaintext highlighter-rouge">[B L, V]</code> before <code class="language-plaintext highlighter-rouge">batchmean</code> instead produces a per-token mean. The values can differ by a factor of \(L\) while the logits, temperature, and nominal KL coefficient remain identical.</p>

<p>A masked implementation can make the denominator explicit:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">teacher_logp</span> <span class="o">=</span> <span class="n">teacher_logits</span><span class="p">.</span><span class="n">log_softmax</span><span class="p">(</span><span class="n">dim</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>
<span class="n">student_logp</span> <span class="o">=</span> <span class="n">student_logits</span><span class="p">.</span><span class="n">log_softmax</span><span class="p">(</span><span class="n">dim</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>

<span class="n">per_token_kl</span> <span class="o">=</span> <span class="p">(</span>
    <span class="n">teacher_logp</span><span class="p">.</span><span class="n">exp</span><span class="p">()</span> <span class="o">*</span> <span class="p">(</span><span class="n">teacher_logp</span> <span class="o">-</span> <span class="n">student_logp</span><span class="p">)</span>
<span class="p">).</span><span class="nb">sum</span><span class="p">(</span><span class="n">dim</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>

<span class="n">soft_loss</span> <span class="o">=</span> <span class="n">per_token_kl</span><span class="p">[</span><span class="n">valid_token_mask</span><span class="p">].</span><span class="n">mean</span><span class="p">()</span>
</code></pre></div></div>

<p>With temperature, both <code class="language-plaintext highlighter-rouge">log_softmax</code> calls receive logits divided by \(T\), and the final soft loss receives the declared \(T^2\) multiplier if that is the chosen convention. The teacher output should be detached or computed without gradients. The hard-target cross-entropy must use the same valid positions if the coefficient ratio is intended to apply per scored token.</p>

<p>The code also states the direction of the divergence. \(\mathrm{KL}(p_T\|p_S)\) weights each log-ratio by the teacher probability. Reversing the arguments is a different objective. PyTorch’s API can make this easy to misread because the student log distribution is the first function argument while the teacher distribution is the target.</p>

<h2 id="vocabulary-alignment-is-a-real-precondition">Vocabulary alignment is a real precondition</h2>

<p>TF3 satisfies a structural condition that should not be treated as incidental: the teacher and student use the same tokenizer and vocabulary. Token index 17,432 therefore names the same subword on both sides, and both models produce a distribution with the same support at the same source position.</p>

<p>Without that alignment, a pointwise KL over logits is undefined as a transfer of token meaning. Two vocabularies can have the same size while assigning different strings to each index. They can also segment the prefix differently, leaving no one-to-one correspondence between prediction positions. Padding a shorter logit vector or matching indices by number would create a valid tensor operation and an invalid learning objective.</p>

<p>Cross-tokenizer distillation needs another bridge, such as probabilities over a shared byte or character space, sequence-level targets sampled from the teacher, or an explicit mapping between compatible tokens. Each changes the object being transferred. The recent note on <a href="/2026/10/07/perplexity-has-a-tokenizer-in-its-denominator.html">tokenizer-dependent perplexity</a> concerned an evaluation denominator. Here the shared tokenizer is part of the training objective’s domain.</p>

<h2 id="record-the-executable-objective">Record the executable objective</h2>

<p>For a language-model student, I would preserve these fields with the checkpoint:</p>

<ul>
  <li>teacher and student revisions, tokenizer artifact, and vocabulary identity;</li>
  <li>temperature \(T\) for both distributions and inference temperature after training;</li>
  <li>KL direction, log-probability convention, and numerical implementation;</li>
  <li>whether the soft loss is multiplied by \(T^2\);</li>
  <li>hard and soft coefficients after all scaling;</li>
  <li>vocabulary sum, valid-token mask, and final reduction denominator;</li>
  <li>treatment of padding, document boundaries, and any unscored first tokens.</li>
</ul>

<p>An executable unit test can use fixed teacher and student logits, a mask containing both valid and ignored positions, and a hand-computed loss. It should assert invariance to adding masked padding and to reshaping valid tokens before reduction. A second test can change \(T\) and verify the chosen scaling convention rather than accepting whatever magnitude a library default returns.</p>

<p>TF3 records the main structural facts: a frozen 51.65M teacher, a 26.45M student, a shared tokenizer, KL from the teacher distribution to the student distribution, hard-target cross-entropy, and weights of 1.0 and 0.1. The remaining details determine the executable fraction behind those weights. A distillation coefficient becomes reproducible only when its temperature and denominator travel with it.</p>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="language-models" /><category term="knowledge-distillation" /><category term="reproducibility" /><category term="experimental-design" />
    
    
    <summary type="html"><![CDATA[A knowledge-distillation coefficient is not reproducible unless the temperature, reduction, and valid-token denominator are recorded with it.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">Perplexity Has a Tokenizer in Its Denominator</title>
    <link href="https://nadas.ro/2026/10/07/perplexity-has-a-tokenizer-in-its-denominator.html" rel="alternate" type="text/html" title="Perplexity Has a Tokenizer in Its Denominator" />
    <published>2026-10-07T09:32:43+03:00</published>
    <updated>2026-10-07T09:32:43+03:00</updated>
    <id>https://mihainadas.github.io/2026/10/07/perplexity-has-a-tokenizer-in-its-denominator</id>
    <content type="html" xml:base="https://nadas.ro/2026/10/07/perplexity-has-a-tokenizer-in-its-denominator.html"><![CDATA[<p>TF3-RO trains a compact Romanian language model from random initialization on synthetic moral microfiction. Before training, the project compared two 32,000-entry Romanian tokenizers. BPE produced 304.89 tokens per sentence on average, while the selected Unigram tokenizer produced 340.35. The Unigram choice favored morphological segmentation rather than the shortest sequences.</p>

<p>My earlier <a href="/2026/01/15/tf3-romanian-microfiction.html">TF3 release note</a> reported that the resulting 51.65M-parameter model reached held-out cross-entropy near 0.89 and perplexity near 2.43. Those values are useful for following checkpoints and compression variants that share the same tokenizer and evaluation pipeline. They do not create a tokenizer-independent scale.</p>

<p>This note isolates the denominator. Perplexity exponentiates average negative log-likelihood per predicted token. If a tokenizer divides the same text into more pieces, it changes that average even when the model assigns the complete text exactly the same probability. A cross-tokenizer comparison therefore needs a unit tied to the text, not only to each model’s tokens.</p>

<h2 id="the-average-is-over-tokens">The average is over tokens</h2>

<p>For a token sequence \(x_1, \ldots, x_T\), let the total negative log-likelihood be</p>

<p>\[
L = -\sum_{t=1}^{T}\log p(x_t \mid x_{&lt;t}).
\]</p>

<p>Token-level cross-entropy is \(L/T\), and perplexity is</p>

<p>\[
\operatorname{PPL} = \exp(L/T).
\]</p>

<p>The <a href="https://huggingface.co/docs/transformers/perplexity">Hugging Face perplexity guide</a> gives this definition and states the comparison constraint directly: tokenization affects perplexity. The exponent does not remove that dependence. It only maps average log loss back to the model’s token scale.</p>

<p>Consider one text with total negative log-likelihood 12 nats under two deterministic, lossless tokenizations:</p>

<table>
  <thead>
    <tr>
      <th>Tokenization</th>
      <th style="text-align: right">Predicted tokens</th>
      <th style="text-align: right">Total NLL</th>
      <th style="text-align: right">Per-token NLL</th>
      <th style="text-align: right">Perplexity</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>coarse</td>
      <td style="text-align: right">6</td>
      <td style="text-align: right">12</td>
      <td style="text-align: right">2</td>
      <td style="text-align: right">7.39</td>
    </tr>
    <tr>
      <td>fine</td>
      <td style="text-align: right">12</td>
      <td style="text-align: right">12</td>
      <td style="text-align: right">1</td>
      <td style="text-align: right">2.72</td>
    </tr>
  </tbody>
</table>

<p>Both factorizations assign the complete text probability \(e^{-12}\), about \(6.14 \times 10^{-6}\). The finer tokenization reports much lower perplexity because the same loss is divided by twice as many prediction events.</p>

<p>The comparison can reverse the likelihood ordering. A model with total NLL 10 over five tokens has perplexity 7.39. Another with total NLL 12 over ten tokens has perplexity 3.32. The second perplexity is lower, but its probability for the complete text is also lower: \(e^{-12}\) rather than \(e^{-10}\). The token scales differ, so the two averages do not rank the texts on a common unit.</p>

<h2 id="tf3-fixes-the-tokenizer-before-reporting-perplexity">TF3 fixes the tokenizer before reporting perplexity</h2>

<p>The <a href="https://arxiv.org/abs/2601.10410">TF3 paper</a> reports that Romanian BPE averaged 304.89 tokens per sentence and Romanian Unigram averaged 340.35 under the actual preprocessing pipeline. The latter is about 11.6% longer. TF3 then uses the Unigram tokenizer for its Transformer, Mamba, compression, and generation experiments.</p>

<p>That order makes the reported training curve interpretable. A decrease from one checkpoint to another uses the same vocabulary, segmentation, held-out distribution, packing rule, and token denominator. The curve describes improved next-token prediction within that fixed system.</p>

<p>It would be different to train one model with each tokenizer and compare only their token perplexities. Even in the artificial case where both models assigned every sentence the same total likelihood, the 11.6% token-count difference would give them different per-token losses. Lower perplexity could reflect a better model, a finer segmentation, or both.</p>

<p>The tokenizer is more than a list of strings. The original <a href="https://arxiv.org/abs/1808.06226">SentencePiece paper</a> describes a model file that contains the vocabulary, segmentation parameters, and character-normalization behavior. Changing that file can change the token count and the text presented to the language model. A reproducible perplexity result therefore needs the tokenizer artifact and its normalization policy, not only the vocabulary size.</p>

<h2 id="normalize-by-a-shared-text-unit">Normalize by a shared text unit</h2>

<p>When two systems score the same normalized text, total negative log-likelihood remains additive across their token factorizations. It can be divided by a unit outside either tokenizer. For a text containing \(C\) declared characters,</p>

<p>\[
\text{bits per character} = \frac{L}{C\log 2}.
\]</p>

<p>Bits per byte uses the byte length in the denominator instead. Either can support a cross-tokenizer comparison if both systems score the same underlying sequence and the unit is defined identically. Bytes avoid ambiguity about what counts as a Unicode character, while characters can be easier to interpret for a fixed normalization and encoding contract.</p>

<p>The contract matters for Romanian. Precomposed <code class="language-plaintext highlighter-rouge">ț</code> and <code class="language-plaintext highlighter-rouge">t</code> followed by a combining comma can be different code-point counts while representing canonically equivalent text. The evaluation should specify whether \(C\) counts raw code points, NFC-normalized code points, grapheme clusters, or UTF-8 bytes. Applying different normalization inside two tokenizers would mean they are no longer scoring the same input object.</p>

<p>This normalization does not make unlike corpora comparable. A restricted synthetic domain can be more predictable than open-domain prose regardless of the denominator. It also does not turn likelihood into a complete generation-quality measure. It only removes the token count as one source of scale mismatch.</p>

<h2 id="context-policy-changes-the-numerator">Context policy changes the numerator</h2>

<p>The denominator is not the only protocol choice. A finite-context model cannot condition every token on an arbitrarily long prefix. Splitting a corpus into disjoint blocks gives tokens at the start of each block little context and usually increases their loss.</p>

<p>The official perplexity guide recommends a strided sliding window for fixed-length models. Overlapping context is supplied to the model, but only the newly scored targets contribute to the loss. Each target must be counted once. The guide’s GPT-2 example changes from perplexity 19.44 with disjoint 1,024-token chunks to 16.44 with a stride of 512. The model and tokenizer are unchanged; the available context changes the numerator.</p>

<p>Packed training data adds a related boundary choice. The evaluation record should say whether prediction continues across document boundaries, whether an end-of-sequence token separates records, and which first token in each segment is unscored because it has no left context in the batch. These choices alter the set of conditional probabilities included in \(L\).</p>

<p>Corpus aggregation must retain the same weighting rule. If document \(i\) contributes loss \(L_i\) over \(T_i\) valid targets, token-weighted perplexity is</p>

<p>\[
\exp\left(\frac{\sum_i L_i}{\sum_i T_i}\right).
\]</p>

<p>Averaging the per-document losses \(L_i/T_i\) instead gives every document equal weight. The two agree only when the documents have equal scored lengths or happen to have the same mean loss. Batch boundaries should not silently choose the estimand.</p>

<h2 id="report-enough-to-reconstruct-the-fraction">Report enough to reconstruct the fraction</h2>

<p>A token-level perplexity result should preserve at least:</p>

<ul>
  <li>the tokenizer artifact and character-normalization policy;</li>
  <li>the held-out text identity and preprocessing revision;</li>
  <li>total negative log-likelihood and the number of valid target tokens;</li>
  <li>treatment of beginning, end, padding, and ignored tokens;</li>
  <li>context length, stride, packing, and document-boundary policy;</li>
  <li>a shared-unit result such as bits per byte for cross-tokenizer comparisons.</li>
</ul>

<p>The first five fields make the reported token perplexity reproducible. The last separates model likelihood from the tokenizer’s choice of how many prediction steps represent the text.</p>

<p>TF3’s perplexity near 2.43 is a result on its Romanian Unigram scale. It can compare checkpoints and variants that keep that scale fixed. The BPE and Unigram token counts answer a different question about segmentation. Combining the two requires returning to the fraction underneath perplexity: total loss in the numerator, and an explicitly chosen unit in the denominator.</p>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="language-models" /><category term="tokenization" /><category term="evaluation" /><category term="experimental-design" />
    
    
    <summary type="html"><![CDATA[Token-level perplexity changes when the same text is split into a different number of tokens, even if its total likelihood does not.]]></summary>
    
  </entry>
  
  
  
  <entry xml:lang="en">
    
    
    
    <title type="html">NFC Cannot Fix the Wrong Diacritic</title>
    <link href="https://nadas.ro/2026/10/05/nfc-cannot-fix-wrong-diacritic.html" rel="alternate" type="text/html" title="NFC Cannot Fix the Wrong Diacritic" />
    <published>2026-10-05T09:35:34+03:00</published>
    <updated>2026-10-17T13:42:57+03:00</updated>
    <id>https://mihainadas.github.io/2026/10/05/nfc-cannot-fix-wrong-diacritic</id>
    <content type="html" xml:base="https://nadas.ro/2026/10/05/nfc-cannot-fix-wrong-diacritic.html"><![CDATA[<p>Romanian diacritic restoration takes text with missing marks and reconstructs the intended spelling while preserving everything else. Modern Romanian uses <strong>ș</strong> and <strong>ț</strong>, with a comma below. Older software and converted corpora often contain the similar-looking cedilla characters <strong>ş</strong> and <strong>ţ</strong> instead. A system importing customer records or scoring restored text has to decide what to do with those older forms. Should it accept them as the same letters, or flag them because modern Romanian output was required?</p>

<p>Unicode normalization gives equivalent encodings a consistent representation. Its NFC form can compose a base letter and a combining mark into one character. My earlier note on <a href="/2025/09/29/romanian-diacritics-challenge.html">restoration metrics</a> included legacy cedillas among the positions that may need repair, but that repair needs a separate rule. Normalization can reconcile a precomposed <strong>ț</strong> with a sequence made from <code class="language-plaintext highlighter-rouge">t</code> and a combining comma below. It does not change a cedilla into a comma.</p>

<p>This note isolates the boundary with four encodings of <code class="language-plaintext highlighter-rouge">fața</code>, meaning <em>the face</em>. Two use the modern comma below and two use the legacy cedilla. NFC reduces four code-point sequences to two Unicode strings, not one. A Romanian-specific mapping can join the remaining pair, but it must be an explicit language and evaluation policy.</p>

<h2 id="four-spellings-form-two-normalization-groups">Four spellings form two normalization groups</h2>

<p>The visible word can be represented with a precomposed letter or with a base letter followed by a combining mark:</p>

<table>
  <thead>
    <tr>
      <th>Form</th>
      <th>Python literal</th>
      <th>Relevant code points</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>modern, precomposed</td>
      <td><code class="language-plaintext highlighter-rouge">fața</code></td>
      <td><code class="language-plaintext highlighter-rouge">U+021B</code></td>
    </tr>
    <tr>
      <td>modern, decomposed</td>
      <td><code class="language-plaintext highlighter-rouge">fat\u0326a</code></td>
      <td><code class="language-plaintext highlighter-rouge">U+0074 U+0326</code></td>
    </tr>
    <tr>
      <td>legacy, precomposed</td>
      <td><code class="language-plaintext highlighter-rouge">faţa</code></td>
      <td><code class="language-plaintext highlighter-rouge">U+0163</code></td>
    </tr>
    <tr>
      <td>legacy, decomposed</td>
      <td><code class="language-plaintext highlighter-rouge">fat\u0327a</code></td>
      <td><code class="language-plaintext highlighter-rouge">U+0074 U+0327</code></td>
    </tr>
  </tbody>
</table>

<p>U+0326 is COMBINING COMMA BELOW. U+0327 is COMBINING CEDILLA. The distinction can be hard to see in a small font, but it is present in the text.</p>

<p>Python exposes the Unicode normalization algorithm through <a href="https://docs.python.org/3/library/unicodedata.html"><code class="language-plaintext highlighter-rouge">unicodedata.normalize</code></a>. The four cases can be checked directly:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">unicodedata</span> <span class="kn">import</span> <span class="n">normalize</span>


<span class="n">samples</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s">"modern precomposed"</span><span class="p">:</span> <span class="s">"fața"</span><span class="p">,</span>
    <span class="s">"modern decomposed"</span><span class="p">:</span> <span class="s">"fat</span><span class="se">\u0326</span><span class="s">a"</span><span class="p">,</span>
    <span class="s">"legacy precomposed"</span><span class="p">:</span> <span class="s">"faţa"</span><span class="p">,</span>
    <span class="s">"legacy decomposed"</span><span class="p">:</span> <span class="s">"fat</span><span class="se">\u0327</span><span class="s">a"</span><span class="p">,</span>
<span class="p">}</span>

<span class="k">for</span> <span class="n">name</span><span class="p">,</span> <span class="n">value</span> <span class="ow">in</span> <span class="n">samples</span><span class="p">.</span><span class="n">items</span><span class="p">():</span>
    <span class="k">print</span><span class="p">(</span><span class="n">name</span><span class="p">,</span> <span class="nb">repr</span><span class="p">(</span><span class="n">normalize</span><span class="p">(</span><span class="s">"NFC"</span><span class="p">,</span> <span class="n">value</span><span class="p">)))</span>
</code></pre></div></div>

<p>The result is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>modern precomposed 'fața'
modern decomposed  'fața'
legacy precomposed 'faţa'
legacy decomposed  'faţa'
</code></pre></div></div>

<p>NFC makes each group internally consistent. It does not join the groups. NFKC produces the same result for these characters.</p>

<h2 id="the-decomposition-table-explains-the-result">The decomposition table explains the result</h2>

<p><a href="https://www.unicode.org/reports/tr15/">Unicode Standard Annex #15</a> defines NFC as canonical decomposition followed by canonical composition. NFKC starts from compatibility decomposition instead. Neither operation is a visual-similarity rule or a language-specific spelling corrector.</p>

<p>The decisive data is in the Unicode Character Database. Its current <a href="https://www.unicode.org/Public/UCD/latest/ucd/UnicodeData.txt"><code class="language-plaintext highlighter-rouge">UnicodeData.txt</code></a> gives these canonical decompositions:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>U+021B LATIN SMALL LETTER T WITH COMMA BELOW
    -&gt; U+0074 LATIN SMALL LETTER T
     + U+0326 COMBINING COMMA BELOW

U+0163 LATIN SMALL LETTER T WITH CEDILLA
    -&gt; U+0074 LATIN SMALL LETTER T
     + U+0327 COMBINING CEDILLA
</code></pre></div></div>

<p>The base letter is the same. The combining marks are not. There is no canonical or compatibility decomposition from the cedilla form to the comma-below form, so none of the four normalization forms is allowed to invent one.</p>

<p>This is a useful constraint. Normalization has stable, language-independent semantics. If it silently changed one mark into another because a particular language treats them as historical variants, it could corrupt text in a language where the cedilla is the intended character.</p>

<h2 id="romanian-equivalence-is-a-higher-level-rule">Romanian equivalence is a higher-level rule</h2>

<p>The <a href="https://unicode.org/versions/Unicode18.0.0/core-spec/chapter-7/">Unicode Standard’s discussion of Romanian</a> prefers U+0219 <strong>ș</strong> for modern Romanian and identifies U+015F <strong>ş</strong> as the Turkish s with cedilla. It also records the practical complication: legacy Romanian data often contains the cedilla code point and Romanian processing should treat the two forms as equivalent.</p>

<p>Those statements describe a language-aware operation above Unicode normalization. A compact implementation is:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">unicodedata</span>


<span class="n">ROMANIAN_LEGACY</span> <span class="o">=</span> <span class="nb">str</span><span class="p">.</span><span class="n">maketrans</span><span class="p">({</span>
    <span class="s">"Ş"</span><span class="p">:</span> <span class="s">"Ș"</span><span class="p">,</span>
    <span class="s">"ş"</span><span class="p">:</span> <span class="s">"ș"</span><span class="p">,</span>
    <span class="s">"Ţ"</span><span class="p">:</span> <span class="s">"Ț"</span><span class="p">,</span>
    <span class="s">"ţ"</span><span class="p">:</span> <span class="s">"ț"</span><span class="p">,</span>
<span class="p">})</span>


<span class="k">def</span> <span class="nf">normalize_romanian</span><span class="p">(</span><span class="n">text</span><span class="p">):</span>
    <span class="n">composed</span> <span class="o">=</span> <span class="n">unicodedata</span><span class="p">.</span><span class="n">normalize</span><span class="p">(</span><span class="s">"NFC"</span><span class="p">,</span> <span class="n">text</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">composed</span><span class="p">.</span><span class="n">translate</span><span class="p">(</span><span class="n">ROMANIAN_LEGACY</span><span class="p">)</span>
</code></pre></div></div>

<p>The order matters. NFC first turns <code class="language-plaintext highlighter-rouge">s</code> plus U+0327 into precomposed U+015F, so the explicit table handles precomposed and decomposed legacy input with the same four entries. A final NFC call would be harmless here but is unnecessary because every replacement is already precomposed.</p>

<p>The function must not be presented as general Unicode cleanup. U+015F is valid Turkish text. Mapping it unconditionally in a multilingual corpus would replace the intended Turkish cedilla with a Romanian comma below. The input’s declared language, field, or corpus contract must authorize the mapping.</p>

<p>Byte decoding belongs before both steps. If an ISO-8859-2 byte stream was decoded with the wrong character encoding, Unicode normalization cannot reconstruct the lost byte interpretation. A replacement character such as U+FFFD is evidence that the pipeline crossed that boundary without enough information.</p>

<h2 id="measure-spelling-and-output-format-separately">Measure spelling and output format separately</h2>

<p>Suppose the reference contains <code class="language-plaintext highlighter-rouge">fața știe</code>, while a system returns <code class="language-plaintext highlighter-rouge">faţa ştie</code>. Three comparisons answer three different questions:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">reference</span> <span class="o">=</span> <span class="s">"fața știe"</span>
<span class="n">prediction</span> <span class="o">=</span> <span class="s">"faţa ştie"</span>

<span class="n">raw_equal</span> <span class="o">=</span> <span class="n">prediction</span> <span class="o">==</span> <span class="n">reference</span>
<span class="n">nfc_equal</span> <span class="o">=</span> <span class="p">(</span>
    <span class="n">unicodedata</span><span class="p">.</span><span class="n">normalize</span><span class="p">(</span><span class="s">"NFC"</span><span class="p">,</span> <span class="n">prediction</span><span class="p">)</span>
    <span class="o">==</span> <span class="n">unicodedata</span><span class="p">.</span><span class="n">normalize</span><span class="p">(</span><span class="s">"NFC"</span><span class="p">,</span> <span class="n">reference</span><span class="p">)</span>
<span class="p">)</span>
<span class="n">romanian_equal</span> <span class="o">=</span> <span class="p">(</span>
    <span class="n">normalize_romanian</span><span class="p">(</span><span class="n">prediction</span><span class="p">)</span>
    <span class="o">==</span> <span class="n">normalize_romanian</span><span class="p">(</span><span class="n">reference</span><span class="p">)</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The values are <code class="language-plaintext highlighter-rouge">False</code>, <code class="language-plaintext highlighter-rouge">False</code>, and <code class="language-plaintext highlighter-rouge">True</code>. The raw comparison is sensitive to every code-point choice. NFC removes accidental composition differences but still requires the correct mark. The Romanian fold measures the intended letters while accepting a declared legacy representation.</p>

<p>For a restoration benchmark, I would keep at least two views:</p>

<ul>
  <li>a content score after NFC and the declared Romanian legacy fold;</li>
  <li>an output-contract score after NFC but before the legacy fold.</li>
</ul>

<p>The first avoids treating old corpus encoding practice as a language error. The second detects a system that keeps emitting legacy code points when the delivery contract requires modern Romanian text. Reporting only the folded score would hide that integration defect. Reporting only the strict score could turn a historical encoding convention into an apparent restoration failure.</p>

<p>The raw form should remain available for provenance and debugging. Dataset hashes, import logs, and audit samples need to show whether the source contained decomposed marks, cedillas, or already-modern text. Destructive normalization at ingestion can otherwise make a later metric impossible to interpret.</p>

<h2 id="test-the-distinctions-not-only-the-function">Test the distinctions, not only the function</h2>

<p>A small regression table is enough to protect the policy:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>modern precomposed   -&gt; modern precomposed
modern decomposed    -&gt; modern precomposed
legacy precomposed   -&gt; modern precomposed, Romanian fields only
legacy decomposed    -&gt; modern precomposed, Romanian fields only
Turkish s-cedilla    -&gt; unchanged outside Romanian fields
invalid byte decode  -&gt; rejected or recorded, never guessed
</code></pre></div></div>

<p>The test should cover uppercase <strong>Ș/Ț</strong>, lowercase <strong>ș/ț</strong>, both combining marks, and mixed strings. It should also assert that unrelated characters and string length at the grapheme level are preserved. A restoration pipeline that changes punctuation, URLs, or names has a different failure from one that chooses the wrong Romanian mark.</p>

<p>For a Romanian-only import, the sequence is small: decode correctly, apply NFC, then apply the declared legacy-character mapping. For a multilingual field, keep the language boundary explicit. Two letters that look almost identical are enough to make an otherwise reasonable cleanup rule change the wrong text.</p>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="romanian-nlp" /><category term="unicode" /><category term="evaluation" /><category term="data-quality" />
    
    
    <summary type="html"><![CDATA[Unicode normalization joins composed and decomposed Romanian letters, but legacy cedillas require a separate, language-aware policy.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">Three Judge Calls Are Still One Item</title>
    <link href="https://nadas.ro/2026/10/02/three-judge-calls-are-still-one-item.html" rel="alternate" type="text/html" title="Three Judge Calls Are Still One Item" />
    <published>2026-10-02T09:34:00+03:00</published>
    <updated>2026-10-02T09:34:00+03:00</updated>
    <id>https://mihainadas.github.io/2026/10/02/three-judge-calls-are-still-one-item</id>
    <content type="html" xml:base="https://nadas.ro/2026/10/02/three-judge-calls-are-still-one-item.html"><![CDATA[<p>TinyFabulist compares story generators and translation systems on held-out items. A model judge may score the same output more than once to reveal sampling instability, presentation-order effects, or invalid responses. The resulting table can contain several rows for one item, one system, and one judge configuration.</p>

<p>My earlier note on <a href="/2026/05/02/interval-belongs-to-comparison.html">paired and independent bootstrap designs</a> treated each held-out item as one observation. Repeated judge calls add another level. They measure how the evaluator varies while the text, requested task, and item difficulty remain fixed. They do not add new stories or translations to the test set.</p>

<p>This note isolates the consequence with four items and three calls per item. The example is deliberately small and contains no TinyFabulist result. It shows why resampling twelve score rows can report much less uncertainty than resampling the four item identities that generated them.</p>

<h2 id="twelve-rows-can-contain-four-independent-units">Twelve rows can contain four independent units</h2>

<p>Suppose two systems are scored on the same four items. Subtract system B’s score from system A’s score for each judge call. To make the dependence visible, let all three calls for an item return the same difference:</p>

<table>
  <thead>
    <tr>
      <th>Item</th>
      <th style="text-align: right">Call 1</th>
      <th style="text-align: right">Call 2</th>
      <th style="text-align: right">Call 3</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td style="text-align: right">-1</td>
      <td style="text-align: right">-1</td>
      <td style="text-align: right">-1</td>
    </tr>
    <tr>
      <td>2</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
      <td style="text-align: right">0</td>
    </tr>
    <tr>
      <td>3</td>
      <td style="text-align: right">1</td>
      <td style="text-align: right">1</td>
      <td style="text-align: right">1</td>
    </tr>
    <tr>
      <td>4</td>
      <td style="text-align: right">4</td>
      <td style="text-align: right">4</td>
      <td style="text-align: right">4</td>
    </tr>
  </tbody>
</table>

<p>The mean difference is 1.0 whether it is computed over four item means or twelve rows. The point estimate does not expose the problem.</p>

<p>A row bootstrap draws twelve values independently from the twelve cells. It can draw the first copy of item 4 without the other two, as if those copies described different sampled texts. An item bootstrap draws four item identities. When item 4 is selected, its three calls travel together because they share the same experimental unit.</p>

<p>Enumerating both empirical bootstrap distributions gives:</p>

<table>
  <thead>
    <tr>
      <th>Resampling unit</th>
      <th style="text-align: right">Units drawn</th>
      <th style="text-align: right">Bootstrap standard error</th>
      <th style="text-align: right">95% discrete percentile interval</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>score row</td>
      <td style="text-align: right">12</td>
      <td style="text-align: right">0.540</td>
      <td style="text-align: right">[0.000, 2.083]</td>
    </tr>
    <tr>
      <td>item</td>
      <td style="text-align: right">4</td>
      <td style="text-align: right">0.935</td>
      <td style="text-align: right">[-0.500, 3.000]</td>
    </tr>
  </tbody>
</table>

<p>The row analysis reduces the standard error by a factor of about \(\sqrt{3}\), exactly what would happen if three perfect copies had tripled the sample size. They did not. The narrower interval is manufactured by the independence assumption.</p>

<p>This is a compact instance of <a href="https://doi.org/10.2307/1942661">pseudoreplication</a>: repeated measurements are analyzed as independent replicates even though they belong to the same experimental unit. Stochastic judge outputs make the copies less obvious, but they do not remove the shared item.</p>

<p>Four items are not enough for a defensible system comparison, and these percentile bounds are not proposed as a small-sample remedy. The construction only holds the point estimate fixed while exposing what the resampling unit changes.</p>

<h2 id="pairing-and-clustering-preserve-different-relations">Pairing and clustering preserve different relations</h2>

<p>Pairing answers the system-comparison question. If systems A and B translated the same source sentence, their scores for that sentence must stay together. A difficult sentence can lower both scores, and the paired difference removes part of that shared item effect.</p>

<p>Clustering answers the repetition question. If one sentence was judged three times, those calls remain attached to that sentence when items are resampled. Flattening the table and preserving only the A/B row alignment keeps the system pair but loses the item cluster.</p>

<p>SciPy’s <a href="https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.bootstrap.html"><code class="language-plaintext highlighter-rouge">bootstrap</code> documentation</a> makes the first operation explicit: with <code class="language-plaintext highlighter-rouge">paired=True</code>, it draws one index array and applies it to every input sample. That is sufficient when each index names one item. It does not infer clusters from repeated item identifiers in a long table.</p>

<p>For balanced repeats, the simplest item-level estimator is:</p>

<p>\[
d_i = \frac{1}{R}\sum_{r=1}^{R}(s_{Air} - s_{Bir}),
\qquad
\hat{\Delta} = \frac{1}{N}\sum_{i=1}^{N} d_i.
\]</p>

<p>Compute one mean difference \(d_i\) per item, then bootstrap the \(N\) item differences. Every item receives equal weight. Three calls refine the estimate for item \(i\); they do not change \(N\).</p>

<h2 id="the-target-determines-whether-calls-are-resampled">The target determines whether calls are resampled</h2>

<p>Collapsing to item means treats the observed calls as the measurement used for each item and estimates variation across new items under that fixed procedure. That often matches the question behind a held-out evaluation: how stable is the mean system gap over another sample of comparable items?</p>

<p>If the target also includes fresh stochastic judge calls, a two-level bootstrap can represent both sources. First sample item identities with replacement. Within each selected item, sample its judge calls with replacement. Recompute the per-item mean and then the system gap. The <a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC7906290/">hierarchical-bootstrap paper by Saravanan, Berman, and Sober</a> describes this resampling pattern for nested measurements and publishes the <a href="https://github.com/soberlab/Hierarchical-Bootstrap-Paper">simulation code</a> used to study it.</p>

<p>Those procedures answer different questions. The item-only bootstrap conditions on the observed measurement procedure. The two-level bootstrap includes its finite-call variation. Neither procedure turns judge calls into additional item coverage.</p>

<p>The design becomes more complicated when every item is scored by every member of a judge panel. Items and judges are then crossed rather than purely nested. Whether judges should be resampled depends on whether the named checkpoints are fixed instruments or a sample from a wider population of possible judges. A generic hierarchical loop cannot decide that estimand.</p>

<h2 id="unequal-repeats-create-an-accidental-weighting-rule">Unequal repeats create an accidental weighting rule</h2>

<p>Evaluation tables rarely stay balanced. One item may need a retry after malformed JSON, another may time out, and a third may receive extra calls during an audit. Averaging every surviving row gives more weight to items with more valid responses.</p>

<p>That weighting might be intended, but it should not arise from failure handling. An invalid response followed by a successful retry is one requested measurement with two linked attempts, not two votes. If each held-out item should count equally, aggregate according to a declared retry rule and compute one item contribution before resampling.</p>

<p>The minimum record needs enough structure to reconstruct that choice:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>item_id, system_id, judge_id, judge_digest,
prompt_version, run_id, seed, presentation_order,
attempt_id, retry_of, parse_status, score
</code></pre></div></div>

<p>Without <code class="language-plaintext highlighter-rouge">item_id</code>, cluster membership is gone. Without <code class="language-plaintext highlighter-rouge">attempt_id</code> and <code class="language-plaintext highlighter-rouge">retry_of</code>, a transport failure can silently change statistical weight. Without the judge and prompt versions, repeated calls may combine different instruments under one label.</p>

<h2 id="spend-repetitions-on-the-uncertainty-they-can-reduce">Spend repetitions on the uncertainty they can reduce</h2>

<p>Additional judge calls can estimate within-item instability. They can reveal a position-sensitive comparison, quantify invalid-output rates, or reduce noise in an item’s aggregated score. They cannot reveal how the systems behave on an unseen kind of story or translation.</p>

<p>For a fixed evaluation budget, one more call on an existing item and one call on a new item therefore buy different evidence. The first measures the evaluator more precisely at a known point. The second expands item coverage. A confidence interval should preserve that distinction by resampling the identity that was actually sampled.</p>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="evaluation" /><category term="statistics" /><category term="language-models" /><category term="experimental-design" />
    
    
    <summary type="html"><![CDATA[Repeated judge calls measure within-item variation. They do not turn one held-out item into several independent test cases.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">Count the Raster After Decoding</title>
    <link href="https://nadas.ro/2026/09/30/count-the-raster-after-decoding.html" rel="alternate" type="text/html" title="Count the Raster After Decoding" />
    <published>2026-09-30T09:35:35+03:00</published>
    <updated>2026-09-30T09:35:35+03:00</updated>
    <id>https://mihainadas.github.io/2026/09/30/count-the-raster-after-decoding</id>
    <content type="html" xml:base="https://nadas.ro/2026/09/30/count-the-raster-after-decoding.html"><![CDATA[<p>86Box emulates historical PC hardware and several peripherals, including printers that accept Epson’s ESC/P 2 control language. A guest printer driver sends commands and data as one byte stream. The emulator has to decide which bytes are command parameters, which are raster pixels, and where normal command parsing resumes.</p>

<p>The <code class="language-plaintext highlighter-rouge">ESC .</code> Print Raster Graphics command makes that boundary less obvious than a fixed-size record. Its six-byte header declares a compression mode, horizontal and vertical densities, a band height, and a width in dots. In uncompressed mode, those fields determine the number of payload bytes directly. In run-length encoded mode, the stream carries counters whose encoded length varies with the image.</p>

<p>An earlier <a href="/2026/08/27/three-emulator-bugs.html">86Box debugging note</a> recorded the integration result: after the command was implemented, a Windows 95 Epson LQ-2500 test page changed from seven malformed PNG files and an emulator termination to one complete page. This note examines the parser invariant behind that result. The command ends when it has produced the declared raster rectangle, not after a fixed number of compressed input bytes.</p>

<h2 id="the-header-describes-the-decoded-image">The header describes the decoded image</h2>

<p>Epson’s <a href="https://files.support.epson.com/pdf/general/escp2ref.pdf#page=179">ESC/P Reference Manual</a> gives the command in this form:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ESC . c v h m nL nH d1 d2 ... dk
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">c</code> byte selects uncompressed data (<code class="language-plaintext highlighter-rouge">0</code>) or run-length encoding (<code class="language-plaintext highlighter-rouge">1</code>). The <code class="language-plaintext highlighter-rouge">v</code> and <code class="language-plaintext highlighter-rouge">h</code> bytes select vertical and horizontal density as <code class="language-plaintext highlighter-rouge">3600 / value</code> dots per inch. The band height <code class="language-plaintext highlighter-rouge">m</code> is 1, 8, or 24 dot rows. The two little-endian bytes <code class="language-plaintext highlighter-rouge">nL</code> and <code class="language-plaintext highlighter-rouge">nH</code> give the width in dots:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>width = nL + 256 * nH
</code></pre></div></div>

<p>Pixels are packed most-significant bit first. A row therefore occupies the ceiling of the width divided by eight, and the complete decoded payload size is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bytes_per_row = (width + 7) / 8
decoded_bytes = m * bytes_per_row
</code></pre></div></div>

<p>Integer division is intentional in the first expression. A 13-dot row still needs two bytes. The final three bits belong to padding rather than pixels. An eight-row band at that width contains 16 decoded bytes regardless of how well or poorly it compresses.</p>

<p>The <a href="https://github.com/86Box/86Box/blob/0b09291183131b12f85c90f226aeed917c81ec5d/src/printer/prt_escp.c#L770-L803">merged 86Box setup code</a> turns the header into this state. It validates the compression mode, densities, band height, and nonzero width; computes the row and band byte counts; records the current print position as the origin; and activates raster handling.</p>

<p>This is the first useful separation in the parser. The header defines an output rectangle. Compression only defines how subsequent input bytes reconstruct it.</p>

<h2 id="rle-changes-the-transport-length">RLE changes the transport length</h2>

<p>The manual defines two kinds of RLE counter. Values from 0 through 127 mean that the following <code class="language-plaintext highlighter-rouge">counter + 1</code> bytes are literals. Values from 128 through 255 mean that the next single byte must be repeated <code class="language-plaintext highlighter-rouge">257 - counter</code> times.</p>

<p>For the 16-byte band above, this six-byte compressed payload produces the required decoded length:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>02 80 00 FF F4 00
</code></pre></div></div>

<p>The first counter, <code class="language-plaintext highlighter-rouge">02</code>, copies the following three bytes literally: <code class="language-plaintext highlighter-rouge">80 00 FF</code>. The second counter, <code class="language-plaintext highlighter-rouge">F4</code> or 244, repeats <code class="language-plaintext highlighter-rouge">00</code> thirteen times. Three literal bytes plus thirteen repeated bytes fill the band.</p>

<p>The same raster could require seventeen encoded bytes if it were sent as one literal run: a counter of 15 followed by all sixteen data bytes. Nothing in the six-byte command header declares either encoded length. Counting bytes received would make the command boundary depend on the compression ratio, which the parser does not know in advance.</p>

<p>The counter rule also deserves a literal implementation. A byte value of 128 requests 129 copies because <code class="language-plaintext highlighter-rouge">257 - 128 = 129</code>. Treating 128 as a no-operation would silently substitute the rule from a different run-length format. The protocol’s arithmetic is a better specification than a familiar compression name.</p>

<h2 id="three-states-preserve-the-boundary">Three states preserve the boundary</h2>

<p>86Box consumes printer data one byte at a time. Its <a href="https://github.com/86Box/86Box/blob/0b09291183131b12f85c90f226aeed917c81ec5d/src/printer/prt_escp.c#L2013-L2051">raster decoder</a> uses three states:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>COUNTER -&gt; LITERAL -&gt; COUNTER
        -&gt; REPEAT  -&gt; COUNTER
</code></pre></div></div>

<p>In <code class="language-plaintext highlighter-rouge">COUNTER</code>, one input byte selects a run type and length. In <code class="language-plaintext highlighter-rouge">LITERAL</code>, each input byte emits one decoded byte until that run is exhausted. In <code class="language-plaintext highlighter-rouge">REPEAT</code>, one input byte can emit as many as 129 decoded bytes. Uncompressed mode bypasses the RLE states and emits each input byte directly.</p>

<p>The invariant is independent of those paths:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0 &lt;= decoded_so_far &lt;= decoded_bytes
</code></pre></div></div>

<p>Every emitted byte increments <code class="language-plaintext highlighter-rouge">decoded_so_far</code>. When it reaches the band size computed from the header, the implementation advances the horizontal print position by <code class="language-plaintext highlighter-rouge">width / horizontal_density</code> and deactivates raster handling. The next input byte returns to the ordinary ESC/P interpreter.</p>

<p>This design makes compressed input a producer of raster bytes rather than a second command-framing scheme. Literal runs, repeat runs, and uncompressed bytes all meet at one output counter. The command boundary has one owner.</p>

<p>It also identifies the malformed-stream policy that needs an explicit test. A valid RLE sequence must produce exactly the declared rectangle. A counter that promises more output than remains must not be allowed to draw outside the band, and an incomplete stream must not be mistaken for a complete command. The merged change validates header fields and stops rendering at the declared output count. It does not claim a general recovery mechanism for every truncated or overlong printer stream.</p>

<h2 id="decoded-order-determines-pixel-position">Decoded order determines pixel position</h2>

<p>The output counter carries enough information to place a byte without retaining the whole band. The <a href="https://github.com/86Box/86Box/blob/0b09291183131b12f85c90f226aeed917c81ec5d/src/printer/prt_escp.c#L1961-L2011">rendering function</a> derives a row and byte offset as follows:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>row    = decoded_so_far / bytes_per_row
byte_x = decoded_so_far % bytes_per_row
dot_x  = byte_x * 8 + bit
</code></pre></div></div>

<p>Bits are tested from <code class="language-plaintext highlighter-rouge">0x80</code> down to <code class="language-plaintext highlighter-rouge">0x01</code>, so the leftmost pixel in a byte is emitted first. The <code class="language-plaintext highlighter-rouge">dot_x &lt; width</code> check discards padding bits in the last byte of a non-byte-aligned row. Horizontal and vertical densities then map the dot coordinates to positions on the emulator’s page bitmap.</p>

<p>This mapping depends only on decoded order. It gives identical pixel positions when the input is uncompressed, split across several literal runs, or expressed with repeat counters. It also prevents input packet boundaries from becoming image boundaries. Whether the emulated parallel port delivers the six bytes above continuously or with pauses does not change the reconstructed sequence.</p>

<p>The current print position moves only after the complete band. Advancing it for every compressed byte would produce different layout for two encodings of the same pixels. Again, decoded geometry owns the visible result.</p>

<h2 id="test-framing-before-judging-the-page">Test framing before judging the page</h2>

<p>The Windows 95 test page remains useful. It exercises a real driver, color selection, raster graphics, text, page completion, and the transition back from image data to later commands. One correct page establishes that the reproduced job crosses those subsystem boundaries successfully.</p>

<p>A smaller parser test should make the framing claim direct. For a 13-by-8 band, it can feed 16 known decoded bytes through both uncompressed mode and several RLE partitions, then compare the resulting pixels and final print position. The test should place another ESC/P command immediately after the raster payload and verify that it is parsed as a command rather than consumed as image data.</p>

<p>Boundary cases add information that a screenshot cannot:</p>

<ul>
  <li>a width not divisible by eight, with set padding bits that must not draw;</li>
  <li>literal and repeat counters at both ends of their ranges, including counter 128;</li>
  <li>a run ending exactly on the final decoded byte;</li>
  <li>declared output that is shorter or longer than the supplied RLE expansion;</li>
  <li>the same decoded bytes divided into different legal runs.</li>
</ul>

<p>The last case is the core equivalence test. If two byte streams decode to the same rectangle, compression choices must not change the page or the parser state that follows it.</p>

<p>The <a href="https://github.com/86Box/86Box/pull/7774">merged pull request</a> repaired one concrete Windows 95 printing path and documented the page-level result. The deeper contract is smaller: derive the output length from the raster geometry, count bytes after decompression, and return control to the command interpreter at that exact boundary. A streaming parser stays synchronized when it measures the object the protocol actually declared.</p>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="systems" /><category term="emulation" /><category term="printers" /><category term="parsing" />
    
    
    <summary type="html"><![CDATA[An ESC/P 2 raster command declares its decoded image size, so a streaming emulator must frame compressed input by output bytes.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">A Ranking Metric Cannot Choose a Threshold</title>
    <link href="https://nadas.ro/2026/09/28/ranking-metric-cannot-choose-threshold.html" rel="alternate" type="text/html" title="A Ranking Metric Cannot Choose a Threshold" />
    <published>2026-09-28T09:35:13+03:00</published>
    <updated>2026-09-28T09:35:13+03:00</updated>
    <id>https://mihainadas.github.io/2026/09/28/ranking-metric-cannot-choose-threshold</id>
    <content type="html" xml:base="https://nadas.ro/2026/09/28/ranking-metric-cannot-choose-threshold.html"><![CDATA[<p>A binary classifier performs two separable jobs. It assigns each case a score, which orders cases by evidence for the positive class, and an operating rule converts that score into an action. My earlier <a href="/2025/07/26/logistic-regression-baseline.html">logistic-regression baseline</a> used the Breast Cancer Wisconsin diagnostic dataset, treated malignant cases as the positive class, and reported ROC AUC alongside precision and recall.</p>

<p>Those numbers answer different questions. ROC AUC evaluates how well scores rank malignant cases above benign cases across possible cutoffs. Recall and precision describe the labels produced at one cutoff. A model can keep exactly the same ranking while a changed score scale moves many cases across the default cutoff.</p>

<p>This note isolates that distinction with six labeled cases. No medical claim follows from the toy sample. Its purpose is to show what a ranking metric preserves, what it discards, and why threshold selection belongs in the experimental protocol rather than in a default call to <code class="language-plaintext highlighter-rouge">predict</code>.</p>

<h2 id="auc-keeps-the-order">AUC keeps the order</h2>

<p>Consider three negative and three positive cases with these scores:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">numpy</span> <span class="k">as</span> <span class="n">np</span>
<span class="kn">from</span> <span class="nn">sklearn.metrics</span> <span class="kn">import</span> <span class="n">confusion_matrix</span><span class="p">,</span> <span class="n">roc_auc_score</span>


<span class="n">labels</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">array</span><span class="p">([</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">])</span>
<span class="n">scores</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">array</span><span class="p">([</span><span class="mf">0.10</span><span class="p">,</span> <span class="mf">0.40</span><span class="p">,</span> <span class="mf">0.60</span><span class="p">,</span> <span class="mf">0.55</span><span class="p">,</span> <span class="mf">0.70</span><span class="p">,</span> <span class="mf">0.90</span><span class="p">])</span>
<span class="n">shifted</span> <span class="o">=</span> <span class="n">scores</span><span class="o">**</span><span class="mi">3</span>

<span class="k">print</span><span class="p">(</span><span class="n">roc_auc_score</span><span class="p">(</span><span class="n">labels</span><span class="p">,</span> <span class="n">scores</span><span class="p">))</span>
<span class="k">print</span><span class="p">(</span><span class="n">roc_auc_score</span><span class="p">(</span><span class="n">labels</span><span class="p">,</span> <span class="n">shifted</span><span class="p">))</span>
</code></pre></div></div>

<p>Both calls return (8/9), or approximately 0.889. There are nine positive-negative pairs. The positive case scored at 0.55 ranks above two negatives, while the positive cases at 0.70 and 0.90 each rank above all three. Eight pairs are ordered correctly.</p>

<p>Cubing every score is strictly increasing on this interval, so it preserves every pairwise order. The <a href="https://scikit-learn.org/stable/modules/generated/sklearn.metrics.roc_auc_score.html"><code class="language-plaintext highlighter-rouge">roc_auc_score</code> documentation</a> accepts either probability estimates or non-thresholded decision values because AUC concerns this ordering rather than one class cutoff.</p>

<p>The labels at a cutoff of 0.5 do not survive the transformation:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="n">name</span><span class="p">,</span> <span class="n">values</span> <span class="ow">in</span> <span class="p">((</span><span class="s">"original"</span><span class="p">,</span> <span class="n">scores</span><span class="p">),</span> <span class="p">(</span><span class="s">"cubed"</span><span class="p">,</span> <span class="n">shifted</span><span class="p">)):</span>
    <span class="n">predicted</span> <span class="o">=</span> <span class="p">(</span><span class="n">values</span> <span class="o">&gt;=</span> <span class="mf">0.5</span><span class="p">).</span><span class="n">astype</span><span class="p">(</span><span class="nb">int</span><span class="p">)</span>
    <span class="n">tn</span><span class="p">,</span> <span class="n">fp</span><span class="p">,</span> <span class="n">fn</span><span class="p">,</span> <span class="n">tp</span> <span class="o">=</span> <span class="n">confusion_matrix</span><span class="p">(</span><span class="n">labels</span><span class="p">,</span> <span class="n">predicted</span><span class="p">).</span><span class="n">ravel</span><span class="p">()</span>
    <span class="k">print</span><span class="p">(</span><span class="n">name</span><span class="p">,</span> <span class="p">{</span><span class="s">"tp"</span><span class="p">:</span> <span class="n">tp</span><span class="p">,</span> <span class="s">"fp"</span><span class="p">:</span> <span class="n">fp</span><span class="p">,</span> <span class="s">"fn"</span><span class="p">:</span> <span class="n">fn</span><span class="p">,</span> <span class="s">"tn"</span><span class="p">:</span> <span class="n">tn</span><span class="p">})</span>
</code></pre></div></div>

<p>The original scores produce three true positives and one false positive. The cubed scores produce one true positive, no false positives, and two false negatives. Their AUC values are identical. AUC does not contain a preferred numeric origin or scale from which 0.5 could be recovered.</p>

<p>This is not a criticism of AUC. It is the property that lets AUC compare ranking performance from probability estimates and raw decision scores. The mistake is asking that ranking summary to specify an operating decision.</p>

<h2 id="a-threshold-contains-a-cost-judgment">A threshold contains a cost judgment</h2>

<p>A cutoff determines which errors the system accepts. On the original six scores, three candidate cutoffs give:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cutoff 0.50: TP=3, FP=1, FN=0, TN=2
cutoff 0.65: TP=2, FP=0, FN=1, TN=3
cutoff 0.85: TP=1, FP=0, FN=2, TN=3
</code></pre></div></div>

<p>Suppose, only for arithmetic, that one missed positive costs five units and one false alarm costs one. The three error costs are 1, 5, and 10. Reverse that ratio, so that a false alarm costs five units and a miss costs one, and the costs become 5, 1, and 2. The score order and AUC did not change. The preferred cutoff among these candidates did.</p>

<p>Real decisions require a defensible objective rather than invented unit costs. The relevant quantities might be review capacity, the consequence of a missed case, a minimum recall constraint, or the measured utility of downstream actions. Scikit-learn’s <a href="https://scikit-learn.org/stable/modules/classification_threshold.html">threshold-tuning guide</a> makes the software boundary explicit: <code class="language-plaintext highlighter-rouge">predict_proba</code> or <code class="language-plaintext highlighter-rouge">decision_function</code> produces scores, while a threshold converts them to labels. Its usual 0.5 probability cutoff and zero decision-score cutoff are defaults, not application requirements.</p>

<p>Precision and recall must therefore be attached to the selected rule. Moving the cutoff changes both even when the model and every score remain fixed. Reporting “the model has 0.98 recall” without naming the positive class, cutoff, and evaluation set omits part of the classifier being evaluated.</p>

<h2 id="calibration-is-a-separate-claim">Calibration is a separate claim</h2>

<p>The cubed values above remain useful scores for ranking, but calling them probabilities would require evidence. A calibrated probability has a frequency interpretation: among cases assigned a value near 0.8, roughly 80% should belong to the positive class. Scikit-learn’s <a href="https://scikit-learn.org/stable/modules/calibration.html">calibration guide</a> tests this relation with reliability diagrams that compare mean predicted probability with observed positive frequency in bins.</p>

<p>A strictly increasing transformation can preserve AUC while changing calibration. That gives two independent questions:</p>

<ul>
  <li>Does the model rank positive cases ahead of negative cases?</li>
  <li>Do its numeric outputs support a probability interpretation?</li>
</ul>

<p>The second matters when a decision rule uses estimated risk, expected cost, or a probability communicated to a person. It is less necessary when a validated rule simply reviews the highest-scoring fixed number of cases, though that rule still needs evaluation under the deployment distribution.</p>

<p>Logistic regression does not make calibration automatic. The scikit-learn guide notes why logistic regression can be well calibrated when the model is correctly specified and regularization is appropriate. Dataset shift, regularization, feature changes, and a mismatched functional form can still break the frequency interpretation. Calibration has to be measured on data not used to fit the calibrator.</p>

<h2 id="do-not-spend-the-test-set-choosing-the-cutoff">Do not spend the test set choosing the cutoff</h2>

<p>Threshold selection is model selection even when the fitted coefficients never change. Trying many cutoffs on the final test set and keeping the one with the best F1 score adapts the reported result to that test sample.</p>

<p>A clean protocol separates three roles:</p>

<ol>
  <li>Fit preprocessing and model parameters on training data.</li>
  <li>Select or tune the operating rule on validation data or within an explicit cross-validation procedure.</li>
  <li>Evaluate the fixed model and fixed rule once on a held-out test set.</li>
</ol>

<p>Scikit-learn’s <a href="https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TunedThresholdClassifierCV.html"><code class="language-plaintext highlighter-rouge">TunedThresholdClassifierCV</code></a> implements the second role by optimizing a specified binary metric over thresholds using cross-validation. Its documentation also warns against fitting the estimator and selecting its cutoff on the same data. Cross-validation handles the mechanics; it does not choose the right objective or positive label for the application.</p>

<p>The evaluation record should preserve the score source, positive-class mapping, calibration method if any, threshold objective, data used for selection, chosen cutoff, and final confusion counts. Those fields make clear which result belongs to ranking and which belongs to the operating rule.</p>

<h2 id="report-the-score-and-the-rule">Report the score and the rule</h2>

<p>The earlier Wisconsin baseline’s ROC AUC of 0.995 supports a narrow statement about ranking on resamples of that dataset. Its malignant-class recall of 0.98 supports a different statement about one fitted pipeline, label mapping, and cutoff on one holdout split. Neither number determines the other.</p>

<p>A deployable classifier is not just a scoring function. It is a scoring function plus a declared rule for acting on the score. AUC can compare the first part across all cutoffs. Calibration can test whether the score has a probability meaning. The threshold, selected against a stated objective on separate data, completes the decision.</p>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="machine-learning" /><category term="evaluation" /><category term="experimental-design" />
    
    
    <summary type="html"><![CDATA[ROC AUC can stay fixed while calibration and the operating decision change. A classifier report needs all three.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">The Perceptron Convergence Bound Has Units</title>
    <link href="https://nadas.ro/2026/09/25/perceptron-convergence-bound-has-units.html" rel="alternate" type="text/html" title="The Perceptron Convergence Bound Has Units" />
    <published>2026-09-25T09:34:18+03:00</published>
    <updated>2026-09-25T09:34:18+03:00</updated>
    <id>https://mihainadas.github.io/2026/09/25/perceptron-convergence-bound-has-units</id>
    <content type="html" xml:base="https://nadas.ro/2026/09/25/perceptron-convergence-bound-has-units.html"><![CDATA[<p>The perceptron is a binary linear classifier that changes its weights only when it misclassifies an example. My earlier <a href="/2025/01/04/perceptron-you-can-audit.html">auditable perceptron notebook</a> used two numeric features, a bias, and a fixed example order so that every update could be inspected. On its small separable dataset, one complete zero-mistake pass was enough to declare convergence.</p>

<p>The convergence theorem explains why that procedure must eventually stop when a separating hyperplane exists. It does not say that all numeric representations of the same labeled examples take the same route. The bound depends on a radius and a margin measured in the coordinates supplied to the algorithm.</p>

<p>This note isolates that dependence with six points and one controlled change. The labels, example order, update rule, and separating decision boundary stay fixed. Only the unit of the first feature changes. The resulting run needs either 3 updates or 3,049.</p>

<h2 id="the-theorem-counts-mistakes-not-epochs">The theorem counts mistakes, not epochs</h2>

<p>Write each label as \(y_i \in \{-1, +1\}\) and include the bias by augmenting each point:</p>

<p>\[
z_i = (x_{i1}, x_{i2}, 1).
\]</p>

<p>Starting from \(w=0\), the basic update is</p>

<p>\[
w \leftarrow w + y_i z_i
\]</p>

<p>whenever \(y_i(w^Tz_i) \leq 0\). If a unit vector \(v\) separates every point with margin</p>

<p>\[
\gamma = \min_i y_i(v^Tz_i) &gt; 0
\]</p>

<p>and \(R = \max_i \lVert z_i \rVert\), the algorithm makes at most</p>

<p>\[
\left(\frac{R}{\gamma}\right)^2
\]</p>

<p>updates. Novikoff’s <a href="https://cs.uwaterloo.ca/~y328yu/classics/novikoff.pdf">1962 convergence proof</a> isolates the progress argument behind the finite bound. MIT’s <a href="https://openlearninglibrary.mit.edu/courses/course-v1%3AMITx%2B6.036%2B1T2019/jump_to/block-v1%3AMITx%2B6.036%2B1T2019%2Btype%40vertical%2Bblock%40perceptron_theory_of_the_perceptron_vert">perceptron theory notes</a> state the modern \((R/\gamma)^2\) form directly.</p>

<p>The quantity being bounded is the number of mistaken-example updates. An epoch is only one pass through however many examples the implementation presents. A run can complete many epochs but make few updates, or concentrate several updates in one epoch.</p>

<h2 id="six-labels-under-three-rulers">Six labels under three rulers</h2>

<p>Consider these points in this fixed order:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>(-3,  3) -&gt; -1
(-2,  0) -&gt; -1
(-4, -3) -&gt; -1
( 3,  0) -&gt; +1
(-1,  1) -&gt; -1
( 0,  2) -&gt; +1
</code></pre></div></div>

<p>The rule</p>

<p>\[
x_1 + 0.3x_2 - 0.2 = 0
\]</p>

<p>separates them. Now replace the first coordinate with \(x’_1=sx_1\). The equivalent boundary is</p>

<p>\[
\frac{1}{s}x’_1 + 0.3x_2 - 0.2 = 0.
\]</p>

<p>The decision for every point is unchanged for any positive \(s\). The perceptron does not receive the symbolic equivalence, though. It receives vectors whose first coordinate may be one hundred times larger or smaller, and its additive update uses those coordinates literally.</p>

<p>This exact loop, evaluated with rational arithmetic, records the effect:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">fractions</span> <span class="kn">import</span> <span class="n">Fraction</span>


<span class="n">ROWS</span> <span class="o">=</span> <span class="p">[</span>
    <span class="p">(</span><span class="o">-</span><span class="mi">3</span><span class="p">,</span> <span class="mi">3</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">),</span>
    <span class="p">(</span><span class="o">-</span><span class="mi">2</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">),</span>
    <span class="p">(</span><span class="o">-</span><span class="mi">4</span><span class="p">,</span> <span class="o">-</span><span class="mi">3</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">),</span>
    <span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">),</span>
    <span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">),</span>
    <span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">1</span><span class="p">),</span>
<span class="p">]</span>


<span class="k">def</span> <span class="nf">train</span><span class="p">(</span><span class="n">scale</span><span class="p">):</span>
    <span class="n">weights</span> <span class="o">=</span> <span class="p">[</span><span class="n">Fraction</span><span class="p">(</span><span class="mi">0</span><span class="p">),</span> <span class="n">Fraction</span><span class="p">(</span><span class="mi">0</span><span class="p">),</span> <span class="n">Fraction</span><span class="p">(</span><span class="mi">0</span><span class="p">)]</span>
    <span class="n">updates</span> <span class="o">=</span> <span class="mi">0</span>

    <span class="k">for</span> <span class="n">epoch</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">10_000</span><span class="p">):</span>
        <span class="n">mistakes</span> <span class="o">=</span> <span class="mi">0</span>
        <span class="k">for</span> <span class="n">x1</span><span class="p">,</span> <span class="n">x2</span><span class="p">,</span> <span class="n">label</span> <span class="ow">in</span> <span class="n">ROWS</span><span class="p">:</span>
            <span class="n">point</span> <span class="o">=</span> <span class="p">[</span><span class="n">scale</span> <span class="o">*</span> <span class="n">x1</span><span class="p">,</span> <span class="n">Fraction</span><span class="p">(</span><span class="n">x2</span><span class="p">),</span> <span class="n">Fraction</span><span class="p">(</span><span class="mi">1</span><span class="p">)]</span>
            <span class="n">score</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="n">weight</span> <span class="o">*</span> <span class="n">value</span> <span class="k">for</span> <span class="n">weight</span><span class="p">,</span> <span class="n">value</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">weights</span><span class="p">,</span> <span class="n">point</span><span class="p">))</span>
            <span class="k">if</span> <span class="n">label</span> <span class="o">*</span> <span class="n">score</span> <span class="o">&lt;=</span> <span class="mi">0</span><span class="p">:</span>
                <span class="n">weights</span> <span class="o">=</span> <span class="p">[</span>
                    <span class="n">weight</span> <span class="o">+</span> <span class="n">label</span> <span class="o">*</span> <span class="n">value</span>
                    <span class="k">for</span> <span class="n">weight</span><span class="p">,</span> <span class="n">value</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">weights</span><span class="p">,</span> <span class="n">point</span><span class="p">)</span>
                <span class="p">]</span>
                <span class="n">updates</span> <span class="o">+=</span> <span class="mi">1</span>
                <span class="n">mistakes</span> <span class="o">+=</span> <span class="mi">1</span>
        <span class="k">if</span> <span class="n">mistakes</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
            <span class="k">return</span> <span class="n">updates</span><span class="p">,</span> <span class="n">epoch</span><span class="p">,</span> <span class="n">weights</span>


<span class="k">for</span> <span class="n">scale</span> <span class="ow">in</span> <span class="p">(</span><span class="n">Fraction</span><span class="p">(</span><span class="mi">100</span><span class="p">),</span> <span class="n">Fraction</span><span class="p">(</span><span class="mi">1</span><span class="p">),</span> <span class="n">Fraction</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">100</span><span class="p">)):</span>
    <span class="k">print</span><span class="p">(</span><span class="n">scale</span><span class="p">,</span> <span class="n">train</span><span class="p">(</span><span class="n">scale</span><span class="p">))</span>
</code></pre></div></div>

<p>The three returned results are:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>100   -&gt; 3 updates,    3 epochs, weights = (300, 1, 1)
1     -&gt; 3 updates,    3 epochs, weights = (3, 1, 1)
1/100 -&gt; 3,049 updates, 822 epochs, weights = (66.77, 1, -1)
</code></pre></div></div>

<p>All three final weight vectors classify all six transformed points correctly. The slow run is not evidence that the labels became nonseparable. Shrinking the first coordinate made each update in that direction small, while the fixed second coordinate and bias continued to move by whole units.</p>

<h2 id="the-geometry-changes-with-the-representation">The geometry changes with the representation</h2>

<p>The same known boundary provides a valid, though not necessarily optimal, margin for each scale. In transformed coordinates its unnormalized weight vector is \((1/s, 0.3, -0.2)\). Normalizing that vector and measuring the largest augmented point gives:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>s = 100:  R = 400.0125, gamma = 1.108974, bound = 130,109 updates
s =   1:  R =   5.0990, gamma = 0.376288, bound =     184 updates
s = 0.01: R =   3.1625, gamma = 0.004000, bound = 625,109 updates
</code></pre></div></div>

<p>These are upper bounds derived from one separator, not predictions of the observed counts and not claims that this separator has the maximum possible margin. Their looseness is visible at \(s=100\): the bound grows to 130,109 while the run still needs only three updates.</p>

<p>The useful part is the dependency. Rescaling one coordinate changes both the largest vector norm and the normalized margin. Uniformly multiplying every augmented coordinate by the same positive constant would multiply \(R\) and \(\gamma\) together, leaving their ratio unchanged. Changing one feature’s unit changes the Euclidean geometry seen by the update rule.</p>

<p>The theorem also does not promise the same final separator. It promises that some separator will be reached after finitely many mistakes under its assumptions. Example order, feature scaling, tie handling, bias representation, and learning rate can all change the path.</p>

<h2 id="scaling-is-part-of-the-experiment-record">Scaling is part of the experiment record</h2>

<p>For a direct implementation, the input units belong beside the seed and example order. Reporting only that the data were linearly separable omits information needed to reproduce the trace.</p>

<p>For library code, the estimator’s stopping rule must also be distinguished from the theorem. Scikit-learn’s <a href="https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.Perceptron.html"><code class="language-plaintext highlighter-rouge">Perceptron</code></a> exposes <code class="language-plaintext highlighter-rouge">max_iter</code>, <code class="language-plaintext highlighter-rouge">tol</code>, and optional shuffling; its documented iteration count is a count of passes, not the raw mistake bound above.</p>

<p>Standardization is one defensible coordinate choice, not a theorem requirement. Scikit-learn’s <a href="https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.StandardScaler.html"><code class="language-plaintext highlighter-rouge">StandardScaler</code></a> records the training mean and variance used for each feature. Its <a href="https://scikit-learn.org/stable/common_pitfalls.html">preprocessing guidance</a> also gives the operational constraint: learn transformations from the training subset, then apply the same transformation to validation, test, and production inputs.</p>

<p>The six-point construction establishes a narrow result. A change of units can leave every label and separating rule intact while changing a deterministic perceptron run by three orders of magnitude. Convergence is a property of separability; the route to convergence belongs to the representation.</p>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="machine-learning" /><category term="perceptron" /><category term="experimental-design" />
    
    
    <summary type="html"><![CDATA[A linearly separable dataset can require a very different perceptron update path after one feature changes units.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">One Field Is Not a List Operation</title>
    <link href="https://nadas.ro/2026/09/24/one-field-is-not-a-list-operation.html" rel="alternate" type="text/html" title="One Field Is Not a List Operation" />
    <published>2026-09-24T06:33:18+03:00</published>
    <updated>2026-09-24T06:33:18+03:00</updated>
    <id>https://mihainadas.github.io/2026/09/24/one-field-is-not-a-list-operation</id>
    <content type="html" xml:base="https://nadas.ro/2026/09/24/one-field-is-not-a-list-operation.html"><![CDATA[<p>86Box emulates historical PC hardware, including the memory maps and x86 processors used by Windows 95 machines. Its new dynamic recompiler translates guest instructions into host code. When the guest modifies a physical page that contains translated code, the emulator must find and invalidate the stale blocks before running them again.</p>

<p>The recompiler tracks pages needing that work in an intrusive eviction list. Each page record stores its own previous and next indices, while a global index names the list head. A special value in <code class="language-plaintext highlighter-rouge">evict_prev</code> means that the page is not in the list. Those fields describe one relation and have to agree.</p>

<p>A soft restart on an emulated VIA VT82C496G system exposed a state in which they did not. Conventional-memory remapping changed the backing pointers and dirty-code masks of existing page records. The same path also set <code class="language-plaintext highlighter-rouge">evict_prev</code> to the not-in-list value without unlinking the page. The global head could still point to that page, so the next purge reached it and then aborted because its membership test said it was absent. The <a href="https://github.com/86Box/86Box/pull/7787">merged correction</a> deleted the two assignments that created this contradiction.</p>

<h2 id="one-relation-had-three-representations">One relation had three representations</h2>

<p>The relevant page fields are small enough to read as a contract. The <a href="https://github.com/86Box/86Box/blob/5cf728b4a00e72bdf3d24758e336134289aff4b6/src/include/86box/mem.h#L206-L236">page declaration</a> defines <code class="language-plaintext highlighter-rouge">evict_prev</code>, <code class="language-plaintext highlighter-rouge">evict_next</code>, and the <code class="language-plaintext highlighter-rouge">EVICT_NOT_IN_LIST</code> sentinel. Membership is tested only through <code class="language-plaintext highlighter-rouge">evict_prev</code>:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">static</span> <span class="kr">inline</span> <span class="kt">int</span>
<span class="nf">page_in_evict_list</span><span class="p">(</span><span class="n">page_t</span> <span class="o">*</span><span class="n">page</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="n">page</span><span class="o">-&gt;</span><span class="n">evict_prev</span> <span class="o">!=</span> <span class="n">EVICT_NOT_IN_LIST</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That predicate is one representation of membership. The list head is another. The forward and backward links are a third. For a live list, at least these conditions must hold:</p>

<ul>
  <li>the head is zero exactly when the list is empty;</li>
  <li>every page reachable from the head reports that it is in the list;</li>
  <li>adjacent pages agree about their forward and backward links;</li>
  <li>the stored page count equals the number of reachable pages.</li>
</ul>

<p>The <a href="https://github.com/86Box/86Box/blob/5cf728b4a00e72bdf3d24758e336134289aff4b6/src/mem/mem.c#L1858-L1888">list insertion and removal functions</a> update those representations together. Insertion rewires the old head, sets both links on the new page, advances the head, and increments the count. Removal rewires the neighbors and head before writing the sentinel and decrementing the count.</p>

<p>Writing only the sentinel is therefore not a smaller removal. It is a different operation with no valid list state as its result.</p>

<h2 id="the-guard-reported-the-contradiction">The guard reported the contradiction</h2>

<p>The removal function begins with a defensive check:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">page_in_evict_list</span><span class="p">(</span><span class="n">page</span><span class="p">))</span>
    <span class="n">fatal</span><span class="p">(</span><span class="s">"page_remove_from_evict_list: not in evict list!</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>
</code></pre></div></div>

<p>The failure message can suggest an invalid removal request. In this case, the caller had obtained the page through valid list state. The <a href="https://github.com/86Box/86Box/blob/5cf728b4a00e72bdf3d24758e336134289aff4b6/src/codegen_new/codegen_block.c#L164-L176">purge path</a> reads the global head, checks the page’s dirty translated-code mask, and eventually <a href="https://github.com/86Box/86Box/blob/5cf728b4a00e72bdf3d24758e336134289aff4b6/src/codegen_new/codegen_block.c#L491-L504">removes the processed page</a>.</p>

<p>The contradiction was already present before removal began:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>purgable_page_list_head = page_index
pages[page_index].evict_prev = EVICT_NOT_IN_LIST
</code></pre></div></div>

<p>The first line makes the page reachable. The second makes the membership predicate false. The guard converted a silent structural error into a reproducible abort at the next operation that needed the relation to be coherent.</p>

<p>This distinction matters during diagnosis. Weakening the guard would not repair the list. Forcing removal to continue would use a sentinel as a neighbor index and could corrupt more state. The useful question was not why removal rejected the page, but which earlier operation was allowed to produce an impossible page.</p>

<h2 id="remapping-did-not-own-list-membership">Remapping did not own list membership</h2>

<p>The <a href="https://github.com/86Box/86Box/blob/5cf728b4a00e72bdf3d24758e336134289aff4b6/src/mem/mem.c#L2840-L2874">page-table allocation path</a> creates the page array and initializes every <code class="language-plaintext highlighter-rouge">evict_prev</code> field to the sentinel. That is the right time to declare that a newly allocated page is not linked.</p>

<p>Conventional-memory remapping has a narrower job. The <a href="https://github.com/86Box/86Box/blob/5cf728b4a00e72bdf3d24758e336134289aff4b6/src/mem/mem.c#L2940-L3030">remap function</a> reuses existing page records while changing their RAM pointers, write callbacks, and per-byte mask pointers. It does not allocate a new page table. It does not rebuild the eviction list. It therefore cannot safely reinitialize one of the list fields.</p>

<p>The fix removed one assignment from each of the function’s two remapping loops. No replacement list operation was needed because remapping was not supposed to change membership. Preserving the links kept the relation owned by the insertion and removal functions.</p>

<p>Deletion is safe here for a specific reason: the old lines had no independent state transition to express. They partially imitated initialization inside an update path. If remapping had needed to remove pages, the correct repair would have been an explicit unlink operation with defined handling for the list head, neighbors, and count.</p>

<h2 id="test-the-transition-that-exposes-the-invariant">Test the transition that exposes the invariant</h2>

<p>The pull request records a narrow regression path: a Windows 95 guest on the 486-VIP-IO2 machine called <code class="language-plaintext highlighter-rouge">ExitWindowsEx(EWX_REBOOT)</code>. The unmodified debug build aborted during the first guest-initiated restart. With the two assignments removed, three consecutive restarts returned to the Windows network logon dialog.</p>

<p>That test is useful because a soft restart preserves enough emulator state to exercise remapping against an already active recompiler. A cold start initializes the page table and list together, so it is less likely to reach the contradictory state.</p>

<p>The lifecycle test establishes that the reproduced restart no longer triggers the fatal removal check. It does not prove every remapping path or every machine configuration correct. A debug-only structural checker could make the invariant more direct: walk from the head, verify each backlink, reject the sentinel on reachable pages, detect cycles, and compare the visited total with <code class="language-plaintext highlighter-rouge">purgeable_page_count</code>.</p>

<p>The general lesson is smaller than “test more restarts.” When a relation is stored in several fields, each mutation must either update all of them or leave all of them alone. Reinitializing one field of a live intrusive list is not cleanup. It is corruption that waits for the next honest guard to find it.</p>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="systems" /><category term="emulation" /><category term="debugging" /><category term="data-structures" />
    
    
    <summary type="html"><![CDATA[An intrusive-list membership marker must change with the head and neighbor links, or a harmless-looking reset creates an impossible state.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">Test the State Space, Not the Noise</title>
    <link href="https://nadas.ro/2026/09/21/test-the-state-space-not-the-noise.html" rel="alternate" type="text/html" title="Test the State Space, Not the Noise" />
    <published>2026-09-21T09:42:25+03:00</published>
    <updated>2026-09-21T09:42:25+03:00</updated>
    <id>https://mihainadas.github.io/2026/09/21/test-the-state-space-not-the-noise</id>
    <content type="html" xml:base="https://nadas.ro/2026/09/21/test-the-state-space-not-the-noise.html"><![CDATA[<p>86Box emulates historical PC hardware, including the Creative Music System and Game Blaster sound cards. Each card uses two Philips SAA1099 chips. An SAA1099 combines six tone channels, two noise generators, stereo amplitude control, and envelope control, which lets old software build effects from digital tones and coloured noise.</p>

<p>The programming interface exposes four clock choices for each noise generator. The <a href="https://www.worldradiohistory.com/Archive-Company-Publications/Philips-Technical-Review/Electronic-Compoinents/Electronic-Components-%26-Apps-Vol-8-No-1.pdf">Philips description of the chip</a> specifies three fixed rates and one rate controlled by a tone generator. It does not give an emulator author a bit-for-bit reference sequence. The implementation still needs a precise state transition, or the right register writes can produce the wrong sound.</p>

<p>That distinction mattered in an <a href="https://github.com/86Box/86Box/issues/6557">86Box report about static sound effects</a>. The old noise path used a 16-bit state and the wrong feedback taps. The merged correction changed it to the SAA1099’s 18-bit recurrence. Listening found the symptom; a state-space test made the correction reviewable.</p>

<h2 id="write-the-recurrence-as-a-contract">Write the recurrence as a contract</h2>

<p>The corrected implementation shifts the state left and inserts the XNOR of stages 18 and 11. In zero-based C bit positions, those taps are 17 and 10:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">MASK</span> <span class="o">=</span> <span class="p">(</span><span class="mi">1</span> <span class="o">&lt;&lt;</span> <span class="mi">18</span><span class="p">)</span> <span class="o">-</span> <span class="mi">1</span>


<span class="k">def</span> <span class="nf">step</span><span class="p">(</span><span class="n">state</span><span class="p">):</span>
    <span class="n">feedback</span> <span class="o">=</span> <span class="mi">1</span> <span class="o">^</span> <span class="p">((</span><span class="n">state</span> <span class="o">&gt;&gt;</span> <span class="mi">17</span><span class="p">)</span> <span class="o">&amp;</span> <span class="mi">1</span><span class="p">)</span> <span class="o">^</span> <span class="p">((</span><span class="n">state</span> <span class="o">&gt;&gt;</span> <span class="mi">10</span><span class="p">)</span> <span class="o">&amp;</span> <span class="mi">1</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">((</span><span class="n">state</span> <span class="o">&lt;&lt;</span> <span class="mi">1</span><span class="p">)</span> <span class="o">|</span> <span class="n">feedback</span><span class="p">)</span> <span class="o">&amp;</span> <span class="n">MASK</span>
</code></pre></div></div>

<p>This short form fixes four choices that prose such as “use an 18-bit LFSR” leaves open: shift direction, tap numbering, XOR versus XNOR, and the retained width. A test should fix the seed as well.</p>

<p>86Box starts its allocated device state at zero, so XNOR feedback is convenient. The all-zero state immediately advances; the all-ones state is the excluded lock-up state. AMD’s <a href="https://docs.amd.com/v/u/en-US/xapp052">LFSR application note</a> describes the general rule: a maximum-length <code class="language-plaintext highlighter-rouge">n</code>-bit recurrence visits <code class="language-plaintext highlighter-rouge">2^n - 1</code> states, and changing XOR to XNOR changes which uniform state locks up.</p>

<p>MAME’s <a href="https://github.com/mamedev/mame/blob/d0f1c15a0f6df2dd51a754cb46e6175b7079c8f2/src/devices/sound/saa1099.cpp#L282-L294">SAA1099 implementation</a> uses the complementary convention: XOR feedback with an all-ones initial state. Comparing its literal state words with 86Box would therefore reject a valid complement. The useful comparison is the tap relation, update direction, period, and declared output polarity.</p>

<h2 id="count-every-state-once">Count every state once</h2>

<p>For 18 stages, the maximum non-locking period is</p>

<p>\[
2^{18} - 1 = 262{,}143.
\]</p>

<p>The period test is small enough to run directly:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">state</span> <span class="o">=</span> <span class="mi">0</span>

<span class="k">for</span> <span class="n">period</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">1</span> <span class="o">&lt;&lt;</span> <span class="mi">18</span><span class="p">):</span>
    <span class="n">state</span> <span class="o">=</span> <span class="n">step</span><span class="p">(</span><span class="n">state</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">state</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
        <span class="k">break</span>

<span class="k">assert</span> <span class="n">period</span> <span class="o">==</span> <span class="mi">262_143</span>
</code></pre></div></div>

<p>This checks more than the integer width. A mistyped tap can remain inside 18 bits and still enter a shorter cycle. A correct recurrence reaches its starting state after 262,143 updates and not before.</p>

<p>Running the same audit on the replaced 86Box recurrence gives a different structure. Starting from zero, it first advances to one, then enters a cycle of 32,767 states. The declared storage was 16 bits, but the feedback relation behaved as a shorter maximal sequence. This explains why changing only the C type would not have repaired the generator.</p>

<p>The <a href="https://github.com/86Box/86Box/blob/e76d467e3563d12cf25ccb725e75940d84eaa187/src/sound/snd_cms.c#L98-L104">merged 86Box code</a> stores the running history in a 32-bit integer. Only the low 18 bits participate in the recurrence and output. Those low bits complete the 262,143-step cycle; the extra high bits are storage headroom, not additional generator stages.</p>

<h2 id="separate-sequence-from-clocking">Separate sequence from clocking</h2>

<p>The SAA1099 register interface controls how quickly the recurrence advances. It does not select a different feedback polynomial. The Philips programming description gives fixed noise clocks of 31.25 kHz, 15.6 kHz, and 7.8 kHz, plus a tone-controlled option from roughly 61 Hz to 15.6 kHz.</p>

<p>A state-space test should therefore call the transition directly. If it renders audio samples instead, a clock-divider error can masquerade as a sequence error, and the test may pass or fail depending on buffer size. Timing needs its own assertions: each register choice should produce the intended number of generator updates over a known sample interval.</p>

<p>This separation also makes failures easier to name. Wrong taps alter the bit order and period. A wrong divider preserves the order but plays it at the wrong rate. Both can sound like bad noise, but they are not the same defect.</p>

<h2 id="test-stereo-with-unequal-values">Test stereo with unequal values</h2>

<p>The same <a href="https://github.com/86Box/86Box/pull/7772">86Box change</a> fixed a separate mixer error. In the noise-only path, the right output used the left amplitude. A listening test with equal channel levels could never expose it.</p>

<p>The smallest useful mixer test sets deliberately unequal values, enables noise without a tone, forces a known high noise bit, and checks the two accumulators independently. In the 86Box mixer’s integer scale, left and right amplitudes of 3 and 11 should contribute 270 and 990 respectively. Swapping, duplicating, or averaging them must fail.</p>

<p>That is a different invariant from the LFSR period. Keeping the tests separate prevents a stereo regression from being hidden inside a long expected audio buffer.</p>

<h2 id="state-what-the-test-establishes">State what the test establishes</h2>

<p>The period test establishes that the implemented recurrence has the intended maximum-length cycle under its declared seed convention. The tap and prefix checks establish deterministic bit order. The asymmetric mixer test establishes independent digital channel gains at the implementation boundary.</p>

<p>None of them proves that the recurrence is a transistor-level description of every SAA1099 revision. The public Philips material documents the programming surface and noise clock choices, while the exact recurrence is corroborated by an independent emulator implementation and by the repaired game paths. Direct hardware capture would be stronger evidence for silicon fidelity.</p>

<p>That boundary is useful rather than embarrassing. The tests turn an audible complaint into three narrow claims about sequence, timing, and stereo routing. A later hardware trace can challenge one claim without making the others vague.</p>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="systems" /><category term="emulation" /><category term="testing" /><category term="audio" />
    
    
    <summary type="html"><![CDATA[An emulator's pseudorandom sound path needs a recurrence, period, seed, and mixer test before listening becomes useful.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">A Holdout Is a Set of Equivalence Classes</title>
    <link href="https://nadas.ro/2026/09/20/holdout-equivalence-classes.html" rel="alternate" type="text/html" title="A Holdout Is a Set of Equivalence Classes" />
    <published>2026-09-20T10:35:01+03:00</published>
    <updated>2026-09-20T10:35:01+03:00</updated>
    <id>https://mihainadas.github.io/2026/09/20/holdout-equivalence-classes</id>
    <content type="html" xml:base="https://nadas.ro/2026/09/20/holdout-equivalence-classes.html"><![CDATA[<p>CalcGPT is a small GPT-2-style language model for controlled arithmetic experiments. It represents addition and subtraction problems as text, trains on a subset of those problems, and evaluates on tasks held out from training. The current work is not trying to publish a headline accuracy yet. It is defining what “held out” must mean before an accuracy can support a generalization claim.</p>

<p>That definition is less obvious than exact string matching. Addition is commutative: <code class="language-plaintext highlighter-rouge">12+34</code> and <code class="language-plaintext highlighter-rouge">34+12</code> are different strings but the same arithmetic fact. Testing on one after training on the other does not provide the same evidence as testing an unseen operand pair.</p>

<p>An audit of the 300-task benchmark found two additions whose operand-swapped twins were present in training. The exact-overlap check had passed because the strings differed. The arithmetic tasks did not.</p>

<p>The <a href="https://github.com/mihainadas/calcgpt/pull/2">CalcGPT reproducibility branch</a> now treats a holdout as a sample of semantic groups rather than a sample of formatted equations. The code change is small. Its useful part is deciding what counts as the same task, then carrying that decision through splitting, benchmark sampling, and artifact records.</p>

<h2 id="define-sameness-before-shuffling">Define sameness before shuffling</h2>

<p>The group key parses each equation into integer operands and an operator. It ignores answer formatting and removes leading-zero differences before applying the operation rule:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">semantic_key</span><span class="p">(</span><span class="n">left</span><span class="p">,</span> <span class="n">operator</span><span class="p">,</span> <span class="n">right</span><span class="p">):</span>
    <span class="k">if</span> <span class="n">operator</span> <span class="o">==</span> <span class="s">"+"</span><span class="p">:</span>
        <span class="n">low</span><span class="p">,</span> <span class="n">high</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">((</span><span class="n">left</span><span class="p">,</span> <span class="n">right</span><span class="p">))</span>
        <span class="k">return</span> <span class="p">(</span><span class="s">"+"</span><span class="p">,</span> <span class="n">low</span><span class="p">,</span> <span class="n">high</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">(</span><span class="s">"-"</span><span class="p">,</span> <span class="n">left</span><span class="p">,</span> <span class="n">right</span><span class="p">)</span>
</code></pre></div></div>

<p>Addition uses an unordered operand pair because it is commutative. Subtraction keeps the operands ordered. <code class="language-plaintext highlighter-rouge">12-7</code> and <code class="language-plaintext highlighter-rouge">7-12</code> are different tasks, even before restricting this experiment to nonnegative results.</p>

<p>Parsing before grouping also separates task identity from representation. <code class="language-plaintext highlighter-rouge">012+034=...</code> and <code class="language-plaintext highlighter-rouge">12+34=...</code> receive the same key. A reversed answer and a normal answer do too. That is required for the planned representation ablation: changing the serialization must not silently change which arithmetic facts enter the holdout.</p>

<p>The implementation lives in <a href="https://github.com/mihainadas/calcgpt/blob/codex/cleanup-vnext/lib/data.py"><code class="language-plaintext highlighter-rouge">lib/data.py</code></a>. It groups examples first, sorts the groups for a stable starting order, shuffles them with a declared seed, and assigns complete groups to training or validation. The regression test compares the sets of group keys across both partitions, not only their equation strings.</p>

<p>This is the arithmetic version of grouped splitting. Scikit-learn’s <a href="https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.GroupShuffleSplit.html"><code class="language-plaintext highlighter-rouge">GroupShuffleSplit</code></a> accepts domain-specific group labels for the same reason: the unit that must stay together is not always one row.</p>

<h2 id="a-requested-fraction-becomes-approximate">A requested fraction becomes approximate</h2>

<p>Grouping changes the meaning of a 20% validation split. Individual examples are no longer independently movable.</p>

<p>If both addition orders exist, an off-diagonal group can contain two strings. A diagonal group such as <code class="language-plaintext highlighter-rouge">7+7</code> contains one. The splitter adds whole groups until it reaches its target example count, so the final fraction may cross the requested value by one group.</p>

<p>That is not a defect to hide with another random move. Moving one member would violate the exclusion invariant. The artifact should record the requested fraction, the realized example counts, and the realized group counts. A reproducible split owns its actual denominator, not only its seed and nominal ratio.</p>

<h2 id="sample-from-the-group-space-directly">Sample from the group space directly</h2>

<p>The benchmark sampler in <a href="https://github.com/mihainadas/calcgpt/blob/codex/cleanup-vnext/lib/benchmark.py"><code class="language-plaintext highlighter-rouge">lib/benchmark.py</code></a> goes further than generating ordered tasks and rejecting leaks afterward. It indexes the semantic group space itself.</p>

<p>For a magnitude bucket containing <code class="language-plaintext highlighter-rouge">n</code> possible operand values, unordered addition has</p>

<p>\[
\frac{n(n+1)}{2}
\]</p>

<p>groups. Canonical nonnegative subtraction has the same count, because each task satisfies <code class="language-plaintext highlighter-rouge">left &gt;= right</code>. The combined bucket therefore has <code class="language-plaintext highlighter-rouge">n(n + 1)</code> semantic groups.</p>

<p>This gives 110 groups for one-digit operands, 8,190 for two-digit operands, and 810,900 for three-digit operands. Each training task is encoded into one of those group identifiers. The sampler removes the blocked identifiers, samples ranks uniformly from the remaining space without replacement, and maps each compressed rank back to its original identifier with a binary search.</p>

<p>Only after an addition group is selected does the sampler choose an operand order. This avoids giving an off-diagonal addition group twice the sampling weight of a diagonal group merely because it has two printable strings.</p>

<p>Rejection sampling could also avoid overlap, but it makes runtime depend on how much of the bucket is blocked and invites an unnoticed retry limit. Sampling the allowed group ranks states the capacity in advance. If 100 tasks are requested and only 99 groups remain, generation fails with that fact instead of returning a smaller benchmark.</p>

<h2 id="put-the-relation-in-the-manifest">Put the relation in the manifest</h2>

<p>The holdout manifest records the exclusion policy, seed, operand width, sample count, task-roster hash, excluded-roster hash, and each task’s semantic key. Rendering the same roster into four text representations preserves the manifest identity.</p>

<p>A later report can therefore check two separate claims:</p>

<ul>
  <li>the evaluated tasks belong to the declared benchmark roster;</li>
  <li>the benchmark roster is disjoint from training under the declared equivalence relation.</li>
</ul>

<p>A dataset hash alone establishes neither. It identifies bytes, not the task relation used to decide whether those bytes leak information.</p>

<h2 id="the-equivalence-relation-is-part-of-the-claim">The equivalence relation is part of the claim</h2>

<p>This rule does not make every arithmetic notion of similarity disappear. <code class="language-plaintext highlighter-rouge">1+4</code> and <code class="language-plaintext highlighter-rouge">2+3</code> share a result but remain different groups. Tasks with similar carry patterns also remain distinct. If the research question concerns unseen results, unseen carries, or extrapolation to wider operands, each needs a different holdout contract.</p>

<p>The present relation supports one narrow statement: no evaluated addition repeats a training operand pair in the opposite order, and no evaluated subtraction repeats the same ordered operands. The <a href="https://github.com/mihainadas/calcgpt/tree/codex/cleanup-vnext/tests">grouping and benchmark tests</a> establish that software invariant. They do not establish that a model has learned arithmetic.</p>

<p>That result still requires completed runs. The split only makes clear which facts those runs are allowed to call unseen.</p>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="evaluation" /><category term="arithmetic" /><category term="reproducibility" /><category term="experimental-design" />
    
    
    <summary type="html"><![CDATA[An arithmetic benchmark can be string-disjoint and still repeat a training fact. Sampling semantic groups makes the exclusion rule explicit.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">Defining a Reproducible Arithmetic Experiment in CalcGPT</title>
    <link href="https://nadas.ro/2026/08/27/calcgpt-reproducible-arithmetic.html" rel="alternate" type="text/html" title="Defining a Reproducible Arithmetic Experiment in CalcGPT" />
    <published>2026-08-27T12:00:00+03:00</published>
    <updated>2026-08-27T12:00:00+03:00</updated>
    <id>https://mihainadas.github.io/2026/08/27/calcgpt-reproducible-arithmetic</id>
    <content type="html" xml:base="https://nadas.ro/2026/08/27/calcgpt-reproducible-arithmetic.html"><![CDATA[<p>CalcGPT began as a small GPT-2-style model trained to complete arithmetic
expressions.</p>

<p>The current <a href="https://github.com/mihainadas/calcgpt/pull/2">pull request</a>
does not make the model larger or publish a new score. It narrows the experiment:
record which arithmetic tasks enter each split, define what counts as an unseen
task, and bind later results to versioned data and model artifacts.</p>

<h2 id="exact-overlap-was-not-enough">Exact overlap was not enough</h2>

<p>The first held-out check excluded exact operand/operator tuples from the training
data. That misses an equivalence specific to addition. If <code class="language-plaintext highlighter-rouge">12+34</code> is in training,
testing <code class="language-plaintext highlighter-rouge">34+12</code> does not provide the same evidence as testing an unseen operand
pair.</p>

<p>Under the intended canonical benchmark seed 42, an audit of the 300-task sample
found two addition tasks whose operand-swapped twins were present in the committed
training data. This does not show that an earlier model memorized either pair. It
shows that exact disjointness could not support the stronger claim of semantic
disjointness.</p>

<p>The proposed sampler assigns addition to a group keyed by its sorted operands;
excluding either ordering excludes both. Subtraction remains directional. A
regression test checks the semantic groups as well as exact task overlap. The
lesson is narrower than “remove duplicates”: the split invariant must match the
symmetry of the task.</p>

<h2 id="artifacts-record-evidence-they-do-not-create-it">Artifacts record evidence; they do not create it</h2>

<p>A saved model needs more than weights. The proposed training path writes a
tokenizer, a task-format specification, and a manifest containing the source
dataset hash, grouped-split hashes, configuration, Git state, dependency versions,
and training losses. Proposed evaluation reports record their own dataset, model,
configuration, and artifact hashes.</p>

<p>Those records matter only when their relationships are checked. The proposed
ablation plan constructs one semantic holdout manifest from the declared source
roster, renders that roster for all four representations, and assigns the same
manifest hash to every cell. Evaluation rejects a supplied manifest whose roster
does not match the evaluated tasks; the summary contract checks that run
provenance against the plan. A shared seed or row count alone is not accepted as
that binding.</p>

<p>Identity checks are necessary but not sufficient. An adversarial review found
that an aggregator could recognize the canonical benchmark hashes while accepting
metrics over only 100 examples from a 300-task manifest, or a completed record
that omitted EOS, arithmetic-stratum, throughput, and supervised-target evidence.
A hash establishes which roster was intended; it does not establish that the
entire roster was scored or that the required measurements were recorded. The
summary validation now requires both provenance and completeness: the canonical
300-task denominator; versioned train and validation target-token counts that
match the training manifest; EOS evidence; all seven declared arithmetic-stratum
partitions reconciled to the primary counts; and finite, internally consistent
throughput under its declared successful-completions scope. Regression tests cover
the missing and inconsistent cases. This establishes a reporting contract, not a
model-quality result.</p>

<p>The repository changes also separate fast contract checks from a real-ML smoke test.
The pull-request matrix stays dependency-light across Python 3.11–3.13 and tests
the extracted source distribution on Python 3.11. A scheduled or manually
dispatched workflow installs the ML runtime, trains an intentionally tiny model,
and checks that the tokenizer, task specification, training manifest, model
configuration, and weights survive the round trip.</p>

<p>That smoke test establishes packaging and artifact plumbing. It is not a model
quality benchmark.</p>

<h2 id="low-accuracy-is-a-result-not-a-crashed-process">Low accuracy is a result, not a crashed process</h2>

<p>The previous evaluation command returned a failing process status when arithmetic
accuracy fell below 50 percent. That is useful only when a threshold has been
declared as a gate. For an ablation, a small model that scores poorly has still
produced a valid outcome if loading, generation, validation, and report writing
completed correctly.</p>

<p>The proposed command therefore separates operational failure from scientific
outcome. A caller can request a <code class="language-plaintext highlighter-rouge">--fail-under</code> threshold explicitly; without one,
weak runs remain in the experiment record instead of disappearing behind a
nonzero exit status.</p>

<p>The proposed evaluator restricts its primary arithmetic metrics to prompts with a
determined answer. Given only the first operand, many continuations are valid;
scoring one arbitrary continuation as the unique target makes aggregate accuracy
difficult to interpret. Those cases remain diagnostics rather than headline
accuracy.</p>

<h2 id="four-representations-one-task-roster">Four representations, one task roster</h2>

<p>The planned experiment crosses fixed-width layout with answer order. Fixed-width
means that both operands and the answer occupy declared-width fields:</p>

<table>
  <thead>
    <tr>
      <th>Condition</th>
      <th style="text-align: right">Fixed-width fields</th>
      <th style="text-align: right">Reversed answer</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Plain</td>
      <td style="text-align: right">no</td>
      <td style="text-align: right">no</td>
    </tr>
    <tr>
      <td>Reverse only</td>
      <td style="text-align: right">no</td>
      <td style="text-align: right">yes</td>
    </tr>
    <tr>
      <td>Fixed-width only</td>
      <td style="text-align: right">yes</td>
      <td style="text-align: right">no</td>
    </tr>
    <tr>
      <td>Combined</td>
      <td style="text-align: right">yes</td>
      <td style="text-align: right">yes</td>
    </tr>
  </tbody>
</table>

<p>The plan renders all four datasets from one ordered roster of normalized
<code class="language-plaintext highlighter-rouge">(left, operator, right)</code> tasks. It declares model seeds 41, 42, and 43 in advance,
with split seed 42 and benchmark seed 42 fixed across conditions. The current
script validates the configuration and expands the four-by-three matrix into
distinct dataset, model, and report paths; it does not launch training.</p>

<p>This is a whole-representation comparison, not a token-budget-matched test of
operand padding. Under answer-only loss, minimal answers have variable length;
fixed-width answers always contain <code class="language-plaintext highlighter-rouge">operand_width + 1</code> digits, and EOS is a target
in both cases. Those target counts belong in each run record. Any narrower claim
about padding would require a separate matched-budget experiment.</p>

<p>If the runs proceed, every planned cell—including failures and low scores—belongs
in the record. A completed run binds its normalized train, validation, and
evaluation rosters to the declared benchmark-manifest hash. It reports exact,
numerical, strict-format, EOS, throughput, and arithmetic-stratum measurements
with denominators. A failed run retains its failed stage, error or exit status,
configuration, Git revision, and available diagnostics rather than disappearing.
Model weights can remain release assets; plans, manifests, hashes, and summaries
can remain reviewable files.</p>

<h2 id="reinforcement-learning-is-a-separate-experiment">Reinforcement learning is a separate experiment</h2>

<p>Reinforcement learning is not part of the current baseline, implementation, or
result. It may become a post-baseline study only after the supervised four-way
matrix is complete.</p>

<p>The smallest useful study is a three-arm continuation from the frozen
padded-reversed checkpoints at seeds 41, 42, and 43: no continuation, an
equal-compute supervised continuation, and a verifier-reward policy-optimization
continuation. Naming the representation before seeing the baseline avoids choosing
the apparent winner after the fact. The supervised control distinguishes an RL
effect from the simpler effect of giving the model more optimization.</p>

<p>The continuation data would use a separate roster with no canonical benchmark
semantic groups. Reward would be 1 only for an exact canonical answer followed by
EOS and 0 otherwise; the KL coefficient, schedule, compute budget, and stopping
rules would be fixed before running. The existing 300-task benchmark would remain
untouched until final evaluation. Verifier mismatch, reward exploitation, and
output collapse would be recorded as failed runs rather than repaired mid-study.</p>

<p>This is an outcome-reward experiment because CalcGPT emits only final answers.
Adding process reward would first require an intermediate-reasoning representation
and would answer a different question. <a href="https://arxiv.org/abs/2402.03300">DeepSeekMath</a>
introduced Group Relative Policy Optimization for mathematical reasoning, while
<a href="https://arxiv.org/abs/2501.12948">DeepSeek-R1</a> studied verifier-based reinforcement
learning on reasoning tasks. <a href="https://arxiv.org/abs/2305.20050">Let’s Verify Step by
Step</a> is useful precisely because it separates
process supervision from final-answer outcome supervision. Those results motivate
the protocol; they do not establish that the same behavior will transfer to this
small model.</p>

<p>Without the completed supervised matrix, executable protocol, and recorded runs,
RL remains a deferred question rather than a capability claim.</p>

<h2 id="what-this-work-establishes">What this work establishes</h2>

<p>The pull request under review defines stronger software and reporting contracts
for a controlled representation ablation. It does not establish that CalcGPT has
learned a general arithmetic algorithm, generalizes to wider operands, or reaches
a particular canonical accuracy. No model-quality result has been produced by
this work.</p>

<p>Those claims require completed runs and published evidence. The
<a href="https://github.com/mihainadas/calcgpt/pull/2">pull request</a> is the review boundary,
and the branch’s <a href="https://github.com/mihainadas/calcgpt/blob/codex/cleanup-vnext/docs/research-log.md">research log</a>
records implementation status separately from empirical results.</p>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="language-models" /><category term="arithmetic" /><category term="reproducibility" /><category term="testing" />
    
    
    <summary type="html"><![CDATA[What semantic holdouts, versioned artifacts, and a predeclared representation ablation can establish—and what they cannot.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">When Noisy Text Changes the Winning Model</title>
    <link href="https://nadas.ro/2026/08/27/small-model-diacritics-noise.html" rel="alternate" type="text/html" title="When Noisy Text Changes the Winning Model" />
    <published>2026-08-27T09:11:56+03:00</published>
    <updated>2026-10-17T13:42:57+03:00</updated>
    <id>https://mihainadas.github.io/2026/08/27/small-model-diacritics-noise</id>
    <content type="html" xml:base="https://nadas.ro/2026/08/27/small-model-diacritics-noise.html"><![CDATA[<p>Romanian diacritic restoration puts missing marks back into text without changing its other characters. A system that works on edited prose may face a different job when the input contains typos or scanning errors. This experiment compared a dictionary, a small bidirectional recurrent network called a BiLSTM, the byte-level text model ByT5, and adapted or prompted language models under both conditions.</p>

<p>On clean Romanian, a 2.4M-parameter BiLSTM reached 96.23% word accuracy. Under heavy corruption it fell to 36.76%. The dictionary baseline finished at 70.31%.</p>

<p>The test input changed the winner. Once the text began to resemble typing mistakes or optical character recognition (OCR) output, the clean-text ranking stopped being a useful deployment guide.</p>

<blockquote>
  <p><strong>Evidence status.</strong> This comparison is preliminary thesis work and a manuscript in preparation. It has not been peer reviewed. ByT5 used 50,000 training pairs, so its result is not a budget-matched architecture comparison.</p>
</blockquote>

<figure class="content-figure content-figure--wide">
  
  <picture>
    
    <source media="(max-width: 640px)" srcset="/assets/figures/small-model-diacritics-noise/robustness-mobile.svg" width="320" height="1110" />
    
    <img src="/assets/figures/small-model-diacritics-noise/robustness.svg" alt="Word accuracy from clean to heavily corrupted Romanian text for four restoration systems: the BiLSTM starts highest and falls fastest, the dictionary baseline starts second and finishes highest, and ByT5 remains between them under heavy noise." width="1200" height="680" loading="lazy" decoding="async" />
  </picture>
  
  
  <figcaption>
    <span>Clean-text accuracy and robustness answer different deployment questions. Values are from the preliminary CRAWLER-1000 experiment.</span>
    
  </figcaption>
  
</figure>

<h2 id="the-clean-table-rewards-constraint">The clean table rewards constraint</h2>

<p>On the clean CRAWLER-1000 test, the dictionary system reached 93.72% word accuracy with a 0.045 diacritic error rate. The 2.4M-parameter BiLSTM led at 96.23% and 0.033. ByT5 reached 92.00% and 0.081. A 1.7B-parameter Qwen3 model adapted with LoRA reached 48.99% and 0.545; prompted Qwen2.5 and Llama baselines were both near 70.5% word accuracy.</p>

<p>Parameter count did not predict the result. The constrained systems have the right inductive bias for a mostly character-preserving transformation. The decoder has far more capacity, but capacity is not the same as control.</p>

<h2 id="noise-reverses-the-operational-choice">Noise reverses the operational choice</h2>

<p>From clean to high corruption, word accuracy changed as follows:</p>

<table>
  <thead>
    <tr>
      <th>System</th>
      <th style="text-align: right">Clean</th>
      <th style="text-align: right">Low</th>
      <th style="text-align: right">Medium</th>
      <th style="text-align: right">High</th>
      <th style="text-align: right">Relative drop</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Dictionary</td>
      <td style="text-align: right">93.72</td>
      <td style="text-align: right">92.26</td>
      <td style="text-align: right">81.31</td>
      <td style="text-align: right">70.31</td>
      <td style="text-align: right">25.0%</td>
    </tr>
    <tr>
      <td>BiLSTM, 2.4M</td>
      <td style="text-align: right">96.23</td>
      <td style="text-align: right">49.17</td>
      <td style="text-align: right">43.66</td>
      <td style="text-align: right">36.76</td>
      <td style="text-align: right">61.8%</td>
    </tr>
    <tr>
      <td>ByT5</td>
      <td style="text-align: right">92.00</td>
      <td style="text-align: right">76.59</td>
      <td style="text-align: right">69.03</td>
      <td style="text-align: right">60.71</td>
      <td style="text-align: right">34.0%</td>
    </tr>
    <tr>
      <td>Qwen3 LoRA, 1.7B</td>
      <td style="text-align: right">48.99</td>
      <td style="text-align: right">53.15</td>
      <td style="text-align: right">45.72</td>
      <td style="text-align: right">37.60</td>
      <td style="text-align: right">23.2%</td>
    </tr>
  </tbody>
</table>

<p>The Qwen model’s low-noise uptick should be treated as sampling variation until replicated. Its smaller relative drop is not a robustness victory because it starts from a much lower clean ceiling.</p>

<p>The practical selection is therefore conditional. For clean text, the BiLSTM leads this benchmark. For corrupted input, the dictionary is the strongest of these four by the high-noise endpoint. ByT5 loses some clean accuracy but degrades more gradually; a budget-matched training run is needed before attributing that behavior to architecture.</p>

<h2 id="put-the-corruption-model-in-the-contract">Put the corruption model in the contract</h2>

<p>A diacritic-restoration benchmark should state which non-diacritic edits are present, when corruption is applied, and whether the system may alter other characters. It should report accuracy at diacritizable positions together with unwanted edits elsewhere. Otherwise, a fluent rewrite can score as restoration while changing the source.</p>

<p>The next experiment needs repeated corruption draws across additional declared seeds, confidence intervals for model differences, latency on the target hardware, and an exact-preservation measure. Until then, this table chooses candidates for deployment tests; it does not crown a universal Romanian restoration model.</p>

<aside class="series-note" aria-labelledby="thesis-series-heading">
  <p class="series-note__label">Thesis evidence series</p>
  <h2 id="thesis-series-heading">Controlled synthetic narratives</h2>
  <ol>
    
      <li>
        <a href="/2025/04/29/three-million-stories-sparse-sample.html">Three Million Stories Are Still a Sparse Sample</a>
      </li>
    
      <li>
        <a href="/2026/08/27/change-one-slot-watch-what-else-moves.html">Change One Slot, Watch What Else Moves</a>
      </li>
    
      <li>
        <a href="/2026/05/02/interval-belongs-to-comparison.html">The Interval Belongs to the Comparison</a>
      </li>
    
      <li>
        <a href="/2026/08/27/judges-rank-systems-not-items.html">The Panel Was Weak on Items and Useful for Ranking Systems</a>
      </li>
    
      <li aria-current="page">
        <span>When Noisy Text Changes the Winning Model</span>
      </li>
    
      <li>
        <a href="/2026/08/27/adaptation-could-not-remove-scraper-artifact.html">Ten Thousand Adaptation Steps Could Not Remove One Scraper Artifact</a>
      </li>
    
  </ol>
  <p>The series begins with the proposal-stage thesis synthesis and traces each result to published, accepted, or thesis-only evidence.</p>
</aside>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="romanian-nlp" /><category term="evaluation" /><category term="small-models" />
    
    
    <summary type="html"><![CDATA[Romanian diacritic restoration changes winners when clean benchmark text gives way to typos and OCR-like corruption.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">The Panel Was Weak on Items and Useful for Ranking Systems</title>
    <link href="https://nadas.ro/2026/08/27/judges-rank-systems-not-items.html" rel="alternate" type="text/html" title="The Panel Was Weak on Items and Useful for Ranking Systems" />
    <published>2026-08-27T09:11:56+03:00</published>
    <updated>2026-08-27T09:11:56+03:00</updated>
    <id>https://mihainadas.github.io/2026/08/27/judges-rank-systems-not-items</id>
    <content type="html" xml:base="https://nadas.ro/2026/08/27/judges-rank-systems-not-items.html"><![CDATA[<p>The TinyFabulist evaluation study tested whether three open-weight language models could replace a proprietary judge when comparing story-generation and translation systems. The panel scored individual outputs, then its median scores were used to rank the systems.</p>

<p>Those two uses require different evidence. Item-level agreement asks whether the judges assign similar labels to the same output. Rank agreement asks whether their aggregated scores order whole systems similarly.</p>

<p>The three panel members disagreed on individual scores. After median aggregation, the panel’s TF1 system ranking tracked o4-mini at Spearman \(\rho=0.93\) and Kendall \(\tau=0.78\). The fixed panel combined Granite 4.1 30B, EXAONE 3.5 32B, and Granite 3.3 8B. Across three tasks it ran 6,180 evaluations, with another 900 bias reruns. Item-level agreement remained weak: Krippendorff’s alpha ranged from −0.34 to +0.12, and mean weighted kappa from −0.02 to +0.15.</p>

<blockquote>
  <p><strong>Evidence status.</strong> The panel study was accepted by the thesis cutoff. The human-arbitration protocol remains proposal-stage and has not run.</p>
</blockquote>

<figure class="content-figure content-figure--wide">
  
  <picture>
    
    <source media="(max-width: 640px)" srcset="/assets/figures/judges-rank-systems-not-items/agreement-mobile.svg" width="320" height="1030" />
    
    <img src="/assets/figures/judges-rank-systems-not-items/agreement.svg" alt="A diagram separating low item-level agreement from high aggregate rank agreement, ending at a human-review gate that remains open." width="1200" height="630" loading="lazy" decoding="async" />
  </picture>
  
  
  <figcaption>
    <span>The measured evidence supports aggregate system ranking under the tested protocol, not trustworthy labels for individual items.</span>
    
  </figcaption>
  
</figure>

<h2 id="agreement-depends-on-the-object">Agreement depends on the object</h2>

<p>For individual items, the judges often used the rubric differently. That is not surprising in literary generation and translation: an answer can preserve meaning while losing style, or read fluently while changing an event. A single ordinal score compresses those disagreements.</p>

<p>Aggregation changed the picture. Under the paper protocol, TF1 system rankings from the panel tracked the o4-mini reference at Spearman \(\rho=0.93\) and Kendall \(\tau=0.78\). A later composition analysis using the full panel produced \(\rho=0.952\) and \(\tau=0.822\). These are different protocols, so the two pairs should not be blended into one headline number.</p>

<p>The defensible operating rule is narrow: use the panel for the tested TF1 model-selection protocol and report system-level rank agreement. Do not infer from that result that its item scores are reliable filters.</p>

<h2 id="three-judges-are-not-three-independent-views">Three judges are not three independent views</h2>

<p>Panel size alone can overstate diversity. Two members come from the Granite family. Model lineage, training data, and instruction style can create correlated errors even when parameter counts differ. A panel audit therefore needs more than a list of checkpoints: family diversity, order sensitivity, rubric perturbations, and repeated runs belong in the record.</p>

<p>The 900 bias reruns address part of that record. They do not prove independence or eliminate shared blind spots.</p>

<h2 id="the-human-gate-is-still-open">The human gate is still open</h2>

<p>The proposed workflow routes model disagreements and other ambiguous cases to human review. The thesis projects a reduction from 3,588 human judgments under a three-rater, per-task protocol to 255 judgments concentrated on 85 disagreement cases. That is a planning estimate, not a completed human study.</p>

<p>Until the arbitration is run, the panel is a ranking instrument with a known weak point. It can reduce the amount of work presented to people; it has not earned the right to stand in for them.</p>

<aside class="series-note" aria-labelledby="thesis-series-heading">
  <p class="series-note__label">Thesis evidence series</p>
  <h2 id="thesis-series-heading">Controlled synthetic narratives</h2>
  <ol>
    
      <li>
        <a href="/2025/04/29/three-million-stories-sparse-sample.html">Three Million Stories Are Still a Sparse Sample</a>
      </li>
    
      <li>
        <a href="/2026/08/27/change-one-slot-watch-what-else-moves.html">Change One Slot, Watch What Else Moves</a>
      </li>
    
      <li>
        <a href="/2026/05/02/interval-belongs-to-comparison.html">The Interval Belongs to the Comparison</a>
      </li>
    
      <li aria-current="page">
        <span>The Panel Was Weak on Items and Useful for Ranking Systems</span>
      </li>
    
      <li>
        <a href="/2026/08/27/small-model-diacritics-noise.html">When Noisy Text Changes the Winning Model</a>
      </li>
    
      <li>
        <a href="/2026/08/27/adaptation-could-not-remove-scraper-artifact.html">Ten Thousand Adaptation Steps Could Not Remove One Scraper Artifact</a>
      </li>
    
  </ol>
  <p>The series begins with the proposal-stage thesis synthesis and traces each result to published, accepted, or thesis-only evidence.</p>
</aside>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="evaluation" /><category term="open-models" /><category term="language-models" />
    
    
    <summary type="html"><![CDATA[An open-weight judge panel was weak at item-level agreement yet useful for ranking systems in one fixed protocol.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">Change One Slot, Watch What Else Moves</title>
    <link href="https://nadas.ro/2026/08/27/change-one-slot-watch-what-else-moves.html" rel="alternate" type="text/html" title="Change One Slot, Watch What Else Moves" />
    <published>2026-08-27T09:11:56+03:00</published>
    <updated>2026-08-27T09:11:56+03:00</updated>
    <id>https://mihainadas.github.io/2026/08/27/change-one-slot-watch-what-else-moves</id>
    <content type="html" xml:base="https://nadas.ro/2026/08/27/change-one-slot-watch-what-else-moves.html"><![CDATA[<p>TinyFabulist generates a story from a six-field specification that names its character, trait, setting, challenge, outcome, and teaching. The intervention experiment asks whether changing one requested field changes only that field in the output or leaks into a field held constant.</p>

<p>Changing only the protagonist disturbed the unchanged moral in 4.0% to 18.5% of matched pairs, depending on the semantic verifier. Changing the moral almost never displaced the protagonist: specified-character realization changed in 0.5% of pairs [0.1%, 2.8%].</p>

<p>Those directions occupy different cells of the response–leakage matrix. I generated 200 matched pairs per intervention, holding every other slot and the decoding setup fixed and using a common seed within each pair.</p>

<blockquote>
  <p><strong>Evidence status.</strong> These are thesis-draft experiments, not a peer-reviewed result. They cover two of the six slots, one generator, 4-bit local inference, and 200 matched pairs per intervention.</p>
</blockquote>

<figure class="content-figure content-figure--wide">
  
  <picture>
    
    <source media="(max-width: 640px)" srcset="/assets/figures/change-one-slot-watch-what-else-moves/intervention-mobile.svg" width="320" height="1110" />
    
    <img src="/assets/figures/change-one-slot-watch-what-else-moves/intervention.svg" alt="A paired intervention matrix showing complete character response with verifier-sensitive moral leakage, while moral changes respond semantically, exact moral wording is less stable, and measured character leakage is low." width="1200" height="650" loading="lazy" decoding="async" />
  </picture>
  
  
  <figcaption>
    <span>Response and leakage are separate measurements. Semantic and verbatim verifiers also answer different questions.</span>
    
  </figcaption>
  
</figure>

<h2 id="characters-responded-morals-sometimes-followed">Characters responded; morals sometimes followed</h2>

<p>When the character slot changed, the new character appeared in 100% of pairs, with a 95% Wilson interval of [98.1%, 100%]. The unchanged moral’s realization also moved in 4.0% to 18.5% of pairs, depending on the semantic verifier.</p>

<p>The direct response is surface-checkable. The leakage estimate depends on a learned verifier deciding whether two stories still realize the same moral. Every tested verifier detected the direction, but they did not identify one defensible magnitude. The common seed removes one source of variation; it does not prove that every remaining difference was caused by the intervention.</p>

<h2 id="moral-changes-rarely-displaced-the-protagonist">Moral changes rarely displaced the protagonist</h2>

<p>Changing the moral produced an 87.5–98.0% response rate under semantic verification. Exact wording was much less stable: the requested moral appeared verbatim in 33.5% of pairs [27.3%, 40.3%].</p>

<p>The specified character was disturbed in only 0.5% of these interventions [0.1%, 2.8%]. The asymmetry is the result; its mechanism is not identified. Character interventions sometimes altered the moral-realization verdict, while moral interventions almost never disturbed the protagonist.</p>

<p>A story can express the requested lesson without repeating the specification literally. Semantic response measures meaning as interpreted by a verifier; verbatim response measures surface realization. Neither should be renamed ground truth.</p>

<h2 id="the-asymmetric-result">The asymmetric result</h2>

<p>Both tested controls responded strongly. Their leakage did not run equally in both directions. Character changes sometimes disturbed the unchanged moral; moral changes almost never disturbed the character.</p>

<p>Because the moral cells depend on automated semantic verifiers, this establishes a direction that survived every tested instrument, not a human-perceived leakage rate. The remaining four slots, other generators, and human adjudication still belong to the next measurement round.</p>

<aside class="series-note" aria-labelledby="thesis-series-heading">
  <p class="series-note__label">Thesis evidence series</p>
  <h2 id="thesis-series-heading">Controlled synthetic narratives</h2>
  <ol>
    
      <li>
        <a href="/2025/04/29/three-million-stories-sparse-sample.html">Three Million Stories Are Still a Sparse Sample</a>
      </li>
    
      <li aria-current="page">
        <span>Change One Slot, Watch What Else Moves</span>
      </li>
    
      <li>
        <a href="/2026/05/02/interval-belongs-to-comparison.html">The Interval Belongs to the Comparison</a>
      </li>
    
      <li>
        <a href="/2026/08/27/judges-rank-systems-not-items.html">The Panel Was Weak on Items and Useful for Ranking Systems</a>
      </li>
    
      <li>
        <a href="/2026/08/27/small-model-diacritics-noise.html">When Noisy Text Changes the Winning Model</a>
      </li>
    
      <li>
        <a href="/2026/08/27/adaptation-could-not-remove-scraper-artifact.html">Ten Thousand Adaptation Steps Could Not Remove One Scraper Artifact</a>
      </li>
    
  </ol>
  <p>The series begins with the proposal-stage thesis synthesis and traces each result to published, accepted, or thesis-only evidence.</p>
</aside>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="synthetic-data" /><category term="evaluation" /><category term="experimental-design" />
    
    
    <summary type="html"><![CDATA[A paired intervention test separates response to a requested field from leakage into a field that should stay fixed.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">Ten Thousand Adaptation Steps Could Not Remove One Scraper Artifact</title>
    <link href="https://nadas.ro/2026/08/27/adaptation-could-not-remove-scraper-artifact.html" rel="alternate" type="text/html" title="Ten Thousand Adaptation Steps Could Not Remove One Scraper Artifact" />
    <published>2026-08-27T09:11:56+03:00</published>
    <updated>2026-08-27T09:11:56+03:00</updated>
    <id>https://mihainadas.github.io/2026/08/27/adaptation-could-not-remove-scraper-artifact</id>
    <content type="html" xml:base="https://nadas.ro/2026/08/27/adaptation-could-not-remove-scraper-artifact.html"><![CDATA[<p>A Romanian diacritic-restoration study compared several pretrained checkpoints before and after task-specific adaptation. The first screening question was operational: does the base model produce ordinary Romanian text that an adapter can plausibly refine?</p>

<p>The model was asked to restore Romanian diacritics. It answered with <code class="language-plaintext highlighter-rouge">autoResizeIframe</code>.</p>

<p>That token is not a plausible restoration error. It is a web-page artifact, probably learned from scraped training text. In one experiment, it appeared in outputs from the LLMic_v2 3B checkpoint before adaptation and remained after rank-16 LoRA, a doubled schedule, and 10,000 training steps.</p>

<blockquote>
  <p><strong>Evidence status.</strong> This is a thesis-draft case study of one checkpoint, one task corpus, and one adaptation setup. It does not show that LoRA generally cannot remove contamination, or that the artifact is irreversible.</p>
</blockquote>

<figure class="content-figure content-figure--wide">
  
  <picture>
    
    <source media="(max-width: 640px)" srcset="/assets/figures/adaptation-could-not-remove-scraper-artifact/artifact-path-mobile.svg" width="320" height="990" />
    
    <img src="/assets/figures/adaptation-could-not-remove-scraper-artifact/artifact-path.svg" alt="A provenance path from scraped web text to a pretrained checkpoint, through LoRA adaptation, ending with the same autoResizeIframe artifact in a Romanian diacritic-restoration output." width="1200" height="600" loading="lazy" decoding="async" />
  </picture>
  
  
  <figcaption>
    <span>Task adaptation improved the checkpoint slightly but did not suppress the observed artifact in this run.</span>
    
  </figcaption>
  
</figure>

<h2 id="the-adapter-learned-around-the-failure">The adapter learned around the failure</h2>

<p>The base checkpoint scored 0.00% word accuracy on the clean restoration test. After adaptation it reached 1.45%. The task score moved, but remained unusable, and the distinctive artifact survived.</p>

<p>A separate multilingual Qwen checkpoint adapted on the same task did not show that exact output pattern. That contrast narrows the diagnosis to this checkpoint and run; it does not identify the original document or prove a single causal path through pretraining.</p>

<p>The important operational signal came before aggregate evaluation. A model that emits templating code into ordinary Romanian text has failed the output contract, even if later tuning raises an average score. More epochs are a poor default response when the base model’s failure is both severe and out of distribution for the task.</p>

<h2 id="contamination-is-also-a-selection-problem">Contamination is also a selection problem</h2>

<p>Dataset cleaning is usually discussed upstream, where the pretraining corpus may be inaccessible to a downstream user. Checkpoint selection is the available downstream control. A small diagnostic set can probe for markup, navigation fragments, templating identifiers, instruction leakage, and non-language tokens before adaptation consumes compute.</p>

<p>That screen should preserve raw generations. Normalizing or post-processing them too early can erase the best clue about provenance.</p>

<p>For this task, the observed artifact should be a stop condition: reject or quarantine the checkpoint, compare another base model, and only then tune hyperparameters. If the model must be studied further, treat the artifact rate as its own metric rather than burying it inside word accuracy.</p>

<h2 id="the-checkpoint-was-disqualified-before-the-causal-story-was-complete">The checkpoint was disqualified before the causal story was complete</h2>

<p>The experiment did not identify the original pretraining document or test full-parameter tuning. It did settle the model-selection decision: after 10,000 adaptation steps, this checkpoint still emitted scraper debris into Romanian output. That was enough to quarantine it before another sweep.</p>

<aside class="series-note" aria-labelledby="thesis-series-heading">
  <p class="series-note__label">Thesis evidence series</p>
  <h2 id="thesis-series-heading">Controlled synthetic narratives</h2>
  <ol>
    
      <li>
        <a href="/2025/04/29/three-million-stories-sparse-sample.html">Three Million Stories Are Still a Sparse Sample</a>
      </li>
    
      <li>
        <a href="/2026/08/27/change-one-slot-watch-what-else-moves.html">Change One Slot, Watch What Else Moves</a>
      </li>
    
      <li>
        <a href="/2026/05/02/interval-belongs-to-comparison.html">The Interval Belongs to the Comparison</a>
      </li>
    
      <li>
        <a href="/2026/08/27/judges-rank-systems-not-items.html">The Panel Was Weak on Items and Useful for Ranking Systems</a>
      </li>
    
      <li>
        <a href="/2026/08/27/small-model-diacritics-noise.html">When Noisy Text Changes the Winning Model</a>
      </li>
    
      <li aria-current="page">
        <span>Ten Thousand Adaptation Steps Could Not Remove One Scraper Artifact</span>
      </li>
    
  </ol>
  <p>The series begins with the proposal-stage thesis synthesis and traces each result to published, accepted, or thesis-only evidence.</p>
</aside>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="romanian-nlp" /><category term="data-quality" /><category term="language-models" />
    
    
    <summary type="html"><![CDATA[One Romanian checkpoint kept emitting a web-page token after supervised adaptation, turning contamination into a model-selection failure.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">The Interval Belongs to the Comparison</title>
    <link href="https://nadas.ro/2026/05/02/interval-belongs-to-comparison.html" rel="alternate" type="text/html" title="The Interval Belongs to the Comparison" />
    <published>2026-08-27T09:11:56+03:00</published>
    <updated>2026-08-27T09:11:56+03:00</updated>
    <id>https://mihainadas.github.io/2026/05/02/interval-belongs-to-comparison</id>
    <content type="html" xml:base="https://nadas.ro/2026/05/02/interval-belongs-to-comparison.html"><![CDATA[<p>The TinyFabulist studies compare generators, translators, and evaluators under different sampling designs. Some systems are scored on separate story samples; others are scored on the same held-out items. A confidence interval must preserve whichever relation produced the scores.</p>

<p>A score has no pairing structure. A comparison does.</p>

<p>That difference determines the bootstrap. In TF1, candidate generators were evaluated on disjoint samples, so the model gap was resampled independently. In TF2, several systems translated the same held-out items, so the gap was resampled by item. Calling both procedures “a bootstrap confidence interval” hides the experimental design that makes each one valid.</p>

<blockquote>
  <p><strong>Archive note.</strong> The 2 May date marks <a href="https://arxiv.org/abs/2504.20605v2">TF1 arXiv v2</a>, which introduced the revised panel evaluation. The confidence-interval analysis itself was added later in the August 2026 thesis draft.</p>
</blockquote>

<figure class="content-figure content-figure--wide">
  
  <picture>
    
    <source media="(max-width: 640px)" srcset="/assets/figures/interval-belongs-to-comparison/bootstrap-mobile.svg" width="320" height="1110" />
    
    <img src="/assets/figures/interval-belongs-to-comparison/bootstrap.svg" alt="Two bootstrap paths: independent resampling for model scores measured on different stories, and paired item resampling for systems evaluated on the same translations. A third path stops because per-item outputs were not retained." width="1200" height="650" loading="lazy" decoding="async" />
  </picture>
  
  
  <figcaption>
    <span>Resample the unit that carries the comparison. Without per-item records, a paired interval cannot be reconstructed.</span>
    
  </figcaption>
  
</figure>

<h2 id="different-stories-resample-independently">Different stories: resample independently</h2>

<p>The TF1 historical-judge comparison between Tulu and Llama has an estimated gap of +0.02 with a 95% bootstrap interval of [−0.13, +0.17]. The open-panel comparison gives +0.12 [+0.07, +0.18]. Each model was scored on its own sampled stories. One model’s row 37 is not the counterpart of the other’s row 37, so preserving row numbers as pairs would invent dependence.</p>

<p>Independent resampling respects that design: draw with replacement within each model’s sample, recompute the two means, then store their difference. The interval is about the difference of two sample means, not a halo around either score.</p>

<p>The two TF1 intervals also make a useful substantive distinction. The historical-judge gap includes zero; the panel gap does not under this analysis. That does not make the panel ground truth. It says the measured separation is more stable under one evaluator construction.</p>

<h2 id="same-items-keep-the-pair-intact">Same items: keep the pair intact</h2>

<p>TF2 systems translated the same items. Their errors can therefore be correlated: a difficult sentence may reduce every system’s score. Resampling each system separately would throw away that information and usually misstate the uncertainty of the difference.</p>

<p>The paired procedure samples item indices and carries every system score attached to an index. It produced +0.03 [−0.02, +0.08] for o3 minus GPT-4.1, and +0.31 [+0.23, +0.41] for o3 minus TF2-4B. The first interval does not establish a separation; the second does on this set and evaluator.</p>

<h2 id="the-missing-interval">The missing interval</h2>

<p>TF2-12B was reported nine hundredths below o3, but its per-item evaluation outputs were not retained. A mean and a sample size cannot recover the covariance between systems. There is no honest paired interval to compute after the fact.</p>

<p>Missing the item rows removed the covariance needed for a paired interval. It also ruled out parity or non-inferiority language. The repair is operational: preserve one row per evaluated item, with the input identifier, system output, judge scores, prompt version, and run metadata.</p>

<p>The choice between paired and independent bootstrap is made before any random resampling begins. It lives in the data lineage of the comparison.</p>

<aside class="series-note" aria-labelledby="thesis-series-heading">
  <p class="series-note__label">Thesis evidence series</p>
  <h2 id="thesis-series-heading">Controlled synthetic narratives</h2>
  <ol>
    
      <li>
        <a href="/2025/04/29/three-million-stories-sparse-sample.html">Three Million Stories Are Still a Sparse Sample</a>
      </li>
    
      <li>
        <a href="/2026/08/27/change-one-slot-watch-what-else-moves.html">Change One Slot, Watch What Else Moves</a>
      </li>
    
      <li aria-current="page">
        <span>The Interval Belongs to the Comparison</span>
      </li>
    
      <li>
        <a href="/2026/08/27/judges-rank-systems-not-items.html">The Panel Was Weak on Items and Useful for Ranking Systems</a>
      </li>
    
      <li>
        <a href="/2026/08/27/small-model-diacritics-noise.html">When Noisy Text Changes the Winning Model</a>
      </li>
    
      <li>
        <a href="/2026/08/27/adaptation-could-not-remove-scraper-artifact.html">Ten Thousand Adaptation Steps Could Not Remove One Scraper Artifact</a>
      </li>
    
  </ol>
  <p>The series begins with the proposal-stage thesis synthesis and traces each result to published, accepted, or thesis-only evidence.</p>
</aside>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="evaluation" /><category term="statistics" /><category term="language-models" />
    
    
    <summary type="html"><![CDATA[Why two model gaps require different bootstrap designs—and why one missing record prevents an interval altogether.]]></summary>
    
  </entry>
  
  
  
  <entry>
    
    
    
    <title type="html">Three Million Stories Are Still a Sparse Sample</title>
    <link href="https://nadas.ro/2025/04/29/three-million-stories-sparse-sample.html" rel="alternate" type="text/html" title="Three Million Stories Are Still a Sparse Sample" />
    <published>2026-08-27T09:11:56+03:00</published>
    <updated>2026-10-17T13:42:57+03:00</updated>
    <id>https://mihainadas.github.io/2025/04/29/three-million-stories-sparse-sample</id>
    <content type="html" xml:base="https://nadas.ro/2025/04/29/three-million-stories-sparse-sample.html"><![CDATA[<p>TinyFabulist’s TF1 project generates English fables from six selected story fields. A specification chooses the ingredients; a language model turns them into prose. The release contains three million stories. To use that corpus for an experiment, I first need to know which combinations it actually represents.</p>

<p>Three million sounds exhaustive until it is divided by a trillion.</p>

<p>The six slots are character, trait, setting, challenge, outcome, and teaching, with one hundred values available in each. That makes \(100^6\), or one trillion, possible specifications. The released three-million-story corpus occupies at most 0.0003% of that grid.</p>

<blockquote>
  <p><strong>Archive note.</strong> This is an August 2026 analysis of the <a href="https://arxiv.org/abs/2504.20605">April 2025 TF1 release</a>. The date marks the artifact being examined; the argument was added to this journal later.</p>
</blockquote>

<figure class="content-figure content-figure--wide">
  
  <picture>
    
    <source media="(max-width: 640px)" srcset="/assets/figures/three-million-stories-sparse-sample/coverage-mobile.svg" width="320" height="1010" />
    
    <img src="/assets/figures/three-million-stories-sparse-sample/coverage.svg" alt="A scale comparison showing one trillion possible six-slot specifications, three million generated stories, about thirty thousand appearances per individual value, and about three hundred appearances per cross-slot pair." width="1200" height="610" loading="lazy" decoding="async" />
  </picture>
  
  
  <figcaption>
    <span>Scale gives strong marginal coverage and useful pairwise coverage; it does not fill the joint design space.</span>
    
  </figcaption>
  
</figure>

<h2 id="what-three-million-buys">What three million buys</h2>

<p>If the sampler is balanced, each individual value appears about 30,000 times: a particular character, setting, or teaching is no longer a rare event. A particular value pair across two slots appears about 300 times on average. That is enough to inspect many first-order and pairwise effects with useful replication.</p>

<p>The sixth-order combination is different. Even with no duplicate rows, the corpus leaves 999,997,000,000 possible specifications unseen. A claim that the data “covers the design space” would confuse a large row count with dense joint coverage.</p>

<p>That leaves useful work to do. Check whether every setting is represented, or whether one character is unusually associated with one moral. Those are questions about individual fields and pairs. Asking whether every interaction among all six fields has been exercised is a much larger demand.</p>

<h2 id="ask-which-combinations-the-experiment-needs">Ask which combinations the experiment needs</h2>

<p>The useful question is not whether the corpus is large. It is: coverage of what?</p>

<ul>
  <li><strong>First order:</strong> roughly 30,000 observations for each slot value.</li>
  <li><strong>Second order:</strong> roughly 300 for each cross-slot value pair.</li>
  <li><strong>Sixth order:</strong> at most three million occupied combinations; almost all the rest are unseen.</li>
</ul>

<p>Those averages assume balanced independent sampling. The actual records still need frequency tables, duplicate checks, and conditional counts; an expectation is not an audit. Rare generation failures or filtering can also disturb the intended balance after sampling.</p>

<h2 id="a-better-release-record">A better release record</h2>

<p>For a controlled synthetic corpus, I would publish three coverage summaries beside the row count: per-value frequencies, cross-slot pair frequencies, and the number of distinct full specifications. They expose different failures. A balanced marginal table can coexist with hidden pairwise coupling; clean pair counts can coexist with duplicated full prompts.</p>

<p>Three million rows make first- and second-order projections measurable. They do not make a trillion-cell space dense.</p>

<aside class="series-note" aria-labelledby="thesis-series-heading">
  <p class="series-note__label">Thesis evidence series</p>
  <h2 id="thesis-series-heading">Controlled synthetic narratives</h2>
  <ol>
    
      <li aria-current="page">
        <span>Three Million Stories Are Still a Sparse Sample</span>
      </li>
    
      <li>
        <a href="/2026/08/27/change-one-slot-watch-what-else-moves.html">Change One Slot, Watch What Else Moves</a>
      </li>
    
      <li>
        <a href="/2026/05/02/interval-belongs-to-comparison.html">The Interval Belongs to the Comparison</a>
      </li>
    
      <li>
        <a href="/2026/08/27/judges-rank-systems-not-items.html">The Panel Was Weak on Items and Useful for Ranking Systems</a>
      </li>
    
      <li>
        <a href="/2026/08/27/small-model-diacritics-noise.html">When Noisy Text Changes the Winning Model</a>
      </li>
    
      <li>
        <a href="/2026/08/27/adaptation-could-not-remove-scraper-artifact.html">Ten Thousand Adaptation Steps Could Not Remove One Scraper Artifact</a>
      </li>
    
  </ol>
  <p>The series begins with the proposal-stage thesis synthesis and traces each result to published, accepted, or thesis-only evidence.</p>
</aside>]]></content>
    
    <author><name>Mihai Nadăș</name></author>
    <category term="synthetic-data" /><category term="experimental-design" /><category term="language-models" />
    
    
    <summary type="html"><![CDATA[What a three-million-row corpus covers when six slots each have one hundred possible values.]]></summary>
    
  </entry>
  
</feed>
