<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[EPA 608 Practice Test Engineering]]></title><description><![CDATA[Engineering notes on building an EPA 608 practice test: scoring, question generation, spaced review and question-bank quality, with runnable JavaScript.]]></description><link>https://epa608.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 02 Nov 2026 17:51:27 GMT</lastBuildDate><atom:link href="https://epa608.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Scoring an EPA 608 Practice Test Like the Real Exam: Per-Section Pass Logic in JavaScript]]></title><description><![CDATA[The EPA 608 exam is scored per section (18 of 25 on each). How to model Core, Type I, II, III and Universal results in JavaScript, with tests.]]></description><link>https://epa608.hashnode.dev/epa-608-practice-test-section-scoring-javascript</link><guid isPermaLink="true">https://epa608.hashnode.dev/epa-608-practice-test-section-scoring-javascript</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[education]]></category><dc:creator><![CDATA[epa608]]></dc:creator><pubDate>Thu, 29 Oct 2026 04:27:21 GMT</pubDate><content:encoded><![CDATA[<p>Most quiz engines end with one number: "You scored 84%." For an EPA 608 practice test, that number is close to useless, and it can be actively misleading.</p>
<p>The real EPA Section 608 technician exam is four separate tests stapled together: <strong>Core, Type I, Type II and Type III</strong>, 25 questions each. Each section is graded on its own. You need 18 of 25 (72%) on a section to pass it. What certification you walk away with depends on <em>which</em> sections you passed, not on your overall percentage.</p>
<p>So a candidate can score 92 out of 100 and still not be Universal certified. Another can score 76 out of 100 and be Universal. If your practice test only shows the total, the first person thinks they're done and the second thinks they're in trouble. Both are wrong.</p>
<p>This post builds the scoring function I wish I'd written first: it takes a list of questions and a set of responses, and it returns per-section results plus the certification those results actually earn.</p>
<h2>The rules we need to encode</h2>
<p>Stripped down to what matters for code:</p>
<ol>
<li>Four sections: <code>Core</code>, <code>Type I</code>, <code>Type II</code>, <code>Type III</code>.</li>
<li>A section passes at <strong>18 correct out of 25</strong>.</li>
<li><strong>Core is a prerequisite.</strong> Passing Type II without Core earns nothing.</li>
<li>Core + Type I = Type I certified. Core + Type II = Type II. Core + Type III = Type III. Any combination is possible.</li>
<li>Core + all three types = <strong>Universal</strong>.</li>
</ol>
<p>Two extra cases show up in practice software that don't exist on the real exam:</p>
<ul>
<li><strong>Short practice sets.</strong> Users often drill 10 or 20 questions from one section. "18 correct" doesn't make sense for a 10-question set, so the pass mark has to scale.</li>
<li><strong>Sections that weren't attempted.</strong> A Type II-only drill has zero Type I questions. That section is "not attempted," not "failed."</li>
</ul>
<h2>Data shape</h2>
<p>Each question carries its section. Responses are a map from question id to the option the user picked.</p>
<pre><code class="language-js">const question = { id: 'TYPE2_015', section: 'Type II', answer: '0 inches Hg', options: [/* ... */] };

const responses = { TYPE2_015: '0 inches Hg', CORE_003: 'Two' };
</code></pre>
<p>Comparing by option <em>text</em> instead of option <em>index</em> matters here, and I'll come back to why at the end.</p>
<h2>The scoring function</h2>
<pre><code class="language-js">const SECTIONS = ['Core', 'Type I', 'Type II', 'Type III'];
const PASS_CORRECT = 18; // out of 25 per section

function scoreExam(questions, responses) {
  const bySection = Object.fromEntries(
    SECTIONS.map(s =&gt; [s, { correct: 0, total: 0, missed: [] }])
  );

  for (const q of questions) {
    const bucket = bySection[q.section];
    if (!bucket) throw new Error(`Unknown section "${q.section}" on ${q.id}`);
    bucket.total++;
    if (responses[q.id] === q.answer) bucket.correct++;
    else bucket.missed.push(q.id);
  }

  for (const s of SECTIONS) {
    const b = bySection[s];
    b.attempted = b.total &gt; 0;
    b.passed = b.attempted &amp;&amp; b.correct &gt;= passMark(b.total);
  }

  return { bySection, certification: certificationFor(bySection) };
}

