<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: midnightgrep</title>
    <description>The latest articles on DEV Community by midnightgrep (@midnightgrep).</description>
    <link>https://dev.to/midnightgrep</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4035993%2F52053c3c-e060-462c-b661-280aa6a3bd14.png</url>
      <title>DEV Community: midnightgrep</title>
      <link>https://dev.to/midnightgrep</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/midnightgrep"/>
    <language>en</language>
    <item>
      <title>The test we called flaky was filing records under the wrong owner</title>
      <dc:creator>midnightgrep</dc:creator>
      <pubDate>Sun, 19 Jul 2026 14:14:07 +0000</pubDate>
      <link>https://dev.to/midnightgrep/the-test-we-called-flaky-was-filing-records-under-the-wrong-owner-2pja</link>
      <guid>https://dev.to/midnightgrep/the-test-we-called-flaky-was-filing-records-under-the-wrong-owner-2pja</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — A contention test in my agent tooling failed in 6.7% of standalone runs (24% in a noisy shared tree). I had labeled it a "known flake." The real failure: under load, an observability record was occasionally &lt;strong&gt;filed under the wrong owner&lt;/strong&gt;. Total row count stayed correct, every writer process exited 0, and the only check that reliably notices this failure shape — a per-owner count assertion — was the one I kept rerunning until it went green. This post is about that failure shape: &lt;em&gt;misattribution&lt;/em&gt;, which is invisible to every totals-based check, and which the flaky-test literature and the SQLite-locking literature each half-describe but don't connect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background: what's already well covered
&lt;/h2&gt;

&lt;p&gt;Three adjacent topics are thoroughly written up, and I won't re-litigate them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Your flaky test might be a real bug"&lt;/strong&gt; is established wisdom: &lt;a href="https://www.testrail.com/blog/flaky-tests/" rel="noopener noreferrer"&gt;TestRail's guide&lt;/a&gt; on flakes masking genuine defects, and &lt;a href="https://dev.to/dmytro_huz/test-flakiness-the-silent-killer-of-engineering-trust-5h0l"&gt;Test Flakiness: The Silent Killer of Engineering Trust&lt;/a&gt; on why "it passes sometimes" must not become an accepted state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLITE_BUSY despite a timeout&lt;/strong&gt; has a canonical explainer in &lt;a href="https://berthub.eu/articles/posts/a-brief-post-on-sqlite3-database-locked-despite-timeout/" rel="noopener noreferrer"&gt;Bert Hubert's post&lt;/a&gt; (implicit transaction upgrades), and &lt;a href="https://github.com/praeclarum/sqlite-net/issues/778" rel="noopener noreferrer"&gt;sqlite-net #778&lt;/a&gt; documents lock errors when &lt;code&gt;journal_mode=WAL&lt;/code&gt; runs before a timeout can be configured. &lt;a href="https://til.simonwillison.net/sqlite/enabling-wal-mode" rel="noopener noreferrer"&gt;Simon Willison's TIL&lt;/a&gt; covers the part many people miss: write-ahead logging (WAL) mode is persisted in the database file header, so you don't need to re-issue it per connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent data failures in pipelines&lt;/strong&gt; — rows that stay countable while becoming wrong — are a known blind spot in the data-quality world (e.g. &lt;a href="https://medium.com/@jooramos_37651/catching-silent-failures-in-data-pipelines-with-forecasting-metadata-and-an-llm-d316e1666bb6" rel="noopener noreferrer"&gt;Catching Silent Failures in Data Pipelines&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I could not find written anywhere: what misattribution looks like &lt;strong&gt;as a test-design problem&lt;/strong&gt; — which assertions can catch it at all, and how easily the one assertion that can gets killed as a flake.&lt;/p&gt;

&lt;h2&gt;
  
  
  The incident
&lt;/h2&gt;

&lt;p&gt;My agent tooling runs each large language model (LLM) agent as an OS process. A small Rust hook fires on every agent event and appends one row to a shared SQLite database in WAL mode. This log is the single source of truth for "which agent is alive and what is it doing." A contention drill spawns 300 writer processes — 30 running concurrently, 10 events per owner — and asserts, per owner, that all rows landed.&lt;/p&gt;

&lt;p&gt;It usually passed. Sometimes, under parallel load, one owner had 9 rows instead of 10. Reruns in isolation: green, green. Into the books it went as "known load flake — rerun solo, two consecutive greens = pre-existing."&lt;/p&gt;

