<?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: Emmanuel</title>
    <description>The latest articles on DEV Community by Emmanuel (@emmnanuel1812).</description>
    <link>https://dev.to/emmnanuel1812</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%2F4125014%2Fbce4da3a-a089-47e7-942f-324022380589.jpeg</url>
      <title>DEV Community: Emmanuel</title>
      <link>https://dev.to/emmnanuel1812</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/emmnanuel1812"/>
    <language>en</language>
    <item>
      <title>Our deploy timed out: one static worker, 60 seconds per page</title>
      <dc:creator>Emmanuel</dc:creator>
      <pubDate>Thu, 05 Nov 2026 19:05:37 +0000</pubDate>
      <link>https://dev.to/emmnanuel1812/our-deploy-timed-out-one-static-worker-60-seconds-per-page-5fem</link>
      <guid>https://dev.to/emmnanuel1812/our-deploy-timed-out-one-static-worker-60-seconds-per-page-5fem</guid>
      <description>&lt;p&gt;I run a site with practice exams for Dutch certification exams. Next to the exams there are guides: how buoys work for the small boating licence, which sound signals you need to know, how to book the exam. On 24 October a deploy failed, and the cause was a function that every one of those pages called without anyone thinking about it.&lt;/p&gt;

&lt;p&gt;Here's what the build log said, what was actually slow, and the rule we wrote down afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the build log said
&lt;/h2&gt;

&lt;p&gt;At the time we were on Vercel's Hobby plan. The log listed the build machine as "2 cores, 8 GB", and Next.js 16.4.2 generated the static pages with a single worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Generating static pages using 1 worker (0/187) ...
Failed to build /(nl)/(site)/klein-vaarbewijs-1/betonning/page: /klein-vaarbewijs-1/betonning (attempt 1 of 3) because it took more than 60 seconds. Retrying again shortly.
...
Failed to build /(nl)/(site)/klein-vaarbewijs-1/bpr-en-rpr/page: /klein-vaarbewijs-1/bpr-en-rpr after 3 attempts.
Export encountered an error on /(nl)/(site)/klein-vaarbewijs-1/bpr-en-rpr/page: /klein-vaarbewijs-1/bpr-en-rpr, exiting the build.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next.js gives each page 60 seconds to render during static generation. That's the default of &lt;code&gt;staticPageGenerationTimeout&lt;/code&gt;. A page that takes longer is retried, up to three attempts, and if it still doesn't finish, the whole build fails.&lt;/p&gt;

&lt;p&gt;Eight guides for the Klein Vaarbewijs 1 (the small boating licence) ran past the limit. One of them, the guide on &lt;a href="https://examenzaal.nl/klein-vaarbewijs-1/bpr-en-rpr/" rel="noopener noreferrer"&gt;BPR and RPR&lt;/a&gt; (the inland shipping rules), failed all three times. The deploy stopped after about 5 minutes 40 seconds, and production kept serving the previous build.&lt;/p&gt;

&lt;p&gt;These are not heavy pages. The &lt;a href="https://examenzaal.nl/klein-vaarbewijs-1/betonning/" rel="noopener noreferrer"&gt;guide on buoys&lt;/a&gt; is text, some images and links to related guides. None of that should take a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  It had been close for a while
&lt;/h2&gt;

&lt;p&gt;When I compared the logs of the deploys before it, the problem wasn't new:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An earlier deploy already hit "took more than 60 seconds" on four reference pages about navigation lights, such as &lt;a href="https://examenzaal.nl/naslag/lichten/groot-motorschip/" rel="noopener noreferrer"&gt;the lights of a large motor vessel&lt;/a&gt;. They passed on a retry, and that build took about 18 minutes.&lt;/li&gt;
&lt;li&gt;A redeploy of the same code built 184 pages with no timeouts, in 13 minutes.&lt;/li&gt;
&lt;li&gt;The failing deploy had 187 pages, and this time the retries didn't save it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the build had been sitting right at the limit, passing or failing by luck. A plain redeploy might have passed, and that would have hidden the problem until the next batch of pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cause: every page re-read the whole content tree
&lt;/h2&gt;

&lt;p&gt;Every question on the site comes with JSON files: a draft, a check by a second model and a verification by a third. They ship with the deployment, in &lt;code&gt;content/checks/&amp;lt;exam&amp;gt;/&lt;/code&gt; and &lt;code&gt;content/verifications/&amp;lt;exam&amp;gt;/&lt;/code&gt;. A guide only goes live once enough of its exam's questions have passed both checks, so deciding which guides exist means reading those files.&lt;/p&gt;

&lt;p&gt;The function that decides is &lt;code&gt;publishedTopics()&lt;/code&gt;. It's shared by the sitemap and the navigation, and every page renders the navigation. For each of about 100 topics, it evaluated the topic's state like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getTopicState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getTopic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;evaluateTopic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;loadCheckFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;examId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;loadVerificationFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;examId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And this was the loader underneath:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readDir&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is wrong with either function on its own. The problem is how often they ran. An exam's check and verification files were read, parsed and validated once for every guide of that exam, just to render one navigation. Then again for the next page, and the next. With one worker, every page waits for the one before it.&lt;/p&gt;

