<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Arte Soft Engineering]]></title><description><![CDATA[Engineering notes from Arte Soft EOOD: native Apple apps, local-first software, media pipelines, and practical architecture lessons.]]></description><link>https://artesoft.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 09 Nov 2026 11:37:08 GMT</lastBuildDate><atom:link href="https://artesoft.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Downloaded Is Not Selected: Managing Whisper and Qwen Models in a Native Voice App]]></title><description><![CDATA[How GPT Realtime Assistant manages model downloads, companion files, and runtime selection for local Whisper and Qwen on Mac.]]></description><link>https://artesoft.hashnode.dev/downloaded-is-not-selected-managing-whisper-and-qwen-models-in-a-native-voice-app</link><guid isPermaLink="true">https://artesoft.hashnode.dev/downloaded-is-not-selected-managing-whisper-and-qwen-models-in-a-native-voice-app</guid><category><![CDATA[Swift]]></category><category><![CDATA[macOS]]></category><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[architecture]]></category><dc:creator><![CDATA[mincho]]></dc:creator><pubDate>Mon, 09 Nov 2026 00:55:27 GMT</pubDate><content:encoded><![CDATA[<p>An “offline AI” switch hides several different questions: is a model present, are its companion files present, and is the runtime actually using it? Treating those as one boolean produces confusing settings and difficult support reports.</p>
<p>The local-model catalog in <a href="https://realtime-assistant.arte-soft.com/">GPT Realtime Assistant</a> makes those distinctions explicit. This article focuses on the inspected download service rather than making a latency claim about every device or language.</p>
<h2>Model identity is more than a display name</h2>
<p><code>RealtimeDownloadableModel</code> entries identify a repository, a remote filename, a destination filename, and an installation kind. The catalog includes Whisper Large v3 Turbo variants and Qwen 3 8B variants with different quantizations.</p>
<p>That metadata is operational configuration. “Qwen” is not enough information to select a compatible file or explain its storage cost. The precise artifact matters, and companion artifacts can matter too.</p>
<h2>Installed and selected answer different questions</h2>
<p>The status builder resolves the runtime’s chosen Whisper, Qwen, and Qwen-ASR paths separately. For each catalog entry it inspects the local file size and checks that required companion files exist. It reports <code>isInstalled</code> separately from <code>isSelected</code>.</p>
<table>
<thead>
<tr>
<th>State</th>
<th>Evidence used by the status builder</th>
<th>Limitation</th>
</tr>
</thead>
<tbody><tr>
<td>File present</td>
<td>Filesystem metadata</td>
<td>Presence does not prove valid model contents</td>
</tr>
<tr>
<td>Companions present</td>
<td>Each required companion has metadata</td>
<td>Still not an inference test</td>
</tr>
<tr>
<td>Selected</td>
<td>Runtime path equals model path</td>
<td>Does not prove the runtime has loaded successfully</td>
</tr>
<tr>
<td>Ready for a conversation</td>
<td>Requires runtime/session checks beyond this catalog</td>
<td>Cannot be concluded from a download progress bar</td>
</tr>
</tbody></table>
<p>This avoids a common UI trap: showing a model as active simply because a download finished.</p>
<h2>Stage large downloads separately</h2>
<p>The downloader is an actor. It creates the model destination directory and uses a <code>.part</code> path while transferring a file. It reports downloaded bytes, expected bytes when known, speed, and an estimated remaining duration.</p>
<p>Progress remains below completion while transfer work is still in progress. When expected size is unavailable, the UI must tolerate uncertainty rather than inventing an accurate-looking completion time.</p>
<p>The downloader processes companion files after the primary model. That ordering reinforces why “main file finished” cannot be the only readiness signal.</p>
<h2>Do not confuse a local pipeline with the cloud voice path</h2>
<p>A local speech-recognition model and a local language model are distinct dependencies. Cloud live voice and local inference also have different startup and failure behavior. The useful interface makes the selected path and missing assets visible rather than silently switching providers.</p>
<p>For regression testing, start with a missing companion, an interrupted <code>.part</code> download, a selected file that has been moved, and two installed quantizations. Assert the displayed state, then separately run a synthetic inference check. File length checks are not cryptographic integrity checks; the code inspected here should not be described as providing such verification.</p>
<h2>Product demos: voice and photo context</h2>
<p>These short demos show the app's live voice and photo-question workflows. They are not tests or benchmarks of the local Mac model pipeline described above.</p>
<h3>Live translation, lecture tasks, and interview preparation</h3>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=wOWFlpuPaZk">https://www.youtube.com/watch?v=wOWFlpuPaZk</a></p>