&lt;p&gt;When I banned that label and required a mechanism, the first failing run said everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;seat-00     | 9     &amp;lt;- expected 10
env-seat-00 | 1     &amp;lt;- ...where did YOU come from
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Owner names lightly anonymized.) The record was not lost. It was &lt;strong&gt;filed under a different owner&lt;/strong&gt;. Totals: still 300. Writer exit codes: all 0. Distinct owners: 30 → 31.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which checks can even see this?
&lt;/h2&gt;

&lt;p&gt;This is the part I wish someone had written down before:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Record &lt;em&gt;loss&lt;/em&gt;
&lt;/th&gt;
&lt;th&gt;Record &lt;em&gt;misattribution&lt;/em&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total row count&lt;/td&gt;
&lt;td&gt;catches&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;blind&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writer exit codes&lt;/td&gt;
&lt;td&gt;catches (write errors only)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;blind&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Did all N processes report?"&lt;/td&gt;
&lt;td&gt;catches&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;blind&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Distinct-owner count&lt;/td&gt;
&lt;td&gt;blind (unless an owner loses every row)&lt;/td&gt;
&lt;td&gt;catches only when the wrong owner is a &lt;em&gt;new&lt;/em&gt; one (here 30 → 31); &lt;strong&gt;blind&lt;/strong&gt; when a record moves between existing owners&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-owner row count&lt;/td&gt;
&lt;td&gt;catches&lt;/td&gt;
&lt;td&gt;catches&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything in the first three rows — the checks a reasonable engineer reaches for first — stays green while ownership moves. In my system the per-owner assertion existed only because the drill happened to be written that way, and it was the assertion I kept rerunning until it passed. The tripwire was almost deleted for doing its job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mechanism: a fallback that lies
&lt;/h2&gt;

&lt;p&gt;The hook resolves which agent it belongs to like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;session_id ──&amp;gt; registry DB lookup ──ok──&amp;gt; seat id        (authoritative)
                     │
                    Err  (swallowed: `Err(_) =&amp;gt; {}`)
                     ▼
              environment variable                        (known-unreliable)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The environment variable is unreliable &lt;em&gt;by design&lt;/em&gt; in my background-agent mode — a shared daemon hands every agent the first client's environment, which is exactly why the session-ID lookup exists as the primary source. So "registry momentarily unreadable" silently degraded into "file this event under whoever the environment claims." A different, wrong owner.&lt;/p&gt;