// 18/25 scaled for practice sets that are not exactly 25 questions
function passMark(total) {
  return Math.ceil(total * PASS_CORRECT / 25);
}
</code></pre>
<p>A few decisions worth calling out:</p>
<p><strong>Unknown sections throw.</strong> It's tempting to silently skip a question with <code>section: 'Type 2'</code> or <code>section: 'TypeII'</code>. Don't. A typo in the data would quietly shrink a section to 24 questions and shift the pass mark. Failing loudly during development is cheaper than a wrong "PASS" shown to someone who is about to pay for the real exam.</p>
<p><strong>Unanswered questions count as wrong.</strong> <code>responses[q.id]</code> is <code>undefined</code> for a skipped question, which never equals the answer. That matches the real exam: a blank is a miss.</p>
<p><code>passMark</code> <strong>uses</strong> <code>Math.ceil</code><strong>.</strong> For 25 questions it returns exactly 18. For a 10-question drill it returns 8 (7.2 rounded up), and for 20 it returns 15 (14.4 rounded up). Rounding <em>up</em> keeps the practice bar at or slightly above 72%, never below it. If you round to nearest, a 10-question set would pass at 7/10 = 70%, which is softer than the real thing.</p>
<h2>Turning sections into a certification</h2>
<pre><code class="language-js">function certificationFor(bySection) {
  if (!bySection.Core.passed) return 'None (Core not passed)';
  const types = ['Type I', 'Type II', 'Type III'].filter(t =&gt; bySection[t].passed);
  if (types.length === 3) return 'Universal';
  if (types.length === 0) return 'None yet (Core passed, no type passed)';
  return types.join(' + ');
}
</code></pre>
<p>The Core check comes first and short-circuits everything. That's the rule people forget most often, both test-takers and developers. I've seen practice apps that report "Type II: PASS" in green when Core failed, which is technically true for the section and completely false for the certificate.</p>
<h2>Tests that encode the edge cases</h2>
<p>The tests are the actual spec here. Each one is a situation a real candidate ends up in.</p>
<pre><code class="language-js">const assert = require('assert');
const make = (section, n) =&gt; Array.from({ length: n }, (_, i) =&gt;
  ({ id: `${section}-${i}`, section, answer: 'ok' }));
const exam = SECTIONS.flatMap(s =&gt; make(s, 25));
const answerFirst = (section, k) =&gt; Object.fromEntries(
  exam.filter(q =&gt; q.section === section).slice(0, k).map(q =&gt; [q.id, 'ok']));

// 92 / 100 overall, but Type III only 17/25
const r1 = { ...answerFirst('Core', 25), ...answerFirst('Type I', 25),
             ...answerFirst('Type II', 25), ...answerFirst('Type III', 17) };
assert.strictEqual(scoreExam(exam, r1).certification, 'Type I + Type II');

// 76 / 100 overall, every section exactly 19
const r2 = Object.assign({}, ...SECTIONS.map(s =&gt; answerFirst(s, 19)));
assert.strictEqual(scoreExam(exam, r2).certification, 'Universal');

// Core fails at 17 -&gt; nothing else counts
const r3 = { ...answerFirst('Core', 17), ...answerFirst('Type II', 25) };
assert.strictEqual(scoreExam(exam, r3).certification, 'None (Core not passed)');