<h3>Asking about an illustrative train timetable</h3>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=p8f-OksdV5k">https://www.youtube.com/watch?v=p8f-OksdV5k</a></p>

<p>The scenes and presenter voices are AI-generated. The timetable is fictional; the route answer comes from a real app test and is edited for time. Some on-screen text in the first demo was adapted.</p>
<p>For shorter cuts, watch the <a href="https://www.youtube.com/shorts/4Zec352CkPo">family Short</a> or the <a href="https://www.youtube.com/shorts/cPGV5TFoZuI">13-second train edit</a>.</p>
<p>The model lifecycle is a first-class part of a native AI product. It determines whether “offline” is an understandable capability or an ambiguous toggle. <a href="https://realtime-assistant.arte-soft.com/compare/">GPT Realtime Assistant’s comparison pages</a> describe the product-level choices around that architecture.</p>
<p>Available on <a href="https://apps.apple.com/us/app/gpt-realtime-assistant/id6770536523?pt=127093297&amp;ct=article_realtime_ai&amp;mt=8">GPT Realtime Assistant on the App Store</a>.</p>
]]></content:encoded></item><item><title><![CDATA[A Mute Button Must Fail Closed: Recording App and Microphone Audio with ReplayKit]]></title><description><![CDATA[Inside Reel’s separate app-audio and microphone paths, local MP4 writer, and the failure case that must never record a muted source.]]></description><link>https://artesoft.hashnode.dev/a-mute-button-must-fail-closed-recording-app-and-microphone-audio-with-replaykit</link><guid isPermaLink="true">https://artesoft.hashnode.dev/a-mute-button-must-fail-closed-recording-app-and-microphone-audio-with-replaykit</guid><category><![CDATA[Swift]]></category><category><![CDATA[iOS]]></category><category><![CDATA[video]]></category><category><![CDATA[architecture]]></category><dc:creator><![CDATA[mincho]]></dc:creator><pubDate>Mon, 09 Nov 2026 00:55:19 GMT</pubDate><content:encoded><![CDATA[<p>A screen recording can look correct while sounding completely wrong. A tutorial needs narration above the application’s audio; a demonstration may need only system sound. Treating both inputs as one volume control makes those two jobs unnecessarily difficult.</p>
<p>In <a href="https://reel-screen-recorder.arte-soft.com/">Reel Screen Recorder</a>, the broadcast extension receives ReplayKit buffers and keeps their source identity until the writer dispatches them. The useful engineering detail is not the slider itself. It is what happens when transforming a buffer fails.</p>
<h2>Keep source identity through the pipeline</h2>
<p>The current <code>SampleHandler.handleAudio</code> distinguishes <code>.audioMic</code> from <code>.audioApp</code> and asks the shared settings for <code>recordingGain(forMicrophone:)</code>. It then passes the processed buffer, <strong>with its original source type</strong>, to <code>BroadcastWriter</code>.</p>
<p>The writer routes video, app audio, and microphone audio to separate capture functions. Its asset writer uses the MP4 container. This division keeps capture policy out of the file-writing dispatch code and makes it possible to reason about each source independently.</p>
<table>
<thead>
<tr>
<th>Boundary</th>
<th>Responsibility</th>
<th>What must remain intact</th>
</tr>
</thead>
<tbody><tr>
<td>ReplayKit callback</td>
<td>Identify source</td>
<td>Video versus app versus mic</td>
</tr>
<tr>
<td>Gain adjustment</td>
<td>Apply that source’s setting</td>
<td>Timing and sample format</td>
</tr>
<tr>
<td>Writer dispatch</td>
<td>Choose capture function</td>
<td>Original buffer type</td>
</tr>
<tr>
<td>Finalization</td>
<td>Finish the local asset</td>
<td>A playable file, not merely a path</td>
</tr>
</tbody></table>
<h2>Zero gain is different from a small gain</h2>
<p>The handler tries a volume-adjusted copy when gain is not one. If that transformation fails and the requested volume is zero, it tries to create a silent buffer. If silence creation also fails, it returns without writing the buffer.</p>
<p>Here is the policy in condensed pseudocode, not a drop-in ReplayKit implementation:</p>
<pre><code class="language-text">if transformation succeeds:
    write transformed buffer