&lt;p&gt;The trigger for "momentarily unreadable" was one line executed on every connection open, &lt;em&gt;after&lt;/em&gt; &lt;code&gt;busy_timeout(5000)&lt;/code&gt; was set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="nf"&gt;.pragma_update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"journal_mode"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"WAL"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// unconditional, every open&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under a 30-process open stampede this failed with, verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SqliteFailure(Error { code: DatabaseBusy, extended_code: 5 }, Some("database is locked"))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my runs this statement was not protected by the busy handler: failing runs completed in 3–4 seconds total, nowhere near the 5-second timeout budget, so no retrying happened on that statement. (This is a different mechanism from the implicit-transaction upgrade in Bert Hubert's post, and different from sqlite-net #778 where the timeout &lt;em&gt;couldn't be set&lt;/em&gt; before the pragma — here it was set and didn't cover it.) As Simon Willison's TIL notes, re-issuing the pragma is unnecessary anyway once the file is in WAL mode — which is what made the collision window pure waste.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed, and what each change was worth
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;6.7% fail (dedicated tree, standalone) / 24% (noisy shared tree)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix 1 only — issue the pragma only if the DB is not already WAL&lt;/td&gt;
&lt;td&gt;1 failure in 10 standalone runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixes 1+2 — also remove the env fallback&lt;/td&gt;
&lt;td&gt;10 consecutive greens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixes 1+2+3 — also bounded retry on first WAL transition&lt;/td&gt;
&lt;td&gt;20 for 20 under deliberate parallel load&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fix 2&lt;/strong&gt; is the one that addresses misattribution itself: if a session ID exists but the registry read fails, don't fall back to a &lt;em&gt;less&lt;/em&gt; trustworthy source. Degrade to a deterministic short-ID placeholder that the daemon later reconciles, and emit a marker event so the degradation is observable. During the 10-run verification, one green run recorded exactly one such marker — the degradation path fired for real and was absorbed correctly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix 3&lt;/strong&gt; exists because I almost stopped too early. After fixes 1+2 hit 10 consecutive greens, a follow-up 20-run campaign failed its &lt;em&gt;first&lt;/em&gt; run with a different mode: 7 of 300 writer processes exited non-zero on a brand-new database file (30 processes stampeding the very first WAL transition, which fix 1's check cannot avoid). Bounded retry — 5 attempts, linear backoff, then fail loud — closed it. If I had stopped at 10 runs, "fixed" would once again be sitting on top of a live bug.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Average write cost was 11.6 ms per process against the 5000 ms budget — this was never a throughput problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Misattribution is a distinct failure shape from loss, and it defeats totals.&lt;/strong&gt; If a record's &lt;em&gt;owner&lt;/em&gt; can be derived from fallible context, put at least one per-owner (or per-attributor) assertion somewhere. Nothing else in the matrix above catches it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fallback from an authoritative source to a less-trustworthy one is not resilience — it is a data-corruption path.&lt;/strong&gt; Degrade to an explicit placeholder and mark the degradation. (This was not the only swallowed-error fallback in my codebase — merely the first one caught in the act.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide your consecutive-green bar before you start, and overshoot it.&lt;/strong&gt; 10 was not enough here; the 20-run campaign caught a second failure mode on run one.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Limits / untested
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The journal-mode/busy-handler behavior above is what I observed on my setup (Rust + rusqlite, bundled SQLite, Windows, process-per-writer); I have not tested other bindings or platforms, and I make no claim about all SQLite statement types.&lt;/li&gt;
&lt;li&gt;I have not measured how much production misattribution the conditional-pragma fix alone would have prevented; production contention differs from the drill's.&lt;/li&gt;
&lt;li&gt;A continuous per-owner drift check (in the retention job, not just in tests) is designed but not built.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Repro notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Windows 11, Rust (rusqlite, bundled SQLite), one writer process per event (300 total, 30 concurrent) appending to one WAL-mode file; &lt;code&gt;busy_timeout(5000)&lt;/code&gt; set on every connection before any other statement.&lt;/li&gt;
&lt;li&gt;In my runs the failure appeared only when real process startups collided on connection open. To watch misattribution live, group rows by owner and diff against the expected owner set — don't count totals.&lt;/li&gt;
&lt;li&gt;The systems are personal tooling and not distributed; owner names above are lightly anonymized.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Drafted with my local AI pipeline; all measurements are from real runs on my machine and reproducible from the notes above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sqlite</category>
      <category>testing</category>
      <category>debugging</category>
      <category>observability</category>
    </item>
    <item>
      <title>A 110M-Parameter Model Instead of an LLM? Testing Japanese NLI Cross-Encoders on Real Conversation Logs</title>
      <dc:creator>midnightgrep</dc:creator>
      <pubDate>Sun, 19 Jul 2026 04:27:41 +0000</pubDate>
      <link>https://dev.to/midnightgrep/a-110m-parameter-model-instead-of-an-llm-testing-japanese-nli-cross-encoders-on-real-conversation-4k1p</link>
      <guid>https://dev.to/midnightgrep/a-110m-parameter-model-instead-of-an-llm-testing-japanese-nli-cross-encoders-on-real-conversation-4k1p</guid>
      <description>&lt;p&gt;&lt;em&gt;TL;DR: I wanted a model small enough to bundle inside a memory tool (tens to hundreds of MB, CPU-only) that could judge whether two Japanese sentences make the same claim, contradict each other, or are unrelated — a Natural Language Inference (NLI) task. I benchmarked two NLI cross-encoders on 16 hand-labeled pairs written in the register of real work logs. The small Japanese model (111M params) failed paraphrase detection outright on this set (0/5). The multilingual one (279M) did better (10/16) and — with a confidence threshold — works today as a zero-false-positive contradiction detector. Neither solves "same claim" out of the box. Numbers, traps, and a possible way forward below.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I wanted this
&lt;/h2&gt;

&lt;p&gt;I run a local, self-hosted memory system: every conversation turn with my AI assistant gets appended verbatim to SQLite. The tape never lies, but it accumulates. The obvious next layer is consolidation — merging entries that state the same fact, flagging entries that contradict each other.&lt;/p&gt;