assert.strictEqual(passMark(25), 18);
assert.strictEqual(passMark(10), 8);
assert.strictEqual(passMark(20), 15);
</code></pre>
<p>The first two cases are the ones from the intro. 92% overall misses Universal by a single question in Type III. 76% overall gets Universal because it clears every section. If your result screen can't express that, it's teaching the wrong lesson.</p>
<p>The third case is the "Core gatekeeper" rule: a perfect Type II section is worth nothing until Core passes.</p>
<h2>Rendering the result</h2>
<p>Once you have per-section data, the result screen almost writes itself. The one design choice I'd push for: <strong>show the margin, not just pass/fail.</strong></p>
<pre><code class="language-js">function describeSection(name, b) {
  if (!b.attempted) return `${name}: not attempted`;
  const need = passMark(b.total);
  const margin = b.correct - need;
  const status = b.passed ? 'PASS' : 'FAIL';
  const note = margin === 0 ? ' (no room to spare)'
    : margin &gt; 0 ? ` (${margin} above the line)`
    : ` (${-margin} short)`;
  return `${name}: ${b.correct}/${b.total} ${status}${note}`;
}
</code></pre>
<pre><code class="language-plaintext">Core: 21/25 PASS (3 above the line)
Type I: 18/25 PASS (no room to spare)
Type II: 16/25 FAIL (2 short)
Type III: not attempted
Certification: Type I
</code></pre>
<p>"18/25 PASS" and "25/25 PASS" are very different states for someone deciding whether to book the exam. A candidate sitting exactly on the line passes this practice set and fails the real one on a bad day. Showing the margin makes that visible without any extra explanation.</p>
<h2>Why responses store option text, not index</h2>
<p>Back to the data shape. It's common to store a response as <code>2</code> ("the user picked the third option"). That breaks the moment you shuffle answer options per attempt, which you should do. Otherwise people learn that the answer to the recovery-cylinder question is "the second one" instead of learning the 80% fill rule.</p>
<p>If options are shuffled at render time, index <code>2</code> means something different on every attempt, and old responses in storage or analytics become meaningless. Storing the option text (or a stable option id) keeps scoring independent of display order. Compare <code>responses[q.id] === q.answer</code> and the shuffle can do whatever it wants.</p>
<h2>Things this doesn't model (on purpose)</h2>
<ul>
<li><strong>Retakes.</strong> Certifying organizations generally let you retake only the sections you failed, but the details vary by organization and proctoring method. I keep that out of the scoring function and in the UI copy, with a pointer to the certifying organization's own rules.</li>
<li><strong>Type I open-book variants.</strong> Some organizations offer Type I under different conditions. Same answer: don't hard-code what you can't keep up to date.</li>
<li><strong>Time limits.</strong> Scoring and timing are separate concerns. A timer that expires should just stop collecting responses; this function scores whatever it receives.</li>
</ul>
<h2>Wrap-up</h2>
<p>Everything here fits in about 50 lines, and it's the difference between an EPA 608 practice test that tells people where they really stand and one that gives them a comforting number. If you're building any exam simulator, the same pattern applies: find out how the real exam is actually <em>graded</em>, model that first, and treat the overall percentage as a footnote.</p>
<p>The full file with the tests is runnable with plain <code>node score.js</code>, no dependencies.</p>
]]></content:encoded></item><item><title><![CDATA[I Audited Our EPA 608 Practice Test Bank: 59% of the Correct Answers Were Option B]]></title><description><![CDATA[How to audit a multiple-choice EPA 608 practice test bank for answer-position and longest-answer bias with Node.js, a chi-square check.]]></description><link>https://epa608.hashnode.dev/epa-608-practice-test-answer-position-bias</link><guid isPermaLink="true">https://epa608.hashnode.dev/epa-608-practice-test-answer-position-bias</guid><dc:creator><![CDATA[epa608]]></dc:creator><pubDate>Mon, 26 Oct 2026 03:40:42 GMT</pubDate><content:encoded><![CDATA[<p>Here's a test you can run on any multiple-choice question bank in about five minutes. It will probably tell you something uncomfortable.</p>
<p>I ran it on the question bank behind our EPA 608 practice test, the one HVAC technicians use to prepare for the federal refrigerant handling certification. The result:</p>
<pre><code class="language-plaintext">  A:   31  (13.7%)
  B:  134  (59.0%)
  C:   52  (22.9%)
  D:   10  (4.4%)
Chi-square vs uniform: 155.7 (df=3, 7.81 is the p=0.05 cutoff)
Correct answer is the longest option: 67.0%
"All of the above" correct: 4/4
</code></pre>
<p>Fifty-nine percent of correct answers were in position B. Correct answers were in position D less than one time in twenty. And in two out of three questions, the right answer was simply the longest option.</p>
<p>Then I ran it on our full-length mock exam file:</p>
<pre><code class="language-plaintext">  A:  100  (100.0%)
  B:    0  (0.0%)
  C:    0  (0.0%)
  D:    0  (0.0%)