else if requested gain is zero:
    if silent copy succeeds: write silence
    else: discard buffer
else:
    write original buffer
</code></pre>
<p>That last nonzero case is a fidelity compromise: requested gain may not be honored. The zero case is a privacy boundary. Writing the original buffer after the user muted it would violate the user’s explicit choice. The implementation treats those failures differently.</p>
<h2>A floating camera is a separate concern</h2>
<p>Reel also offers a picture-in-picture camera workflow. A camera preview, a system PiP surface, and an encoded recording are different objects with different lifetimes. A visible camera window alone is not proof that the exported clip contains the intended image and audio.</p>
<p>For verification, use a short recording with identifiable synthetic app sound and a spoken microphone marker. Inspect the exported MP4, then repeat with each source muted individually. Include interruption and failed-transform cases in tests. These are suggested checks, not performance results claimed from this code review.</p>
<p>The broader lesson is to preserve source identity until the last component that needs it. Mixing early makes later controls harder to explain and harder to verify. For the product workflow and alternatives, see <a href="https://reel-screen-recorder.arte-soft.com/compare/">Reel’s recording comparisons</a>.</p>
<p>Available on <a href="https://apps.apple.com/us/app/id6758941990?pt=127093297&amp;ct=article_reel&amp;mt=8">Reel Screen Recorder on the App Store</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Incremental Dependency-Graph Recalculation for 184 Excel Formulas in a Browser Web Worker]]></title><description><![CDATA[How we keep large .xlsx workbooks responsive at 60fps in the browser by separating incremental precedent BFS from dynamic-array spill passes inside a…]]></description><link>https://artesoft.hashnode.dev/incremental-dependency-graph-recalculation-for-184-excel-formulas-in-a-browser-web-worker</link><guid isPermaLink="true">https://artesoft.hashnode.dev/incremental-dependency-graph-recalculation-for-184-excel-formulas-in-a-browser-web-worker</guid><dc:creator><![CDATA[mincho]]></dc:creator><pubDate>Sun, 08 Nov 2026 11:02:50 GMT</pubDate><content:encoded><![CDATA[<p>Spreadsheet UIs fail a simple latency test when built naively in JavaScript: the moment a user edits cell <code>B4</code> in a 25,000-cell <code>.xlsx</code> model, evaluating every formula on the main thread drops frames and freezes keyboard navigation.</p>
<p>When building <strong><a href="https://sheets.arte-soft.com/">Arte Sheets</a></strong>—a local-first browser spreadsheet supporting <strong>184 Excel-compatible functions</strong>, dynamic arrays, Pivot Tables, and <code>.xlsx</code> / <code>.ods</code> / <code>.csv</code> / <code>.tsv</code> import/export without requiring an account—we moved formula evaluation and file conversion into dedicated Web Workers.</p>
<p>Moving work off the main thread is only half the fix. If the worker still recalculates all 10,000 formulas on every keystroke, rapid edits queue up behind stale worker messages. Here is how we designed a hybrid <strong>incremental precedent-closure + dynamic-spill fallback</strong> engine in under 60 lines of worker coordination code.</p>
<hr />
<h2>Problem 1: Why Pure Topological DAGs Break on Real Spreadsheets</h2>
<p>In textbook spreadsheet tutorials, cell dependencies form a static Directed Acyclic Graph (DAG):</p>
<ul>
<li><code>C2 = A2 * B2</code> depends statically on <code>A2</code> and <code>B2</code>.</li>
<li>Edit <code>A2</code>, walk outgoing edges from <code>A2</code>, recalculate <code>C2</code>, done.</li>
</ul>
<p>Real-world <code>.xlsx</code> workbooks immediately break static DAG assumptions in three ways:</p>
<ol>
<li><strong>Volatile &amp; Dynamic-Reference Functions:</strong> <code>INDIRECT()</code>, <code>OFFSET()</code>, <code>NOW()</code>, <code>TODAY()</code>, <code>RAND()</code>, and <code>RANDBETWEEN()</code> either change their target coordinates at runtime or mutate without any cell edit.</li>
<li><strong>Dynamic Array Spills:</strong> Modern Excel functions like <code>FILTER()</code>, <code>UNIQUE()</code>, <code>SORT()</code>, <code>SORTBY()</code>, <code>SEQUENCE()</code>, and <code>TRANSPOSE()</code> write output into neighboring empty cells (<code>spill</code> regions) whose dimensions depend on runtime data values.</li>
<li><strong>Structural Mutations:</strong> Renaming a sheet, changing merged regions, or updating named ranges invalidates range resolution globally.</li>
</ol>
<p>If you try to handle <code>OFFSET</code> and <code>FILTER</code> spills inside an incremental single-cell walker, subtle stale-cache bugs creep in. Conversely, if you fall back to full recalculation for every workbook, simple financial sheets feel sluggish.</p>
<hr />
<h2>Problem 2: Splitting Safe Incremental Edits from Spill/Volatile Passes</h2>
<p>Inside our calculation worker, we classify every formula cell on upsert using a fast regex check for volatile and dynamic-spill functions, tracking them in an <code>unsafe</code> set alongside sheet <code>spillOwners</code>:</p>
<pre><code class="language-javascript">const DYNAMIC_OR_VOLATILE =
  /\b(INDIRECT|OFFSET|NOW|TODAY|RAND|RANDBETWEEN|SEQUENCE|FILTER|UNIQUE|SORT|SORTBY|TRANSPOSE|RANDARRAY)\s*\(/i;

// During cell upsert in the worker:
const isFormula = typeof cell.raw === 'string' &amp;&amp; cell.raw.startsWith('=');
const identity = `${sheet.id}!${key}`;

if (isFormula &amp;&amp; DYNAMIC_OR_VOLATILE.test(cell.raw)) {
  unsafe.add(identity);
} else {
  unsafe.delete(identity);
}
</code></pre>
<p>When an edit message arrives from the UI coordinator, the worker decides in <code>O(1)</code> whether it must run a full topological pass or can execute a targeted incremental closure:</p>
<pre><code class="language-javascript">const nextLayout = JSON.stringify([
  data.sheets.map(s =&gt; [s.id, s.name, s.mergedRegions]),
  data.namedRanges,
  data.locale,
  data.currency,
]);

const needsFullPass =
  !store ||
  data.reset ||
  store.workbookId !== data.workbookId ||
  layout !== nextLayout ||
  unsafe.size &gt; 0 ||
  store.sheets.some(s =&gt; s.spillOwners.size &gt; 0);
</code></pre>
<hr />
<h2>Problem 3: Closing the Precedent Graph Before Evaluation</h2>
<p>For the common case—workbooks filled with <code>SUM</code>, <code>XLOOKUP</code>, <code>INDEX</code>/<code>MATCH</code>, <code>SUMIFS</code>, <code>IFERROR</code>, <code>PMT</code>, and standard arithmetic—<code>needsFullPass</code> is <code>false</code>.</p>
<p>Instead of evaluating dependents immediately as we discover them (which risks reading half-updated intermediate cells in diamond dependencies like <code>A1 -&gt; B1, C1 -&gt; D1</code>), we <strong>first close the transitive dirty set</strong> across recorded range precedents <code>[r1..r2, c1..c2]</code>, and then evaluate dirty formulas cleanly:</p>
<pre><code class="language-javascript">// Close the dependency graph before evaluation so chains see fresh values.
for (let i = 0; i &lt; points.length; i++) {
  const [sheetName, key] = points[i];
  const [r, c] = key.split(',').map(Number);

  for (const [dependent, ranges] of store.precedents) {
    if (dirty.has(dependent)) continue;

    const hitsRange = ranges.some(
      x =&gt; x.sheetName === sheetName &amp;&amp;
           r &gt;= x.r1 &amp;&amp; r &lt;= x.r2 &amp;&amp;
           c &gt;= x.c1 &amp;&amp; c &lt;= x.c2
    );

    if (hitsRange) {
      dirty.add(dependent);
      const sep = dependent.lastIndexOf('!');
      points.push([dependent.slice(0, sep), dependent.slice(sep + 1)]);
    }
  }
}
</code></pre>
<p>And if a newly edited formula introduces a dynamic spill mid-evaluation, <code>store._needsPass</code> flips to <code>true</code>, seamlessly escalating to <code>store.recalculateAllSync()</code> before posting the delta payload back to the main thread.</p>
<hr />
<h2>Minimal Worker Payloads: Ship Only Formula Outputs, Not Whole Grids</h2>
<p>Posting an entire <code>WorkbookStore</code> back across <code>postMessage</code> via structured cloning can easily serialize megabytes of formatting metadata on every edit. Instead, the worker returns a compact patch containing only:</p>
<ul>
<li><code>id</code>: monotonic request ID so the UI coordinator ignores superseded calculations</li>
<li><code>evaluated</code>: exact count of cells recomputed (great for live telemetry &amp; regression tests)</li>
<li><code>values</code>: <code>[cellKey, computedValue]</code> tuples strictly for formula cells</li>
<li><code>spill</code> &amp; <code>spillOwners</code>: active dynamic array regions</li>
</ul>
<hr />
<h2>Browser Spreadsheet Trade-Offs: Cloud SaaS vs. Local-First Worker Engine</h2>
<p>Designing around Web Workers, <code>IndexedDB</code>, and an offline <code>Service Worker</code> changes the operational profile compared to cloud-only spreadsheets:</p>
<table>
<thead>
<tr>
<th align="left">Capability</th>
<th align="left">Cloud-First Browser Sheets</th>
<th align="left">Desktop Suite Install</th>
<th align="left">Arte Sheets (<code>sheets.arte-soft.com</code>)</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>Time to First Edit</strong></td>
<td align="left">Requires account login &amp; cloud doc creation</td>
<td align="left">Multi-GB desktop install + license</td>
<td align="left">Instant in browser, zero sign-up required</td>
</tr>
<tr>
<td align="left"><strong>Formula Execution</strong></td>
<td align="left">Remote server + client hybrid</td>
<td align="left">Native local threads</td>
<td align="left">Dedicated Web Worker (184 Excel functions + spills)</td>
</tr>
<tr>
<td align="left"><strong>File I/O (<code>.xlsx</code>, <code>.ods</code>, <code>.csv</code>)</strong></td>
<td align="left">Uploads file to vendor cloud first</td>
<td align="left">Local disk</td>
<td align="left">Local worker parsing + <code>IndexedDB</code> persistence</td>
</tr>
<tr>
<td align="left"><strong>Offline Availability</strong></td>
<td align="left">Requires browser extension / sync setup</td>
<td align="left">Full offline</td>
<td align="left">Built-in Service Worker offline caching</td>
</tr>
<tr>
<td align="left"><strong>Optional Cloud Sync</strong></td>
<td align="left">Mandatory vendor storage</td>
<td align="left">Vendor cloud drive</td>
<td align="left">Optional EU (Germany) cloud sync on Pro</td>
</tr>
</tbody></table>
<hr />
<h2>Key Takeaways for Heavy Browser Apps</h2>
<ol>
<li><strong>Separate file parsing and formula calculation into independent workers.</strong> Importing a multi-sheet <code>.xlsx</code> archive shouldn't block UI scrolling or incremental cell edits.</li>
<li><strong>Be honest about dynamic dependencies.</strong> Fast-path static range precedents (<code>SUMIFS</code>, <code>XLOOKUP</code>), and explicitly gate volatile/spill functions (<code>INDIRECT</code>, <code>OFFSET</code>, <code>FILTER</code>) into full-pass verification rather than risking silent stale values.</li>
<li><strong>Return compact value diffs over <code>postMessage</code>.</strong> Keep cell style trees and DOM layout state on the main thread; let the worker own pure values and precedent ranges.</li>
</ol>
<p>You can test the live worker engine directly in your browser (no sign-up) at <strong><a href="https://sheets.arte-soft.com/">sheets.arte-soft.com</a></strong>, or browse detailed formula and workflow comparisons against Excel, Google Sheets, LibreOffice Calc, and Numbers at <strong><a href="https://sheets.arte-soft.com/compare/">sheets.arte-soft.com/compare/</a></strong>.</p>
]]></content:encoded></item><item><title><![CDATA[Mining 14,351 Chess Puzzles with Stockfish 18 (and the Illegal-FEN Trap in chess.js)]]></title><description><![CDATA[How we built an automated MultiPV puzzle-mining and verification pipeline with Stockfish 18—and caught an illegal-position edge case that chess.js accepts…]]></description><link>https://artesoft.hashnode.dev/mining-14-351-chess-puzzles-with-stockfish-18-and-the-illegal-fen-trap-in-chess-js</link><guid isPermaLink="true">https://artesoft.hashnode.dev/mining-14-351-chess-puzzles-with-stockfish-18-and-the-illegal-fen-trap-in-chess-js</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Swift]]></category><category><![CDATA[open source]]></category><category><![CDATA[GameDev]]></category><dc:creator><![CDATA[mincho]]></dc:creator><pubDate>Thu, 05 Nov 2026 21:07:58 GMT</pubDate><content:encoded><![CDATA[<p>When we started building <strong><a href="https://brasspawn.com/">Brass Pawn</a></strong> (<a href="https://github.com/minchopm/chess-trainer">source on GitHub, AGPLv3</a>), we needed a large offline tactics, positional judgement, and endgame dataset that ran completely on-device—both in the browser and natively on iPhone, iPad, and Mac.</p>
<p>Transcribing chess puzzles by hand or scraping unverified problem collections breaks down fast. Two failure modes show up immediately in real training:</p>
<ol>
<li><strong>Cooked solutions:</strong> A position has a flashy sacrifice, so a human annotator marks it as winning—missing a quiet defensive resource at depth 18, or missing a <em>second</em> equally winning move that punishes a student for playing an objectively strong alternative.</li>
<li><strong>Horizon flips:</strong> A shallow engine pass (depth 10–12) flags a move as <code>+2.10</code>, only for depth 22 to reveal perpetual check (<code>0.00</code>).</li>
</ol>
<p>Instead of transcribing puzzles, we built a deterministic mining and independent verification pipeline powered by <strong>Stockfish 18</strong>. Along the way, we hit a subtle FEN validation edge case in <code>chess.js</code> that silently breaks UCI engines.</p>
<hr />
<h2>Stage 1: Mining for Uniqueness, Not Just Blunders</h2>
<p>A good tactics puzzle isn't simply "someone blundered in a game." The actual mathematical requirement for a fair puzzle is <strong>uniqueness</strong>: <em>exactly one legal move must preserve or seize a decisive advantage, while every alternative drops significant win probability.</em></p>
<p>Our generator runs a multi-stage filter:</p>
<ol>
<li><strong>Human-strength self-play seeding:</strong> Stockfish plays games at calibrated Elo bands (1320–2500), randomizing among shallow top candidates in the opening so positions diversify across pawn structures rather than repeating five mainlines.</li>
<li><strong>Shallow two-line screen (Depth 12, <code>MultiPV = 2</code>):</strong> Every candidate position is evaluated with two principal variations. Most positions either have multiple good moves or zero good moves; both are discarded cheaply here.</li>
<li><strong>Deep uniqueness confirmation (Depth 20, <code>MultiPV = 2</code>):</strong> Survivors are re-searched at depth 20. We only keep a candidate if <code>bestMove</code> beats the second-best move by <strong>at least 140 centipawns</strong> (<code>&gt;= 140 cp</code>) and achieves a concrete evaluation swing.</li>
<li><strong>Branchless line extension:</strong> We step forward along the principal variation, extending the puzzle ply by ply <em>only</em> while every solver turn continues to have a single uniquely best reply. The moment two moves become interchangeable, the puzzle terminates cleanly.</li>
</ol>
<p>That pipeline produced <strong>14,351 verified tactics puzzles</strong> spanning 760–2899 puzzle rating, alongside 116 positional judgement exercises (where <em>no</em> forced tactical win exists) and canonical endgame drills.</p>
<hr />
<h2>Stage 2: Independent Depth-22 Verification &amp; Time Caps</h2>
<p>Never trust the same search depth that generated your dataset to grade your dataset. In <a href="https://github.com/minchopm/chess-trainer/blob/main/scripts/verify-puzzles.mjs"><code>scripts/verify-puzzles.mjs</code></a>, we spin up a fresh Stockfish instance and re-verify every puzzle two plies deeper (<code>depth 22</code>) with a per-position wall-clock ceiling (<code>6000 ms</code>) so fortress-like positions cannot stall batch runs for hours:</p>
<pre><code class="language-javascript">const { lines } = await engine.analyse(puzzle.fen, {
  depth: DEPTH,       // Default: 22 (two plies deeper than mining)
  movetime: TIME_CAP, // 6,000 ms ceiling for pathological positions
  multipv: 2,
});