&lt;p&gt;The judgment at the core of consolidation is a three-way classification:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Given two sentences: &lt;strong&gt;same claim / contradiction / unrelated&lt;/strong&gt;?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's not generation. That's NLI, a task BERT-class models were solving before LLMs were cool. So instead of paying an LLM (API cost, GPU memory, or both) to babysit my logs, could a bundle-sized model (tens to hundreds of MB) do it? My constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CPU only&lt;/strong&gt;, imperceptibly light (the user should not be able to tell it's running)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundleable&lt;/strong&gt;: same deployment shape as the OCR and embedding models my tool already ships (40–150MB each)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail-safe direction&lt;/strong&gt;: false merges are worse than no merges. Low recall is acceptable; false positives are not.&lt;/li&gt;
&lt;li&gt;Wall-clock time is unlimited (it's a sleep-time job)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The test set
&lt;/h2&gt;

&lt;p&gt;16 hand-labeled Japanese pairs, written deliberately in the register of my actual logs — casual, technical, elliptical. Not textbook NLI sentences. Three axes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;5 paraphrase pairs&lt;/strong&gt; (expected: entailment) — e.g. "検索ツールを構成から外した" vs "検索ツールは構成の一覧から削除された"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 contradiction pairs&lt;/strong&gt; — e.g. "バックアップはそのうちやろう" vs "バックアップは今すぐ実装する"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;6 unrelated pairs&lt;/strong&gt;, including &lt;strong&gt;2 adversarial ones&lt;/strong&gt;: same topic, different claims — e.g. "このツールは検索には悪くない" vs "このツールでは意味の蒸留までは厳しい". These are exactly what embedding cosine similarity gets wrong, and exactly what consolidation must not weld together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(Tool names in the examples above are lightly anonymized; sentence structure and register are unchanged. Yes, n=16 is small — this is a smoke test to decide whether deeper investment is justified, not a paper.)&lt;/p&gt;

&lt;p&gt;Hardware: consumer desktop CPU, single-pair inference, PyTorch, no batching.&lt;/p&gt;

&lt;h2&gt;
  
  
  Candidate 1: Japanese-only, 111M params
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://huggingface.co/akiFQC/bert-base-japanese-v3_nli-jsnli-jnli-jsick" rel="noopener noreferrer"&gt;&lt;code&gt;akiFQC/bert-base-japanese-v3_nli-jsnli-jnli-jsick&lt;/code&gt;&lt;/a&gt; — tohoku BERT-base v3, fine-tuned on JSNLI + JNLI + JSICK.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap #1 before any evaluation&lt;/strong&gt;: the model's &lt;code&gt;config.json&lt;/code&gt; has no &lt;code&gt;id2label&lt;/code&gt;. You get &lt;code&gt;LABEL_0/1/2&lt;/code&gt; and nothing else. The mapping (0=entailment, 1=neutral, 2=contradiction) exists &lt;strong&gt;only in the README&lt;/strong&gt;. If you guess the standard-but-wrong order, your accuracy numbers are garbage and you may not notice. I burned 20 minutes on this; a probe set of trivial pairs ("犬が走っている / 動物が走っている") is the fast way to pin the mapping empirically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Results (16 pairs):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;axis&lt;/th&gt;
&lt;th&gt;correct&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;paraphrase (E)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0/5&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;contradiction (C)&lt;/td&gt;
&lt;td&gt;2/5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unrelated (N)&lt;/td&gt;
&lt;td&gt;5/6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7/16&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Latency: &lt;strong&gt;23.7 ms/pair&lt;/strong&gt; (CPU, single pair, no batching).&lt;/p&gt;

&lt;p&gt;(Counts are under the corrected label mapping — and yes, I first miscounted this table as 8/16 at 5 a.m.; recounting from raw logs before publishing is part of the job.)&lt;/p&gt;

&lt;p&gt;On this set, the paraphrase failure is total: every single paraphrase pair — including near-trivial ones like "外した / 削除された" — collapsed to &lt;em&gt;neutral&lt;/em&gt;. The model only caught contradictions that contained explicit negation ("使っていない" vs "毎日使っている"). A pragmatic contradiction ("そのうちやろう" vs "今すぐ実装する") sailed through as neutral.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why&lt;/strong&gt;: JSNLI is machine-translated SNLI, i.e. translated &lt;em&gt;image captions&lt;/em&gt;. JNLI's pairs also derive from image captions (YJ Captions). The model has essentially never seen technical, colloquial, or elliptical Japanese. This is textbook distribution shift, and at 111M params there's no reserve of general knowledge to absorb it. The latency is spectacular — the semantics are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Candidate 2: multilingual, 279M params
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://huggingface.co/MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7" rel="noopener noreferrer"&gt;&lt;code&gt;MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7&lt;/code&gt;&lt;/a&gt; — mDeBERTa-v3-base fine-tuned on ~2.7M NLI pairs across many datasets. Japanese is mostly cross-lingual transfer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Results (same 16 pairs):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;axis&lt;/th&gt;
&lt;th&gt;correct&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;paraphrase (E)&lt;/td&gt;
&lt;td&gt;2/5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;contradiction (C)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4/5&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unrelated (N)&lt;/td&gt;
&lt;td&gt;4/6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10/16&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Latency: 389 ms/pair (CPU, single pair, no batching).&lt;/p&gt;

&lt;p&gt;Better across the board except the two N misses (it saw contradictions that weren't there). Still weak on paraphrase, and it also missed the pragmatic "later vs now" contradiction. But here's the interesting part:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Threshold analysis.&lt;/strong&gt; If I only act when &lt;code&gt;P(contradiction) ≥ 0.95&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3 contradictions detected (at 0.976, 0.998, 0.984)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0 false positives&lt;/strong&gt; in this set (the wrong contradiction calls scored 0.69 and 0.54 — comfortably below the bar)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's exactly the fail-safe shape I need: a low-recall, high-precision &lt;strong&gt;contradiction alarm&lt;/strong&gt;. Not a consolidator — an alarm. "This new log line disagrees with what your knowledge base says" is a useful, safe, shippable feature even when full consolidation isn't.&lt;/p&gt;

&lt;p&gt;16× slower than the 111M model, but 389 ms on CPU is still nothing for a sleep-time job, and int8 ONNX quantization is commonly reported to buy back around 4× (I haven't measured that here).&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;These two bundle-sized NLI models did not solve same-claim detection on my logs.&lt;/strong&gt; The core judgment of memory consolidation — "are these two sentences saying the same thing?" — is precisely the axis where both models are weakest. Hypothesis refuted, with numbers. (My embedding model had already failed this differently: cosine similarity says "same topic", which is emphatically not "same claim" — that conflation is how you weld contradictions together.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Threshold-gated contradiction detection is usable today.&lt;/strong&gt; High precision, low recall, zero LLM involvement, CPU-trivial. Deployed as an alarm, it fails silently — which is safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the README, not just the config.&lt;/strong&gt; Missing &lt;code&gt;id2label&lt;/code&gt; is a silent accuracy killer. Probe with trivial pairs before trusting any numbers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The failure is specific, therefore fixable.&lt;/strong&gt; The diagnosis is distribution shift, not model capacity. Which suggests:&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The way forward (untested)
&lt;/h2&gt;

&lt;p&gt;The one path I haven't run yet: &lt;strong&gt;domain distillation&lt;/strong&gt;. Sample a few thousand sentence pairs from my actual logs, have a large LLM label them &lt;em&gt;once, offline&lt;/em&gt; (the LLM appears at training time, never at runtime), and fine-tune the 111M Japanese model on pairs that look like my data. CPU fine-tuning of a model this size is slow but feasible, and sleep-time jobs have unlimited wall-clock budget.&lt;/p&gt;

&lt;p&gt;Encouragingly, a paper at the Japanese NLP conference this year (&lt;a href="https://www.anlp.jp/proceedings/annual_meeting/2025/pdf_dir/Q4-2.pdf" rel="noopener noreferrer"&gt;ANLP 2025, Q4-2&lt;/a&gt;) studied domain adaptation for English-Japanese &lt;em&gt;medical translation&lt;/em&gt; and found that supervised fine-tuning alone acquired domain knowledge about as well as continued pretraining + SFT — and that mixing in over 50% pretraining data actually degraded scores on low-frequency knowledge. Their domain isn't mine, so the caveat may or may not transfer, but it suggests I can try skipping the pretraining stage and go straight to labeled pairs. If the experiment happens, the follow-up post will have the numbers, whichever way they fall.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repro notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Python 3.11, &lt;code&gt;transformers&lt;/code&gt; + &lt;code&gt;torch&lt;/code&gt; CPU wheels + &lt;code&gt;fugashi&lt;/code&gt; + &lt;code&gt;unidic-lite&lt;/code&gt; (candidate 1 needs the MeCab tokenizer deps; candidate 2 needs &lt;code&gt;sentencepiece&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;On Windows, watch out for: pip long-path failures inside deep venv paths, and &lt;code&gt;cp932&lt;/code&gt; console encoding clobbering UTF-8 output (&lt;code&gt;PYTHONIOENCODING=utf-8&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Both models load with &lt;code&gt;AutoModelForSequenceClassification.from_pretrained(...)&lt;/code&gt;; classify with softmax over logits of the &lt;code&gt;(premise, hypothesis)&lt;/code&gt; pair&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Drafted with my local AI pipeline; all measurements are from real runs on my machine and reproducible from the notes above. Opinions mine, benchmarks yours to check.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nlp</category>
      <category>machinelearning</category>
      <category>huggingface</category>
      <category>japanese</category>
    </item>
  </channel>
</rss>