&lt;p&gt;Turbopack had even pointed at the file, in the same log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;./lib/content/load.ts:19:18
Warning: The file pattern (&amp;lt;dynamic&amp;gt; '/content/checks/' &amp;lt;dynamic&amp;gt; | …) matches … files
Overly broad patterns can lead to build performance issues and over bundling.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That warning is about bundling (it can't narrow down a dynamic &lt;code&gt;fs&lt;/code&gt; path), not about render time. But it was the right file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: parse once per process
&lt;/h2&gt;

&lt;p&gt;The content files can't change inside a deployment, so there's no reason to parse them more than once. &lt;code&gt;lib/content/load.ts&lt;/code&gt; now keeps the parsed directories in a &lt;code&gt;Map&lt;/code&gt; for the life of the process, in production only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readDir&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))))&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this works in Next.js:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;next build&lt;/code&gt; runs with &lt;code&gt;NODE_ENV=production&lt;/code&gt;, so the cache is on during static generation, not only on the live site.&lt;/li&gt;
&lt;li&gt;Module state lives as long as the worker process. With one static worker, the first page pays for parsing and every page after it reuses the result.&lt;/li&gt;
&lt;li&gt;In development and tests the cache is off, because there the files do change. A test pins both behaviours (trimmed):
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;vi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stubEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NODE_ENV&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;loadCheckFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nl-kvb1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;loadCheckFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nl-kvb1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// after vi.unstubAllEnvs():&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;loadCheckFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nl-kvb1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;loadCheckFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nl-kvb1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The numbers: ten calls to &lt;code&gt;publishedTopics()&lt;/code&gt; went from 3,445 ms to 187 ms. Our local build rehearsal dropped from about 10 minutes to under 4.&lt;/p&gt;

&lt;p&gt;We also raised the timeout, with a comment that says what it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The real fix is the parsed-content cache in lib/content/load.ts; this is headroom.&lt;/span&gt;
&lt;span class="nx"&gt;staticPageGenerationTimeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Raising the timeout alone would have made the build pass and stay slow. Every new guide would have pushed it a bit closer to 180 seconds instead of 60. I wanted the timeout as a seatbelt, not as the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same pattern at runtime
&lt;/h2&gt;

&lt;p&gt;The build was just where it showed first. Later that day we found the same pattern on the dynamic pages: the exam start, the result page, checkout and the account page evaluated topic states on every request, about 0.7 seconds of work for all topics. A second cache, one level up, now keeps the evaluated state per topic in production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getTopicState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;topicStateCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getTopic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluateTopic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;loadCheckFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;examId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;loadVerificationFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;examId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;topicStateCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The rule we wrote down
&lt;/h2&gt;

&lt;p&gt;Our decision log now says: &lt;strong&gt;nothing a page renders may re-read the whole content tree per call. Load once, then reuse.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That isn't specific to JSON files. Anything in a shared layout, header or navigation runs once for every page in the build. If it does I/O or parsing that grows with your content, build time grows with pages times content. The site has grown since that day (the sitemap lists 193 URLs now), and with the old loader every one of those pages would have paid for it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone else on Next.js 16
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read the "Generating static pages using N worker" line.&lt;/strong&gt; On a small build machine N can be 1, and then every slow page adds straight to the build time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat "took more than 60 seconds … Retrying" as a failure,&lt;/strong&gt; even when the retry passes. Ours passed on retry before the day it didn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look for work in shared components that grows with your content.&lt;/strong&gt; Navigation and sitemaps are the usual suspects, because every page or every build touches them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache what can't change inside a deployment&lt;/strong&gt; at module level, in production only, and test that development still reads fresh.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raise &lt;code&gt;staticPageGenerationTimeout&lt;/code&gt; as headroom, not as the fix,&lt;/strong&gt; and say so in the comment next to it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We've since moved to Vercel Pro, but the rule stays. More build power would have hidden the problem, not fixed it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this post with help from an AI assistant. The log lines, code and numbers come from our own repository.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>vercel</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Six practice exams from a thin question bank</title>
      <dc:creator>Emmanuel</dc:creator>
      <pubDate>Thu, 05 Nov 2026 19:04:35 +0000</pubDate>
      <link>https://dev.to/emmnanuel1812/six-practice-exams-from-a-thin-question-bank-24f4</link>
      <guid>https://dev.to/emmnanuel1812/six-practice-exams-from-a-thin-question-bank-24f4</guid>
      <description>&lt;p&gt;I build practice exams for Dutch certification exams. Each exam has a fixed set of practice exams, "Oefenexamen 1" to "6", and each one follows the official format: one question per slot, where a slot is a topic with a set number of points.&lt;/p&gt;

&lt;p&gt;For most exams that's easy. The hospitality licence (SVH) has 40 slots with 6 to 8 verified questions in each, so six forms that never share a question come out on their own.&lt;/p&gt;

&lt;p&gt;The Klein Vaarbewijs 2, the Dutch boating licence for open and tidal water, was different. Its 27 slots hold between 0 and 8 verified questions. The GPS slot has 2. The tide calculation slot has 2. One slot has none. Here's how we still got six forms out of it, and why they're a JSON file in the repo instead of a random draw.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the engine did before
&lt;/h2&gt;

&lt;p&gt;The original form builder is simple. Per slot, shuffle the questions with a fixed seed and deal them out: form 1 gets the first, form 2 the second, and so on. First it checks that every slot has enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Builds the fixed practice exams ("Oefenexamen 1..n"). Forms never share a question,
 * and the same pool + seed always gives the same forms, so a form number stays stable.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;assembleForms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exam&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExamDefinition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PoolQuestion&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ExamItem&lt;/span&gt;&lt;span class="p"&gt;[][]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;exam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fixedCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;short&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;coverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exam&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;have&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;needForForms&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;short&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`not enough questions for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; forms: ...`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...seeded shuffle per slot, then deal&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the KVB2 bank that throws, and every form shows as "binnenkort" (coming soon). Waiting for 6 verified questions per slot would take weeks, and for some slots it wasn't going to happen at all. The GPS slot failed four drafting rounds in our checks: the new questions had cues that gave the answer away, or relied on inference.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious fix, and why I didn't use it
&lt;/h2&gt;

&lt;p&gt;Let questions repeat. Keep the runtime draw, add a counter, and allow each question in at most N forms. That's a small change to the function above.&lt;/p&gt;