if (lines.length &lt; 2) {
  problems.push('only one legal move');
} else {
  if (lines[0].move !== puzzle.solution[0]) {
    problems.push(`deeper search prefers ${lines[0].move} over ${puzzle.solution[0]}`);
  }
  const gap = cpFor(lines[0].score, solverColor) - cpFor(lines[1].score, solverColor);
  if (gap &lt; 100) {
    problems.push(`solution is not unique (gap ${gap}cp)`);
  }
}
</code></pre>
<h3>Why Endgames Need Time Budgets Instead of Fixed Depth</h3>
<p>For endgame drills (rook vs. pawn, opposition, Lucena, Philidor, two bishops), fixed search depth fails in both directions:</p>
<ul>
<li>In a bare <code>KR vs K</code> position, <strong>depth 28</strong> finishes in milliseconds.</li>
<li>In a minor-piece endgame with pawns on both wings, <strong>depth 28</strong> explodes combinatorially and times out.</li>
</ul>
<p>Switching endgame verification from fixed plies to a fixed <strong><code>4,000 ms</code> movetime budget</strong> gave consistent tablebase-like stability across all drills, asserting <code>cp &gt;= 400</code> (or forced mate) on positions labelled <code>"win"</code> and <code>|cp| &lt;= 150</code> on positions labelled <code>"draw"</code>.</p>
<hr />
<h2>The Illegal-FEN Trap <code>chess.js</code> Won't Catch</h2>
<p>When curating hand-crafted endgame drills or mutating FEN strings in tests, there is one mistake that is shockingly easy to make: placing a piece so that <strong>the side <em>not</em> to move is already in check</strong>.</p>
<p>No legal sequence of chess moves can ever reach such a position—because the player who just moved would have left (or moved) their own king into check.</p>
<p>Here is the catch:</p>
<ul>
<li><code>new Chess(fen)</code> in <code>chess.js</code> <strong>accepts these FENs without throwing</strong>.</li>
<li>When you pass that same FEN to Stockfish over UCI (<code>position fen ...</code> -&gt; <code>go movetime 4000</code>), Stockfish immediately responds with <code>bestmove (none)</code>—which looks identical to an engine crash or pipe timeout if you aren't checking for it!</li>
</ul>
<p>The fix is a 10-line helper that flips the active turn token in the FEN, clears the en-passant target square, and asks <code>chess.js</code> whether the swapped side is in check:</p>
<pre><code class="language-javascript">import { Chess } from 'chess.js';