Chi-square vs uniform: 300.0 (df=3, 7.81 is the p=0.05 cutoff)
Correct answer is the longest option: 79.0%
</code></pre>
<p>Every single correct answer was option A.</p>
<p>This post covers the audit script, why it happened, and the fix. If you build any kind of quiz, exam prep or e-learning product, check your bank before you read the rest.</p>
<h2>Why this happens</h2>
<p>Nobody sat down and decided "make the answer B." Question authors have habits:</p>
<ul>
<li><strong>Type the correct answer first,</strong> then come up with distractors. If nothing reorders them, the answer is always A. That's what happened to the mock exam file.</li>
<li><strong>Avoid putting the answer first</strong> because it "feels too easy," and drift toward B or C instead. That's a well-known human tendency when people try to place answers "randomly."</li>
<li><strong>Write the correct answer carefully and the distractors quickly.</strong> Correct answers come out longer, more specific and more hedged ("90% of the refrigerant or 4 inches of vacuum"), and distractors come out short ("50%").</li>
</ul>
<p>None of this matters if the app shuffles options when it displays them. Ours shuffled the <em>question order</em> but rendered the options in the order they were stored. So the authors' habits went straight to the user's screen.</p>
<h2>Why it matters for a practice test specifically</h2>
<p>A biased practice test doesn't just leak points. It <strong>teaches the wrong skill</strong>.</p>
<p>A candidate who takes enough of those practice tests will, without ever noticing, learn "when in doubt, pick B" or "pick the long one." Their practice scores go up. Then they sit the real EPA 608 exam, which is written and checked by a certifying organization, and the trick stops working. Each section is pass/fail at 18 of 25, so a couple of points of fake confidence per section is exactly the margin that decides the result.</p>
<p>A practice score is only useful if it predicts the real score. Bias breaks that.</p>
<h2>The audit script</h2>
<p>The bank is a JSON array of questions. Each question has <code>options</code> and an <code>answer</code> that matches one option's text:</p>
<pre><code class="language-json">{ "id": "TYPE1_004", "options": ["...", "...", "...", "..."], "answer": "90% of the refrigerant or 4 inches of vacuum" }
</code></pre>
<pre><code class="language-js">// audit.js: node audit.js bank.json
const fs = require('fs');

const bank = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const LETTERS = ['A', 'B', 'C', 'D'];

const positionCounts = [0, 0, 0, 0];
let longestIsCorrect = 0;
let aotaCorrect = 0, aotaTotal = 0;

for (const q of bank) {
  const idx = q.options.indexOf(q.answer);
  if (idx === -1) throw new Error(`${q.id}: answer not found in options`);
  positionCounts[idx]++;

  const lengths = q.options.map(o =&gt; o.length);
  if (q.answer.length === Math.max(...lengths)) longestIsCorrect++;

  if (q.options.some(o =&gt; /all of the above/i.test(o))) {
    aotaTotal++;
    if (/all of the above/i.test(q.answer)) aotaCorrect++;
  }
}

const n = bank.length;
const expected = n / 4;
const chiSquare = positionCounts.reduce((sum, obs) =&gt; sum + (obs - expected) ** 2 / expected, 0);

console.log(`Questions: ${n}`);
positionCounts.forEach((c, i) =&gt;
  console.log(`  ${LETTERS[i]}: ${String(c).padStart(4)}  (${(100 * c / n).toFixed(1)}%)`));