&lt;p&gt;The problem is what the runtime knows about a question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;PoolQuestion&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;topicId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;code&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AnswerKey&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No sources. And two of our rules need them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source clashes.&lt;/strong&gt; Two questions that rest on the same sentence of the same source don't go in one form. One tends to answer the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclusive pairs.&lt;/strong&gt; Some questions give each other away without sharing a sentence. KVB2 lists 8 such pairs. One example: a few slot 17 questions ask what ALAT or NAP is (two reference levels for water height), and a slot 18 question rests on knowing both. Put them in the same form and the second one is free.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A random draw with a max-uses counter keeps the counting rule and loses both of these.&lt;/p&gt;

&lt;h2&gt;
  
  
  Assembling the forms offline
&lt;/h2&gt;

&lt;p&gt;So KVB2's forms are assembled from the content files, where every draft still has its sources, and written to &lt;code&gt;content/forms/nl-kvb2.json&lt;/code&gt; as question codes. The exam definition reads that file, and the engine serves exactly those forms.&lt;/p&gt;

&lt;p&gt;Each draft becomes a candidate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Candidate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="cm"&gt;/** Answer letter; "" for a question without one (a sleepvraag, ...). */&lt;/span&gt;
  &lt;span class="nl"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;units&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;sourceIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;units&lt;/code&gt; are the source sentences the draft rests on, found by matching its verbatim quote against the source text split into sentences and lines. Two candidates that share a unit clash.&lt;/p&gt;

&lt;p&gt;The assembler fills one form at a time, thinnest slot first. For each slot it ranks the candidates: least used so far, then the answer letter this form has the fewest of, then a seeded random tie-break. Then it filters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;fits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;clashes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;caps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exclusive&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;usable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mayUse&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mayUse&lt;/code&gt; is the use cap, with a per-slot override:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxUsesBySlot&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxUsesPerQuestion&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cap&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;used&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After a form is filled, a repair pass swaps questions from the most common answer letter to a rarer one, as long as the swap breaks no rule. The whole thing runs 200 times with different seeds and keeps the best result: fewest unfilled slots first, then the best key balance, then the least reuse. It's deterministic, so the same bank always gives the same forms.&lt;/p&gt;

&lt;p&gt;For KVB2 the call looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;assembly&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;assembleFixedForms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;forms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;caps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;kvb2Retirements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;caps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;exclusive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;kvb2Retirements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exclusive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KVB2_FORM_SEED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxUsesPerQuestion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KVB2_MAX_USES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 2&lt;/span&gt;
  &lt;span class="na"&gt;maxUsesBySlot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KVB2_USES_BY_SLOT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// { 18: 3, 23: 3 }&lt;/span&gt;
  &lt;span class="na"&gt;omitted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nlKvb2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;omittedSlots&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where the limits came from
&lt;/h2&gt;

&lt;p&gt;Each limit is a decision we wrote down, with a reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At most 2 forms per question&lt;/strong&gt; means a slot needs 3 verified questions to fill all six forms. That became the floor: below 3, the slot goes back to drafting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At most 3 forms&lt;/strong&gt; for slots 18 and 23, which got stuck at 2. Further drafting kept producing questions that failed the checks. Someone who does all six forms sees those questions three times. We decided that's better than leaving the slot empty or forcing in weaker questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One slot left out.&lt;/strong&gt; Slot 21 is a course calculation with wind drift, worth 3 points. No public source states the rule, and three drafting rounds failed. So it's omitted by decision. Each form has 26 questions instead of 27, scoring is on 47 points instead of 50, and the pass mark becomes 33 (70 %, rounded up). The &lt;a href="https://examenzaal.nl/klein-vaarbewijs-2/" rel="noopener noreferrer"&gt;Klein Vaarbewijs 2 page&lt;/a&gt; says so before anyone buys. The assembler takes &lt;code&gt;omitted&lt;/code&gt; as an option, so the slot isn't reported as a gap.&lt;/p&gt;

&lt;p&gt;The result from the current bank:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;6 forms × 26 questions = 156 places.&lt;/li&gt;
&lt;li&gt;139 different questions fill them: 126 used once, 9 twice, 4 three times.&lt;/li&gt;
&lt;li&gt;Forms 1 and 2 share no questions. Form 6 shares 8 with earlier forms.&lt;/li&gt;
&lt;li&gt;Every form has 7 or 8 each of a, b and c as the right answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the part that surprised me. The bank holds 156 eligible questions, exactly as many as there are places. It still can't make six forms without repeats, because slots with 7 or 8 questions have leftovers while slots 18 and 23 run dry. The total doesn't matter. The thinnest slot does.&lt;/p&gt;

&lt;p&gt;For SVH the same assembler runs only as a report. It confirms the bank supports six forms without sharing a question, and it writes a drafting brief: which slots are short, how many new drafts they need, and which answer letters would let every form balance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Forms must not drift
&lt;/h2&gt;

&lt;p&gt;Fixed forms have a second job: staying put. "Oefenexamen 3" has to be the same exam tomorrow as today, or a retake tells you nothing. Two tests guard that.&lt;/p&gt;

&lt;p&gt;The first pins the committed file to the assembly of the current bank:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;assembly&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;assembleKvb2Forms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;kvb2Bank&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nlKvb2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;kvb2FixedForms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;assembly&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a question is retired or a new one passes the checks, the assembly can change, and then the test fails until someone regenerates the file. So the forms change only in a commit that says so, never as a side effect.&lt;/p&gt;