/**
 * True when the side *not* to move is in check — an illegal position
 * that no game can reach. chess.js accepts these, and Stockfish replies
 * with "bestmove (none)", which is easy to misread as an engine crash.
 */
function opponentInCheck(fen) {
  const parts = fen.split(' ');
  parts[1] = parts[1] === 'w' ? 'b' : 'w';
  parts[3] = '-'; // Clear en-passant target before swapping active color
  try {
    return new Chess(parts.join(' ')).isCheck();
  } catch {
    return false;
  }
}
</code></pre>
<p>Adding <code>opponentInCheck(fen)</code> before invoking UCI saved hours of debugging mysterious empty engine evaluations during endgame drill authoring.</p>
<hr />
<h2>Grading Human Moves: Win Probability vs. Raw Centipawns</h2>
<p>Raw centipawn loss (<code>CPL</code>) is a poor teacher for human players because engine centipawns are non-linear:</p>
<table>
<thead>
<tr>
<th align="left">Position Transition</th>
<th align="left">Raw Centipawn Delta</th>
<th align="left">Practical Impact on Game Outcome</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><code>+9.00</code> → <code>+7.20</code></td>
<td align="left"><code>-180 cp</code></td>
<td align="left"><strong>Negligible</strong> — both positions are trivially winning</td>
</tr>
<tr>
<td align="left"><code>+0.30</code> → <code>-1.50</code></td>
<td align="left"><code>-180 cp</code></td>
<td align="left"><strong>Game-losing blunder</strong> — throws away equality into a lost position</td>
</tr>
<tr>
<td align="left"><code>+2.80</code> → <code>+0.40</code></td>
<td align="left"><code>-240 cp</code></td>
<td align="left"><strong>Missed win</strong> — converts a decisive edge into a probable draw</td>
</tr>
</tbody></table>
<p>In Brass Pawn's coached play and native iOS/macOS trainer (which pairs <strong>Stockfish 18</strong> with <strong>Reckless</strong> on-device), moves are graded by <strong>win-probability loss</strong> rather than raw centipawn deltas. Trading down into a clean, slightly lower-centipawn king-and-pawn endgame isn't penalized as an "inaccuracy" when win probability stays pinned near 99%.</p>
<hr />
<h2>Architecture Snapshot: Cloud Chess Platforms vs. Local Engine Study</h2>
<p>Because the full puzzle set and neural engine run locally, there is zero network round-trip per ply and zero account wall:</p>
<table>
<thead>
<tr>
<th align="left">Architectural Layer</th>
<th align="left">Typical Cloud Chess Portal</th>
<th align="left">Brass Pawn (Web &amp; Native iOS/macOS)</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>Tactics Verification</strong></td>
<td align="left">Mixed community / game-imported</td>
<td align="left">14,351 MultiPV-screened &amp; depth-22 verified puzzles</td>
</tr>
<tr>
<td align="left"><strong>Positional Training</strong></td>
<td align="left">Usually absent or video-only</td>
<td align="left">116 engine-screened non-tactical judgement drills</td>
</tr>
<tr>
<td align="left"><strong>Engine Runtime</strong></td>
<td align="left">Browser WASM or metered cloud server</td>
<td align="left">Local Stockfish 18 (Web/App) + Reckless on iOS/macOS</td>
</tr>
<tr>
<td align="left"><strong>Move Grading Metric</strong></td>
<td align="left">Often centipawn thresholds</td>
<td align="left">Win-probability delta per ply</td>
</tr>
<tr>
<td align="left"><strong>Offline &amp; Telemetry</strong></td>
<td align="left">Account login required for most modes</td>
<td align="left">100% offline capable, zero ads, zero ad tracking</td>
</tr>
</tbody></table>
<hr />
<h2>Try the Code or Run Your Own Verification Pass</h2>
<ul>
<li><strong>Open-Source Repo (AGPLv3):</strong> <a href="https://github.com/minchopm/chess-trainer">github.com/minchopm/chess-trainer</a> (<code>npm install &amp;&amp; npm start</code>, or inspect <code>scripts/verify-puzzles.mjs</code>)</li>
<li><strong>Live Web &amp; Native App Overview:</strong> <a href="https://brasspawn.com/">brasspawn.com</a> (also available natively on <a href="https://apps.apple.com/us/app/brass-pawn-chess-trainer/id6803566012">iPhone, iPad &amp; Mac</a>)</li>
<li><strong>Technical Chess Guides &amp; Trainer Benchmarks:</strong> <a href="https://brasspawn.com/guides">brasspawn.com/guides</a> and <a href="https://brasspawn.com/compare">brasspawn.com/compare</a></li>
</ul>
<p>If you maintain chess tooling or puzzle generators in JavaScript or Swift, drop <code>opponentInCheck()</code> into your FEN ingestion tests—it catches broken test fixtures before they ever reach UCI.</p>
]]></content:encoded></item></channel></rss>