console.log(`Chi-square vs uniform: ${chiSquare.toFixed(1)} (df=3, 7.81 is the p=0.05 cutoff)`);
console.log(`Correct answer is the longest option: ${(100 * longestIsCorrect / n).toFixed(1)}%`);
console.log(`"All of the above" correct: ${aotaCorrect}/${aotaTotal}`);
</code></pre>
<p>Three checks:</p>
<p><strong>Position distribution with a chi-square test.</strong> With four options, each position should hold about 25% of correct answers. The chi-square statistic measures how far the observed counts are from that. With 3 degrees of freedom, anything above 7.81 means "this would happen by chance less than 5% of the time." We were at 155.7, which is not a borderline result.</p>
<p>You don't need a stats library. The statistic is one <code>reduce</code>, and the critical value for df = 3 is a constant you can hard-code.</p>
<p><strong>Longest-option rate.</strong> If length carried no information, the correct answer would be the longest option roughly a quarter of the time, a bit more once you count ties. 67% means length is a strong clue.</p>
<p><strong>"All of the above."</strong> In our bank, every question that offered "All of the above" had it as the correct answer. Small sample, but candidates notice patterns like that quickly.</p>
<h2>The fix, part 1: a real shuffle</h2>
<p>The obvious fix is to shuffle options at render time. The obvious <em>implementation</em> is wrong:</p>
<pre><code class="language-js">options.sort(() =&gt; Math.random() - 0.5); // don't
</code></pre>
<p>A comparator that returns random results breaks the assumptions of the sort algorithm, and the resulting permutations aren't uniform. How biased they are depends on the engine and its sort implementation. I measured it on Node 22 by shuffling <code>['A','B','C','D']</code> 100,000 times and recording where <code>A</code> ended up:</p>
<pre><code class="language-plaintext">sort(() =&gt; Math.random() - 0.5): 36.2%  17.0%  15.6%  31.3%
Fisher-Yates:                     25.0%  25.1%  24.9%  25.0%
</code></pre>
<p>With the sort trick, the first option still lands in position A more than a third of the time. On a bank where the answer was authored first, that's leaving a lot of the bias in place.</p>
<p>Fisher-Yates is five lines and provably uniform:</p>
<pre><code class="language-js">// Fisher-Yates: every permutation equally likely
function shuffle(array, rng = Math.random) {
  const a = array.slice();
  for (let i = a.length - 1; i &gt; 0; i--) {
    const j = Math.floor(rng() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}
</code></pre>
<p>The <code>rng</code> parameter lets you pass a seeded generator in tests, so a shuffled exam can be reproduced exactly.</p>
<h2>The fix, part 2: pin the options that refer to other options</h2>
<p>You can't shuffle "All of the above" into position A. It stops making sense. Same for "Both A and B" and "None of the above." Those get pinned to the end, and only the rest are shuffled:</p>
<pre><code class="language-js">const PINNED = /^(all|none|both) of the above$|^(a|b) and (b|c)$|^(a|b), (b|c),? and (c|d)$/i;

function shuffleOptions(question, rng = Math.random) {
  const pinned = question.options.filter(o =&gt; PINNED.test(o.trim()));
  const free = question.options.filter(o =&gt; !PINNED.test(o.trim()));
  return { ...question, options: [...shuffle(free, rng), ...pinned] };
}
</code></pre>
<p>Options like "A and B" are the worst case: after a shuffle, the letters point at different options. Pinning keeps the option in place, but the letters inside it are still wrong. The real fix is to rewrite those questions so options don't refer to each other by letter. The audit is a good moment to find them.</p>
<h2>The fix, part 3: compare answers by text</h2>
<p>If the app records "the user picked index 2," shuffling breaks scoring. Index 2 means something different on every attempt. Our bank already stored the answer as option text, so scoring is <code>selected === question.answer</code> no matter what order the options appeared in. If yours stores indices, migrate to text or stable option ids <strong>before</strong> you turn on shuffling, or historical results become meaningless.</p>
<h2>What shuffling doesn't fix</h2>
<p>Shuffling removes position bias completely. It does nothing for the <strong>longest-answer</strong> bias or the <strong>"All of the above"</strong> pattern. Those are in the content, and only editing fixes them:</p>
<ul>
<li>Rewrite distractors so they're as specific as the correct answer. "50% of the refrigerant" next to "90% of the refrigerant or 4 inches of vacuum" gives it away. "80% of the refrigerant or 4 inches of vacuum" doesn't.</li>
<li>Make sure "All of the above" is sometimes wrong, or remove it.</li>
<li>Re-run the audit after each batch of edits and watch the longest-option rate fall toward 25 to 30%.</li>
</ul>
<p>I'd add the audit to CI with a threshold, for example fail if the chi-square for authored positions exceeds the cutoff on newly added questions, or if the longest-option rate for a section goes above 40%. That catches regressions when someone adds a batch of new questions next quarter.</p>
<h2>The takeaway</h2>
<p>Shuffle the questions <em>and</em> the options, with a real shuffle. Then audit what shuffling can't fix. It takes an afternoon, and it's the difference between an EPA 608 practice test that measures what someone knows and one that measures how well they've learned the author's habits.</p>
<p>Our app was shuffling questions the whole time and still sending a 59% B / 100% A pattern straight to users. That's the scary part about this kind of bug: nobody files a bug report about getting answers right.</p>
]]></content:encoded></item></channel></rss>