&lt;p&gt;The second came with the engine change. To serve KVB2, the engine learned fixed assignments and omitted slots. Every other exam had to come out exactly as before. The test copies the old engine verbatim from the commit before the change and compares:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;20261020&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;assembleForms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exam&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;legacyAssembleForms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exam&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more details help. An attempt stores its own list of questions when it starts, so an exam in progress keeps its questions even if a form changes. And if a fixed form names a question that isn't in the database, that form isn't offered at all. No gap, no substitute.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;If a rule needs data the runtime doesn't have, do the draw where the data is&lt;/strong&gt;, and ship the result as a file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count the thinnest slot, not the bank.&lt;/strong&gt; A bank with enough questions in total can still fail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make caps per slot, and write down why&lt;/strong&gt; each one exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving a slot out is better than padding it.&lt;/strong&gt; Score on the points that are there and tell people before they pay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin generated output with a test&lt;/strong&gt;, so it changes only on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tide slots are the thinnest part of this exam. If you're curious what they cover, there's a &lt;a href="https://examenzaal.nl/klein-vaarbewijs-2/getijden/" rel="noopener noreferrer"&gt;guide to tides for the Klein Vaarbewijs 2&lt;/a&gt; (in Dutch). The roomier bank is &lt;a href="https://examenzaal.nl/sociale-hygiene/" rel="noopener noreferrer"&gt;Sociale Hygiëne&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this post with help from an AI assistant. The code, the numbers and the decisions come from our own repository.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>typescript</category>
      <category>testing</category>
      <category>education</category>
    </item>
    <item>
      <title>Our exam questions store buoys as data, and a broken spec shows text instead of a guessed picture</title>
      <dc:creator>Emmanuel</dc:creator>
      <pubDate>Thu, 05 Nov 2026 19:03:42 +0000</pubDate>
      <link>https://dev.to/emmnanuel1812/our-exam-questions-store-buoys-as-data-and-a-broken-spec-shows-text-instead-of-a-guessed-picture-1c6g</link>
      <guid>https://dev.to/emmnanuel1812/our-exam-questions-store-buoys-as-data-and-a-broken-spec-shows-text-instead-of-a-guessed-picture-1c6g</guid>
      <description>&lt;p&gt;I build practice exams for Dutch certification exams. One of them is the Dutch boating licence, and many of its questions are pictures: a buoy, a traffic sign on a bridge, the lights of a boat at night seen from one side. The questions are written by a language model.&lt;/p&gt;

&lt;p&gt;That's a risk you don't have with text. If a buoy is drawn with the wrong topmark, the question doesn't just look odd. It teaches the wrong rule, and the candidate learns it from us.&lt;/p&gt;

&lt;p&gt;So no question in our content contains an image. Each one contains a small typed spec, and our own SVG components draw the picture from it. Here's how that works, and the one rule I care most about: a spec that doesn't parse renders as plain text, never as a picture we guessed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just use images
&lt;/h2&gt;

&lt;p&gt;There were two obvious options, and our decision log lists both as rejected: free-form specs, and the official PNGs from the regulation.&lt;/p&gt;

&lt;p&gt;Images from an image model were never an option. Our imagery rules say why: generated pictures invent details, like a wrong colour on a buoy or a wrong number of lights, and you can't cite them as a source.&lt;/p&gt;

&lt;p&gt;The official PNGs would have been accurate. But a PNG is a fact you can't test. The rule ("a north cardinal mark has two black cones, both pointing up") lives in the pixels of each image, and in every question that uses one. With a spec, the rule lives in one place in code, and a test can check it. Copyright was never the issue: Dutch copyright law leaves laws and their annexes free (article 11 of the Auteurswet), so the drawings could have been used. It was a testing decision, not a legal one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a question stores
&lt;/h2&gt;

&lt;p&gt;The specs are zod schemas. This is the whole spec for a buoy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BuoySpec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;mark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BuoyMark&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;form&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;spar&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;topmark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="cm"&gt;/** Kenteken painted on the buoy, e.g. "HD 4". */&lt;/span&gt;
  &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BuoyMark&lt;/code&gt; is an enum of 12 marks (&lt;code&gt;lateral-right&lt;/code&gt;, &lt;code&gt;cardinal-n&lt;/code&gt;, &lt;code&gt;safe-water&lt;/code&gt; and so on). Notice what's missing: colour, shape and what the topmark looks like. The question says &lt;em&gt;which&lt;/em&gt; mark it is, not what it looks like. All visual kinds sit in one discriminated union, and every kind requires alt text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VisualSpecStrict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;discriminatedUnion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;kind&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vessel-lights&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VesselLightsSpec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;buoy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BuoySpec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="c1"&gt;// ...signs, day signals, sound signals, safety signs, chart tasks, chart symbols&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a question file, a buoy is just this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"visual"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"buoy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"spec"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mark"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cardinal-e"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"form"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"default"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"alt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Een zwart-geel-zwarte boei met twee zwarte kegels, de bases naar elkaar toe."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The rules live in the renderer
&lt;/h2&gt;

&lt;p&gt;The buoy component has one table, with one row per mark, and each row comes from the annex of the inland shipping regulations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RULES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;BuoyMark&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Rule&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lateral-right&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;can&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bands&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PAINT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;red&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
                     &lt;span class="na"&gt;topmark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cylinder&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PAINT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;red&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cardinal-e&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pillar&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bands&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="cm"&gt;/* black, yellow, black */&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                  &lt;span class="na"&gt;topmark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;two-cones&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bases-together&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;safe-water&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sphere&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bands&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vertical-red-white&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;topmark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Record&amp;lt;BuoyMark, Rule&amp;gt;&lt;/code&gt; means TypeScript won't compile if a mark is added to the enum without a rule. And some rules can't be overridden by the spec at all. In Dutch inland waters, a safe-water mark has no topmark, whatever the question asks for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Safe water never shows a topmark in BPR waters (bijlage 8, 6.3), whatever the spec says.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;topmark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;topmark&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;topmark&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Navigation lights go one step further. A light only shines over a fixed arc: a masthead light forward, a red sidelight on the port side, a stern light aft. Which lights you see depends on where you're standing. So the spec lists &lt;strong&gt;all&lt;/strong&gt; the lights a vessel carries, plus the view, and the code decides what's visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;visibleFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LightRole&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;View&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;masthead&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;masthead-aft&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stern&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sidelight-port&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;port&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sidelight-starboard&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;starboard&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stern&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stern&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One of our questions describes a small motor boat with a masthead light, two sidelights and a stern light, seen from straight behind, and asks which lights you see. The spec lists all four lights with &lt;code&gt;"view": "stern"&lt;/code&gt;. The model that wrote the question never decides which lights are visible. The renderer draws one white light, because that's what the arcs allow. The drawing and the answer key come from different places, so a wrong key can't come with a matching wrong picture.&lt;/p&gt;

&lt;p&gt;Seen head-on, the red light is on your &lt;em&gt;right&lt;/em&gt;, because the boat's port side faces you. That's the kind of detail that's easy to get wrong once and copy forever. Here it's one line in the renderer and one test.&lt;/p&gt;

&lt;h2&gt;
  
  
  A spec that doesn't parse is text
&lt;/h2&gt;

&lt;p&gt;This is the component that every question visual goes through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Visual&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;visual&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;visual&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;VisualSpecStrict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;visual&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;visual&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;visual&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;visual&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;visual&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Afbeelding&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"img"&lt;/span&gt; &lt;span class="na"&gt;aria-label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;alt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;data-visual&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"invalid"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;alt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// switch on parsed.data.kind and render the right SVG&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Trimmed: the real one also has classes for a dashed box.)&lt;/p&gt;

&lt;p&gt;It takes &lt;code&gt;unknown&lt;/code&gt; on purpose: it trusts nothing it's given. If the spec is wrong, there's no fallback picture, no closest match, no default buoy. You get the alt text in a box. As the comment in the code puts it: a missing image is recoverable, a misleading one is not.&lt;/p&gt;

&lt;p&gt;In practice that box should never appear, because there's a second layer. The draft files are loaded with the same schema (&lt;code&gt;visual: VisualSpecStrict.nullable()&lt;/code&gt;), and a test parses every exam's drafts. A model that writes &lt;code&gt;{ "teken": "A.11" }&lt;/code&gt; instead of &lt;code&gt;{ "code": "A.11" }&lt;/code&gt; breaks CI before anything is imported. The fallback is for what slips past that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing drawings without screenshots
&lt;/h2&gt;

&lt;p&gt;These tests don't use screenshots. The renderers put small data attributes on the parts that carry meaning, and the tests read the markup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cardinal topmarks point the right way&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cones&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;mark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nf"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mark&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/data-cone="&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;up|down&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;"/g&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;cones&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cardinal-n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;up&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;up&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;cones&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cardinal-e&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;up&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;down&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// bases together&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;cones&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cardinal-w&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;down&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;up&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// points together&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tests/visuals.test.tsx&lt;/code&gt; has 149 of these: light arcs per view, every buoy mark as buoy and spar with and without topmark, every traffic sign code, and one that hands the dispatcher a broken sign spec and expects the alt text back. A separate file with 176 tests checks the opposite: that the markup does &lt;strong&gt;not&lt;/strong&gt; contain the answer. A question that asks "what does this sign mean?" shouldn't have the sign code in an attribute where anyone can read it in the page source.&lt;/p&gt;

&lt;p&gt;For looking rather than testing, there's a dev-only gallery at &lt;code&gt;/dev/visuals&lt;/code&gt; that draws every kind and variant, each set of lights from four sides, at night and as a sketch. That's where a new drawing is compared with the official image before it goes live.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;The renderers cover 12 buoy marks, 8 light roles, 107 traffic sign codes and 49 workplace safety signs and hazard pictograms. In our Dutch exams' draft files, 170 of 3,532 question drafts carry a visual (including redrafts, so it's not the live count). All 170 parse.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone else
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Store what something &lt;em&gt;is&lt;/em&gt;, not what it looks like.&lt;/strong&gt; "cardinal-e" can be tested. A colour list in each question can't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the rules that can't change in code the content can't override.&lt;/strong&gt; The safe-water topmark is a good example.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the data is bad, show less, not something.&lt;/strong&gt; A box with text is a visible bug. A plausible wrong picture is an invisible one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the meaning in the markup.&lt;/strong&gt; A few data attributes make SVG easy to assert on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same components draw the reference pages on the site, like the &lt;a href="https://examenzaal.nl/naslag/betonning/veilig-vaarwater/" rel="noopener noreferrer"&gt;safe water mark&lt;/a&gt; without its topmark, the &lt;a href="https://examenzaal.nl/naslag/betonning/oostcardinale-markering/" rel="noopener noreferrer"&gt;east cardinal mark&lt;/a&gt;, and the &lt;a href="https://examenzaal.nl/naslag/lichten/klein-motorschip/" rel="noopener noreferrer"&gt;lights of a small motor boat&lt;/a&gt; from every side (all in Dutch).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this post with help from an AI assistant. The code, tests and numbers come from our own repository.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>svg</category>
      <category>typescript</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Three models, no human reviewer: how we gate AI-written exam questions</title>
      <dc:creator>Emmanuel</dc:creator>
      <pubDate>Thu, 05 Nov 2026 18:53:27 +0000</pubDate>
      <link>https://dev.to/emmnanuel1812/three-models-no-human-reviewer-how-we-gate-ai-written-exam-questions-3i3f</link>
      <guid>https://dev.to/emmnanuel1812/three-models-no-human-reviewer-how-we-gate-ai-written-exam-questions-3i3f</guid>
      <description>&lt;p&gt;I build practice exams for Dutch certification exams: workplace safety (VCA), the hospitality licence, two boating licences, marine radio and the taxi theory exam. The questions are written by a language model. Nobody reads every question before it goes live. I don't have the time, and I'm not a qualified skipper or safety instructor anyway.&lt;/p&gt;

&lt;p&gt;That only works if the checking is better than "ask the model if it's sure". Here's the pipeline we ended up with, and what roughly 10,000 check results taught us.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four steps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A. Draft from a source, with a quote.&lt;/strong&gt; Every question starts from an official text: an article of the inland shipping regulations, a law, or the published exam requirements. The drafting model (mostly Claude Opus) writes the question, the options, an explanation, and a &lt;strong&gt;verbatim quote&lt;/strong&gt; from the source that proves the answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B. A blind check by a second model.&lt;/strong&gt; A fresh session on a different model (Claude Sonnet) gets the stem, the options and the source text, but &lt;strong&gt;not&lt;/strong&gt; the key. It answers first and writes down why. Only then does it see our key and explanation, and it fills in six yes/no criteria: key correct, exactly one defensible answer, explanation correct, natural Dutch, unambiguous, at the level of the real exam. Any "no" is a fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;C. One redraft.&lt;/strong&gt; A failed question gets one redraft that addresses the findings, and the redraft goes through the checks again. It doesn't get a third round.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;D. Independent verification by a third model.&lt;/strong&gt; Same procedure again, blind first, by a model that neither wrote the question nor did step B (Claude Haiku or Fable). The instructions tell it to be stricter: if you'd hesitate to put this question in a paid exam, fail it.&lt;/p&gt;

&lt;p&gt;A question is imported as verified only if both checks pass &lt;strong&gt;and&lt;/strong&gt; the two checker models differ. That rule is in code, not in a prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Only counts when an independent model did it: the verifier must differ from the first checker.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;passesCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the quote is checked by a test, not by a model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;draftId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: quote not found verbatim in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the drafter "remembers" a rule that isn't in the cited text, CI fails before any model looks at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers say
&lt;/h2&gt;

&lt;p&gt;Across our exams, the checks have produced 5,148 step B results and 4,608 step D results. Those include re-checks of redrafts, so they count results, not unique questions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Step B failed 13.4 %.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Step D failed 14.3 % of what step B had already passed.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I expected the third model to mostly agree with the second. It didn't. And look at &lt;em&gt;why&lt;/em&gt; step D failed questions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion that failed at step D&lt;/th&gt;
&lt;th&gt;Times&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Not at the level of the real exam&lt;/td&gt;
&lt;td&gt;216&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Explanation not correct&lt;/td&gt;
&lt;td&gt;153&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not unambiguous&lt;/td&gt;
&lt;td&gt;140&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;More than one defensible answer&lt;/td&gt;
&lt;td&gt;109&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not natural Dutch&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Key wrong&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A wrong key, the thing everyone worries about, was the rarest reason. Most failures were about a question that's technically right but not &lt;em&gt;good&lt;/em&gt;: too easy, an explanation that cites the wrong reason, or a second option you could argue for. Those are exactly the problems a candidate would complain about, and the first checker had let them through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that went wrong, and the rule each one left behind
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. A script broke 62 questions after they were written.&lt;/strong&gt; An early batch balanced which letter is correct by moving keys around &lt;em&gt;after&lt;/em&gt; the explanations were written. The explanations still said "b is correct" while b was now a distractor. Step B caught it. The rule now: write options in display order, never re-letter afterwards, and re-read each draft to check that the option the explanation calls correct says what the explanation claims.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A "blind" check that wasn't blind.&lt;/strong&gt; One step B batch opened a file that showed the keys before writing its own answers. Its results looked great and were worthless. The batch had to rely on step D for its blind pass, and the runbook now says it outright: don't run a script or open a file that shows the keys until every blind answer of your batch is written. A check that saw the key first is not blind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. All three models missed a pattern.&lt;/strong&gt; In many questions the right answer was the one option worded differently from the others, so you could find it without reading the question. None of the models flagged it, because each one checks the content, not the shape. That one needed a mechanical check in code, which I wrote about in &lt;a href="https://dev.to/emmnanuel1812/the-right-answer-was-the-odd-one-out-67-of-the-time-3glh"&gt;an earlier post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't guarantee
&lt;/h2&gt;

&lt;p&gt;Three models agreeing is not the same as an expert agreeing. They share training data and can share a blind spot, as the odd-one-out pattern showed. The quote check proves the source &lt;em&gt;says&lt;/em&gt; something, not that the question tests it fairly. So the site says plainly that no person reads every question, every question shows its source, and every question in the answer review has a button to report an error. The reports are the human review we do have.&lt;/p&gt;

&lt;p&gt;If you're generating questions, quizzes or anything else that has a single right answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Make the checker answer before it sees your answer.&lt;/strong&gt; A model that reads your key first will usually agree with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a different model for each check,&lt;/strong&gt; and enforce that in code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the source mechanically.&lt;/strong&gt; A verbatim quote plus a string match catches invented rules for free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count why things fail,&lt;/strong&gt; not just how many. The rare failure (a wrong key) wasn't where most of the work was.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The process is described for candidates on &lt;a href="https://examenzaal.nl/over-ons/" rel="noopener noreferrer"&gt;our about page&lt;/a&gt; (in Dutch), and the exams themselves are at &lt;a href="https://examenzaal.nl/examens/" rel="noopener noreferrer"&gt;examenzaal.nl&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this post with help from an AI assistant. The numbers come from our own check files and code.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>testing</category>
      <category>education</category>
    </item>
    <item>
      <title>Our 404 page answers 200, and we decided to keep it that way</title>
      <dc:creator>Emmanuel</dc:creator>
      <pubDate>Thu, 05 Nov 2026 18:52:36 +0000</pubDate>
      <link>https://dev.to/emmnanuel1812/our-404-page-answers-200-and-we-decided-to-keep-it-that-way-34n6</link>
      <guid>https://dev.to/emmnanuel1812/our-404-page-answers-200-and-we-decided-to-keep-it-that-way-34n6</guid>
      <description>&lt;p&gt;I run a site with practice exams for Dutch certification exams. Each exam has a few fixed practice forms, at URLs like &lt;code&gt;/examen/b-vca/1/&lt;/code&gt;, &lt;code&gt;/examen/b-vca/2/&lt;/code&gt; and so on. Last month I typed &lt;code&gt;/examen/b-vca/999/&lt;/code&gt; to check the error page.&lt;/p&gt;

&lt;p&gt;The page looked right: "Deze pagina bestaat niet" ("this page doesn't exist"), the normal layout, a link back. Then I looked at the status code. &lt;strong&gt;200.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A 404 page served with a 200 is what Google calls a soft 404. Here's why it happens in Next.js 16 with partial prerendering, what I tried, and why I ended up leaving it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the status is already gone
&lt;/h2&gt;

&lt;p&gt;The exam page is built the way Next.js 16 wants you to build it with &lt;code&gt;cacheComponents: true&lt;/code&gt;. The page itself is a static shell, and everything that needs the request goes inside a Suspense boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Oefenexamen&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;robots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ExamPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;PageProps&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/examen/[exam]/[form]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Notice&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Oefenexamen laden…"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ExamForm&lt;/span&gt; &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside, the component looks up the exam and calls &lt;code&gt;notFound()&lt;/code&gt; when the form number doesn't exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;exam&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;1-9&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;exam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fixedCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With partial prerendering, the server sends the prerendered shell first and streams the dynamic part after it. By the time &lt;code&gt;notFound()&lt;/code&gt; runs inside the Suspense boundary, the response headers, including &lt;code&gt;200 OK&lt;/code&gt;, have already gone out. Next.js still renders the not-found UI into the stream, so a person sees the right page. A crawler that only reads the status sees a normal page.&lt;/p&gt;

&lt;p&gt;URLs that don't match any route are fine. They go to &lt;code&gt;global-not-found.tsx&lt;/code&gt; and get a real 404. Only an unknown value inside a dynamic route that streams is affected.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;&lt;code&gt;generateStaticParams&lt;/code&gt;.&lt;/strong&gt; List the real forms, and anything else should 404. It doesn't work that way here. The listed params get prerendered, but an unlisted one still gets the shell and renders on demand, so we're back at the same stream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;dynamicParams = false&lt;/code&gt;.&lt;/strong&gt; This is the classic switch for "only the params I listed". With &lt;code&gt;cacheComponents&lt;/code&gt; enabled, Next.js doesn't allow it on the route.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Making the page fully static.&lt;/strong&gt; Then &lt;code&gt;notFound()&lt;/code&gt; would run at build time and the status would be right. But this page reads the visitor's session cookie and the database (who is this, and do they have access). It can't be static.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A proxy in front of the route.&lt;/strong&gt; This is the one that actually works. A &lt;code&gt;proxy.ts&lt;/code&gt; (what used to be &lt;code&gt;middleware.ts&lt;/code&gt;) checks the exam and form number before the page runs and returns a 404 for unknown ones. The cost is that it runs on &lt;strong&gt;every&lt;/strong&gt; request under &lt;code&gt;/examen/&lt;/code&gt;, including the server actions that save answers and hand in the exam. It also needs a list of valid exam and form pairs at the edge, generated from the content and kept in sync. That's a new moving part on the hottest path in the app, to fix a URL nobody links to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we kept the soft 404
&lt;/h2&gt;

&lt;p&gt;What matters about a soft 404 is that a search engine might index an error page or waste crawl budget on it. So I checked what a search engine can actually do with these URLs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every exam page is &lt;code&gt;noindex, nofollow&lt;/code&gt;,&lt;/strong&gt; including the real ones. A practice exam isn't a page anyone should land on from search. The guides that link to it are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/examen/&lt;/code&gt; is disallowed in &lt;code&gt;robots.txt&lt;/code&gt;.&lt;/strong&gt; Well-behaved crawlers don't fetch the page at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing links to a form that doesn't exist.&lt;/strong&gt; The links to exam forms are generated from the same data that decides which forms exist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So no crawler should ever see the 200, and if one does, the page tells it not to index anything. The fix would add risk to the part of the app where a bug costs a user their exam attempt. The problem it fixes has no visible effect.&lt;/p&gt;

&lt;p&gt;We wrote it down as a decision with a condition: &lt;strong&gt;revisit it if a crawler or an uptime monitor ever needs the real status.&lt;/strong&gt; Then the proxy plus a generated exam/form map is the way to do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone else on Next.js 16
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check your error status codes with curl, not the browser.&lt;/strong&gt; &lt;code&gt;curl -s -o /dev/null -w "%{http_code}" &amp;lt;url&amp;gt;&lt;/code&gt; takes a second. The page looking right tells you nothing about the header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;notFound()&lt;/code&gt; inside Suspense can't change the status&lt;/strong&gt; once the shell has streamed. If the status matters, the check has to happen before streaming starts: at build time, or in a proxy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide per route whether the status matters.&lt;/strong&gt; On public, indexable routes it does, so make those static or check them early. On routes that are &lt;code&gt;noindex&lt;/code&gt; and disallowed anyway, a soft 404 can be the cheaper, safer trade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the decision down with the condition that would change it.&lt;/strong&gt; Otherwise someone (probably you) rediscovers the 200 in three months and spends an evening on it again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The site is &lt;a href="https://examenzaal.nl/examens/" rel="noopener noreferrer"&gt;examenzaal.nl&lt;/a&gt;, practice exams for Dutch certificates. The real exam forms start from pages like the &lt;a href="https://examenzaal.nl/vca/b-vca/" rel="noopener noreferrer"&gt;B-VCA practice page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this post with help from an AI assistant. The code and the decision come from our own repository.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
      <category>seo</category>
    </item>
    <item>
      <title>The right answer was the odd one out 67 % of the time</title>
      <dc:creator>Emmanuel</dc:creator>
      <pubDate>Thu, 05 Nov 2026 17:58:20 +0000</pubDate>
      <link>https://dev.to/emmnanuel1812/the-right-answer-was-the-odd-one-out-67-of-the-time-3glh</link>
      <guid>https://dev.to/emmnanuel1812/the-right-answer-was-the-odd-one-out-67-of-the-time-3glh</guid>
      <description>&lt;p&gt;I build practice exams for Dutch certification exams: the VCA safety certificate, the hospitality licence (Sociale Hygiëne), the small-boat licence, marine radio and a few more. One model drafts the questions. A second model answers them blind, without seeing the drafter's notes. A third model, which neither wrote nor checked the question, verifies it independently. Only then does a question go live.&lt;/p&gt;

&lt;p&gt;All three models were happy with the questions. Then I counted something none of them had flagged: in many questions you could find the right answer &lt;strong&gt;without reading the question&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cue
&lt;/h2&gt;

&lt;p&gt;Here's a made-up question in the same style:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What do you do first when you find a colleague unconscious next to an electrical cabinet?&lt;/p&gt;

&lt;p&gt;A. Call the company's emergency response officer&lt;br&gt;
B. Call the emergency number&lt;br&gt;
C. Make sure the power is switched off&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two options start with "Call". One doesn't. If you've taken a lot of multiple-choice tests, your eye goes straight to C, and here it's right.&lt;/p&gt;

&lt;p&gt;That would be fine once. It isn't fine when it's a pattern.&lt;/p&gt;

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

&lt;p&gt;I took every verified single-choice question where &lt;strong&gt;exactly one option starts with a different first word&lt;/strong&gt; than the others, and counted how often that odd option was the correct answer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Exam&lt;/th&gt;
&lt;th&gt;Key = the odd option&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sociale Hygiëne (hospitality licence)&lt;/td&gt;
&lt;td&gt;67 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VOL-VCA (safety, supervisors)&lt;/td&gt;
&lt;td&gt;63 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B-VCA (safety, basic)&lt;/td&gt;
&lt;td&gt;51 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marifonie (marine radio)&lt;/td&gt;
&lt;td&gt;42 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Klein Vaarbewijs 1 (small-boat licence)&lt;/td&gt;
&lt;td&gt;39 %&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most of these exams have three options, so by chance the key would be the odd option about a third of the time. The boating exam has four options, so chance is a quarter. Every exam was above chance, and the hospitality exam was at two thirds.&lt;/p&gt;

&lt;p&gt;A second cue was even stronger: &lt;strong&gt;absolute words&lt;/strong&gt;. Where every wrong option contained a word like "only", "always", "never" or "but not", and exactly one option didn't, that option was the key in 82 % (B-VCA), 89 % (VOL-VCA), 81 % (Sociale Hygiëne), 63 % (boating) and 60 % (marine radio) of the cases. "Absolute answers are usually wrong" is one of the first tricks test-wise people learn. Our questions rewarded it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;p&gt;My best explanation: a language model writes the correct answer carefully, as a specific action or a qualified statement. Then it writes the distractors as a set, and a set comes out parallel: same opening verb, same structure, and often an absolute word to make sure each one is clearly wrong. The key ends up as the one option that doesn't look like the others.&lt;/p&gt;

&lt;p&gt;A model reviewing the question checks whether the key is correct and the distractors are wrong. It does that well. It doesn't ask whether the key &lt;strong&gt;looks&lt;/strong&gt; different, because that isn't a question about content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why shuffling doesn't help
&lt;/h2&gt;

&lt;p&gt;The first idea people have is to shuffle the options. That fixes a cue in the &lt;strong&gt;position&lt;/strong&gt; (for example "the answer is often B"). This cue is in the &lt;strong&gt;wording&lt;/strong&gt;. C is still the odd one out when it's shown as A.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. A mechanical check on every draft.&lt;/strong&gt; It's a few lines of TypeScript and runs before any model looks at the question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;firstWord&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^\p&lt;/span&gt;&lt;span class="sr"&gt;{L}&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{N}&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/gu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="cm"&gt;/** True when the key is the only option with a different first word. */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;keyIsOddOneOut&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}[],&lt;/span&gt; &lt;span class="nx"&gt;keyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;keyId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;others&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;keyId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;firstWord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;others&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;others&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;firstWord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real check has two more rules: every distractor is restricted (by an absolute word) and the key isn't, and the key is the only "yes" among "no"s (or the reverse). Each language has its own word lists. A conditional like "if" or "unless" doesn't count as a restriction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A rewrite pass that only touches the options.&lt;/strong&gt; Every live question that failed the check got new wording for its options. The correct answer, the fact behind it and the cited source stayed exactly the same. The rewrite went through the same blind check and independent verification as a new question, and the original was only retired when the replacement passed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The verifying model reads for it too.&lt;/strong&gt; The third model now treats a form cue as a reason to fail a question. On a later batch of 56 new questions, it passed 51 and failed 5, all five on form cues. Those were redrafted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't solve
&lt;/h2&gt;

&lt;p&gt;This catches the cheapest cues, not all of them. A longer, more careful key is another classic, and so is grammar that only fits one option. The check is a floor: it makes sure that a question can't be answered from its shape alone. It doesn't make a question good.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you generate quizzes with an LLM
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Count it.&lt;/strong&gt; For your own bank, measure how often the key is the option with a different first word, and how often it's the only option without an absolute word. Compare with chance. It takes an afternoon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write distractors like the key.&lt;/strong&gt; Same structure, same length, same kind of qualifier. If the key is an action, the distractors are actions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use absolute words in the key too,&lt;/strong&gt; when they're true. Otherwise "never" becomes a giveaway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check form separately from content.&lt;/strong&gt; A reviewer who checks whether an answer is right won't notice that it also looks different.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practice exams this came from are at &lt;a href="https://examenzaal.nl/" rel="noopener noreferrer"&gt;examenzaal.nl&lt;/a&gt; (Dutch, with the VCA exams also in English).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this post with help from an AI assistant. The numbers come from our own question bank and code.&lt;/em&gt;&lt;/p&gt;

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