🔬 Question-corpus forensics

Superseded — 2026-08-19

The 679-question / 456-subject bench inventory measured here is superseded by the 2026-08-19 legacy WXR import, which closed at 9,069 questions and 483 subject terms; the 60 ledger candidates below were resolved to 53 accepted and 7 rejected at the importer boundary by the 2026-08-19 corpus import. Preserved as observed; current state: 📍 Now. Corpus overview: 📚 Question corpus.

Headline, all measured 2026-08-19

11 source banks measured across a walk of 135 files / 6,520,043 bytes under the project’s 04 QUESTIONS/ directory (read-only throughout). Those banks hold 12,271 rows but only 1,841 distinct normalised stems. Run through the deployed validator floor and the dedupe policy, the corpus closes at 12,271 rows in → 60 ledger candidates (corrected 2026-08-19; the first run said 353). Of those 60 the deployed importer later accepted 53 and rejected 7 multi-answer rows at its validator boundary, so 60 is a candidate count and never an import outcome (2026-08-19 corpus import). Deliverables (a)–(e) of the corpus prompt are reproduced below, each number carrying its command, file:line, or measurement. Anything a file asserts about itself is labelled claimed; everything else is measured.

Status vocabulary on this page: measured · claimed · PROVEN · REFUTED · UNDECIDED-because.

Ground truth on the bench

Command (bench, 2026-08-19):

cd ~/dev/wrdp
sudo -n docker compose --profile cli run --rm \
  -v /tmp/qw_phase0.php:/tmp/qw_phase0.php wpcli wp eval-file /tmp/qw_phase0.php
MeasurementValue
published qw_question posts679
wp_qwizz_question_index rows679 (drift 0)
index rows lang='en'508
qw_subject terms456
wp_qwizz_question_subjects rows908
language split over the 679en 508 · de 57 · fr 57 · es 57
type splitmultiple 588 · boolean 91
difficulty spliteasy 276 · normal 240 · hard 163

This supersedes the day-old 679 / 679 / 508 / 456 snapshot: the four counts are unchanged, and the run adds the 908 subject-assignment rows plus the three splits, which the snapshot did not carry.

It reconciles exactly. A second bench query (get_post_meta($quiz_id,'qw_question_ids') per draft quiz, then each question’s qw_source) accounts for every published row:

40 OpenTDB + 229 world-quiz-dataset + 410 pack = 679.

(a) Per-source census

Measured by extract_census.py in the forensics output directory; .xlsx read with zipfile + xml.etree (no openpyxl on this machine, pip blocked by PEP 668), sheet names resolved through xl/_rels/workbook.xml.rels, header rows detected, not assumed (Gold banks and Question Bank on sheet row 4; Wave 01 and Production Queue on row 1 — matching the prompt, no discrepancy).

BankRows (measured / claimed)Cols/keysLanguage axisDifficulty vocabularyType distribution (top)
gold240240 / 24013none; English texteasy · medium · hard (96/84/60 medium/easy/hard)single_choice 108, multi_choice 24, short_text 24, boolean 12, +6 rich types ×12
gold360360 / 36014noneeasy · medium · hard (144/120/96)single_choice 120, multi_choice 36, short_text 36, boolean 24, +6 ×24
gold504504 / 50422noneeasy · medium · hard (204/168/132)single_choice 144, multi_choice 60, short_text 48, boolean 36, +6 ×36
gold10081008 / 100817noneeasy · medium · hard (408/336/264)single_choice 288, multi_choice 120, short_text 96, boolean 72, +6 ×72
runway_queue8992 / 899224noneeasy · medium · hard (3640/2997/2355)slots only — the Question column is empty on 8,992 of 8,992 rows
wave01_168168 / 16817noneeasy · medium · hard (60 hard/60 medium/48 easy)single_choice 48, multi_choice 24, boolean 12, +7 ×12
qs_json32 / no self-claim11meta.languages claims 6; inline translations{} on 2 of 32 rows5 words (medium 11, easy 6, hard 6, expert 5, beginner 4) plus numeric ds 1–5boolean 7, multiple 7, single 7, text 6, sort 5
wqd_questions229 / 22918one row per (question, language): lang, is_origin, origin_idnumeric levels 2/3/4 → easy/medium/hard (77/76/76)mcq 217, boolean 12
refdemo_3333 / no self-claim17per-item locale object (en-US 27, de-DE 2, es-ES 2, es-419 1, ja-JP 1)numeric display_level_1_5 only (2:15, 3:11, 1:6, 4:1)single 14, free_text 5, boolean 3, numeric 3, +6 tail types
atlas_physci_easy26 / no self-claim9noneeasy on all 26implicit single-correct + 3 wrongs, no type field
live_679679 / 67913per-row lang (en 508, de 57, fr 57, es 57)easy 276 · normal 240 · hard 163multiple 588, boolean 91

Nine walked paths are noise excluded from every bank (.DS_Store ×3, six __pycache__/*.pyc). The atlas claim holds: 36 .jsonl buckets, 35 zero bytes, one non-empty (8,914 B).

No bank’s self-claimed row count disagrees with its measured row count.

Hash comparisons (measured)

Comparisonsha256 A (24 hex)sha256 B (24 hex)IdenticalVerdict
Wave-01 168 vs its (1) copy6a7161f1fce7b6fbf4eb5c806a7161f1fce7b6fbf4eb5c80yesbyte-identical duplicate
10k Runway vs _dfqc46b8b7a8f6cb93200d53010c46b8b7a8f6cb93200d53010yesbyte-identical duplicate
the two payload zips0c2263a19d56ed77aabe88c189dcf5ccd048e21851c9e73bnosame payload, different container bytes — member name/size/CRC lists identical
the two extracted questions.jsonb3004656d4028bf8e3f4abbbb3004656d4028bf8e3f4abbbyesbyte-identical duplicate

Qs.json is not valid JSON (measured)

json.load raises json.JSONDecodeError: Invalid \escape at line 287, column 18, char 11699 — the \l of $\lim_{x→0} sin(x)/x = 1$ inside a feedback.why string. Exactly one offending escape sequence exists in the file. A repaired copy in the forensics output directory was produced with 1 substitution (every backslash not starting a legal JSON escape doubled) and parses; the original was never touched, in line with the read-only rule for the corpus directory.

Non-banks

world_quiz_masterplan_v3.yml, quizwiztaxonomy.yaml, quizwizquestiontypes.yaml, qwizz-irresist.yaml, the two JSON Schemas, the atlas scripts/ prompts/ tests/ staging/ meta/ trees, the world-quiz generator sources, the prose briefs, and arcprint_100_quiz_extraction_engines.csv carry no questions and are not counted in any bank.

(b) Overlap matrix

Measured by extract_overlap.py. The matrix is meaningless without its normalisation, so it is quoted as code:

import hashlib, re, unicodedata
def norm_stem(s: str) -> str:
    s = unicodedata.normalize("NFKD", str(s))
    s = "".join(c for c in s if not unicodedata.combining(c))
    s = s.casefold()
    s = re.sub(r"[^\w\s]", " ", s, flags=re.UNICODE)
    s = re.sub(r"\s+", " ", s).strip()
    return s
def stem_hash(s: str) -> str:
    return hashlib.sha256(norm_stem(s).encode("utf-8")).hexdigest()[:16]

Shared-hash matrix, 11 × 11, symmetric; the diagonal is that bank’s distinct hash count.

gold240gold360gold504gold1008runway_queuewave01_168qs_jsonwqd_questionsrefdemo_33atlas_physci_easylive_679
gold24024023923923901111065
gold36023936036036001111070
gold50423936050450401111070
gold10082393605041008011110101
runway_queue00000000000
wave01_16811110165000019
qs_json111100321102
wqd_questions111100122910205
refdemo_33111100113301
atlas_physci_easy000000000240
live_679657070101019220510678

Two rows deserve a caption. runway_queue is all zeros because it holds 8,992 rows and 0 stems. wave01_168 holds 168 rows but 165 distinct hashes: Match each term to its description. appears 3× and Match each term to its best description. 2×.

Headline duplication

ScopeTotal rowsDistinct stem hashesDuplication (rows − distinct)
all 11 banks12,2711,84110,430
excluding runway_queue3,2791,8411,438
excluding live_67911,5921,48810,104
excluding runway_queue and live_6792,6001,4881,112

763 duplicate clusters were measured (full list in the machine-readable overlap.json).

Collision examples, quoted in full

Collision 1 — hash e32fc11a82cf29a2, cluster size 9 across 8 banks. Normalised: who painted the mona lisa.

  • gold240/gold360/gold504/gold1008 id QZ-LIT-001: “Who painted the Mona Lisa?”
  • qs_json id q17: “Who painted the Mona Lisa?”
  • wqd_questions id wqd:opentdb:269f37b5c3427be2:en: “Who painted the ‘Mona Lisa’?”
  • refdemo_33 (id empty): “Who painted the Mona Lisa?”
  • live_679 id 357: “Who painted the ‘Mona Lisa’?” and id 1915: “Who painted the Mona Lisa?”

Collision 2 — hash 49006b8c43cef9bb. Normalised: quel est le plus petit etat souverain du monde par la superficie.

  • wqd_questions id wqd:opentdb:798427cca75149b7:fr: “Quel est le plus petit État souverain du monde par la superficie ?”
  • live_679 id 485: “Quel est le plus petit État souverain du monde par la superficie…”

Collision 3 — hash a48bd571c89df08e. Normalised: quelle est la somme des angles interieurs d un triangle en geometrie euclidienne.

  • wqd_questions id wqd:opentdb:905ac5cae3b0c65a:fr: “Quelle est la somme des angles intérieurs d’un triangle en géométrie euclidienne ?”
  • live_679 id 503: “Quelle est la somme des angles intérieurs d’un triangle en géométrie euclidienne…”

Deliberate near-miss — NOT merged. Token overlap 1.000, different hashes:

  • qs_json id q15 (6fc7c38ad33710fd): “Which country gifted the Statue of Liberty to the United States?” → which country gifted the statue of liberty to the united states
  • live_679 id 104 (5e8626910c4e48dc): “Which country gifted the Statue of Liberty to the United States of…” → which country gifted the statue of liberty to the united states of

The normalised strings differ, so the hashes differ. The normalisation collapses case, diacritics, punctuation and whitespace only — never wording. Three further near-misses at token overlap 0.929 are recorded in the overlap artifact, all of them clipped post_title prefixes.

(c) Provenance

Measured by extract_provenance.py, plus one bench query for the draft-quiz binding. 321 of 679 live stems are truncated by post_title clipping, so live↔corpus matching is prefix-aware for those rows (>20 normalised characters) and exact otherwise.

#HypothesisVerdict
1Gold content chain 240 → 360 → 504 → 1008PROVEN
2wave01_168 is an ancestor in that chainREFUTED
3gold1008 columns ⊇ gold504 columnsREFUTED
4Runway = Gold 1008 + a queue of empty slotsPROVEN, one qualifier
5Live 229 qw_wqd_id rows came from questions.jsonPROVEN
6The live 40 OpenTDB questions have a local source fileREFUTED
7Each draft quiz binds to exactly one pack digestPROVEN (was UNDECIDED)
8The draft packs are source-less generated contentREFUTED

VERDICT 1 — the Gold content chain is linear: PROVEN. Ordered-pair overlap (smaller → larger): gold240→gold360 239/240 stems and 240/240 IDs; gold360→gold504 360/360 stems and 360/360 IDs; gold504→gold1008 504/504 stems and 504/504 IDs. Growth is exact doubling of the per-category serial range (20 → 30 → 42 → 84 across 12 category families).

The single named drop in the whole chain is not a deletion: QZ-PEO-018 was reworded in place under the same ID at the 240→360 step — "…Article 1 of the Universal Declaration of Human Rights: 'All human beings are born free and equal in ___ and ___.'" became "…Article 1 of the 1948 Universal Declaration: …". Dropped totals for gold360→gold504, gold360→gold1008 and gold504→gold1008 are 0.

VERDICT 2 — wave01_168 is not an ancestor: REFUTED. 0/165 shared IDs and 1/165 shared stems against every Gold bank; its per-family serials run 085–098, exactly one past Gold 1008’s 084. It is the unmerged next wave. The single shared stem is "In the standard adult insect body plan, how many legs are attached to the thorax? Give an exact number."

VERDICT 3 — the schema chain is not a superset: REFUTED. gold504 has 22 columns, gold1008 17; 9 of gold504’s columns are absent from gold1008: Legacy subcategory, Primary branch, Primary topic, All topic tags, Coverage lens(es), Cycle, Q Type, Correct answer / mapping, Review by (two of those are renames; the substantive losses are branch, topic, topic tags, coverage lens, cycle, review by). Consequence for any merge: branch/topic/cycle must be read from gold504, never from gold1008. One linear content line, two divergent schema branches.

VERDICT 4 — Runway = 1008 + queue: PROVEN with a qualifier. Production Queue holds 8,992 rows, 24 columns, 0 rows with any stem text, all Status = Brief Needed, IDs QZ-10K-01009 … QZ-10K-10000. Six of the seven Gold-1008 sheets are content-identical by sha256 of the parsed row matrix; the seventh, Question Showcase, is the one differing sheet — a header cell Player prompt + presented elements became Question and the answer column was re-rendered (B — The Kuleshov effectThe Kuleshov effect [B]) across its 12 rows. So: 1008 plus a slot queue plus a cosmetic showcase edit, not a byte copy.

VERDICT 5 — the live 229 came from questions.json: PROVEN 229/229. The file holds 229 items, 58 distinct origin_id, langs en 58 / de 57 / fr 57 / es 57. Live carries 229 rows with qw_wqd_id, 58 distinct values, the same language split. Naive matching (live wqd_id → file id) yields 58 agreements and a false 171 mismatches, because the plugin stores the origin (:en) id on every language row. Resolving through (origin_id, lang): matched 229, id-only 0, stem-only 0, conflicts 0.

VERDICT 6 — the 40 OpenTDB questions have no local source: REFUTED. 40 live rows carry integer qw_opentdb_id with source = "OpenTDB", all lang=en; 39 of 40 match nothing in the corpus directory. The single hit, "What is the capital of Australia?" in Qs.json, is a common-knowledge collision, not lineage. Their upstream is the external Open Trivia Database, distinct from the wqd:opentdb:<hash>:<lang> ids of the world-quiz dataset.

VERDICT 7 — draft-quiz ↔ pack-digest binding: PROVEN. The provenance run left this UNDECIDED-because live-stems.json carries no quiz→question relation. One bench query settled it (get_post_meta($quiz_id,'qw_question_ids'), then each question’s qw_source digest). Every draft quiz maps to exactly one digest and every digest to exactly one quiz:

QuizTitleQuestionsPack digest (first 8)
1556QuizWizz English Import Smoke Test107966a781
1657QuizWizz English General Knowledge100fabae9e7
1758QuizWizz English Science and Technology10079f32477
1859QuizWizz English History and Geography100c22b78d0
1960QuizWizz English Arts and Entertainment1009b142bed

VERDICT 8 — the packs are not source-less: REFUTED. All 410 pack questions resolve to gold1008 + wave01_168: the four 100-question packs draw 86–93 from gold1008 topped up with 7–14 from wave01_168; the 10-question smoke pack is entirely wave01_168. Unmatched: 0.

Bidirectional reconciliation

BankLive stems found in bankShare of 679Bank distinct stemsBank stems already liveShare of bank live
gold24012518.41%24012451.67%
gold36014721.65%36014640.56%
gold50418326.95%50418236.11%
gold100835452.14%100835335.02%
wave01_168578.39%1655734.55%
runway_queue00%00n/a
wqd_questions23033.87%229229100%
qs_json30.44%3226.25%
refdemo_3320.29%3313.03%
atlas_physci_easy00%2400%

640 of 679 live stems match at least one corpus file; the 39 that match none are exactly the OpenTDB rows minus the one Qs.json collision. The wqd_questions row reads 230 live hits against 229 distinct file stems because the Mona-Lisa stem is a genuine cross-lineage collision, not a normalisation artefact.

(d) Canonical schema

The floor is the deployed validator on the bench, wp/wp-content/plugins/quizwizz/includes/QuestionValidator.php (121 lines, QuestionValidator::validate_array()), read 2026-08-19 — not a local release zip.

#ConstraintError codefile:line
1type must be one of multiple | booleanqw_invalid_typeQuestionValidator.php:40
2legacy alias singlemultiple before the type check:37-39
3stem required, length ≤ 2000qw_invalid_stem:45
42..8 choices, none emptyqw_invalid_choices:64
5boolean requires exactly 2 choicesqw_invalid_boolean_choices:67
6a single choice ≤ 1000 charsqw_invalid_choice:71
7choices distinct under comparison_key() (lowercase + whitespace collapse)qw_duplicate_choices:75-86, key at :15-33
8correct_idx int or all-digit string and an existing choice indexqw_invalid_correct:88-95
9explanation ≤ 4000 charsqw_invalid_explanation:98
10lang matches /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/qw_invalid_language:101-104, regex at :102
11difficulty in easy | normal | hard, medium aliased to normalqw_invalid_difficulty:105-109, alias at :106
12accepted return shape — nothing else survives:111-119

The boundary rule. The accepted shape is exactly type, stem, choices, correct_idx, explanation, lang, difficulty (:111-119). Every other field any corpus carries is discarded at the validator boundary. Anything worth keeping must be stored beside the question as qw_* post meta or as taxonomy by the importer, or it is gone — which is why the proposal has two layers. A second validator exists at includes/Import/QuestionValidator.php (113 lines); the import path is not the render path, and the floor above decides what a published question may look like.

Two traps worth naming: "Red" and "red " collide under comparison_key() and the row is rejected; and the lang regex is lowercase-only, so Qs.json’s declared zh-Hans and pt-BR must be written zh-hans and pt-br.

Core / sidecar split

48 sidecar fields were enumerated across all 11 banks, one disposition each, none unassigned:

DispositionFieldsExamples
keep-as-postmeta23qw_source_ref, qw_origin_bank, qw_source_title/_url/_grade/_license, qw_freshness, qw_value_tags, qw_why_selected, qw_qa_status, qw_topic_tags, qw_coverage_lens, qw_cycle, qw_tags, qw_quality, qw_evidence, qw_entities, qw_localization, qw_rights, qw_temporal, qw_lineage
keep-as-taxonomy4category → qw_subject top slug; subcategory → leaf slug; gold504 Primary branch and Primary topic
drop12Review by, difficulty basis/confidence, Correct choice IDs, canonical-answer mapping, Choice order, state/schema_version, locale block, prompt context, inline translations{}, per-distractor feedback, raw distractor lists, production-queue workflow columns
blocks-import9Accepted/Reject/Prompt if, Unit, Tolerance, Order matters, Partial credit, media, response runtime (selection_count > 1)

Subjects. qw_subject is a flat term set of path slugs (456 terms on the bench; 438 distinct slugs in use across the live rows). slug(x) = casefold, &and, drop punctuation, spaces and slashes → -, collapse repeats. A row emits at most two terms — slug(top) and slug(top)-slug(leaf) — or slug(leaf) alone where the bank has no top level. That is exactly the live split: 229 rows carry two terms (e.g. science, science-botany), 450 rows carry one leaf slug (e.g. marine-life). The import that produced the packs dropped the top term; the canonical rule restores it.

Difficulty. Every workbook bank says medium, and the validator already aliases it to normal at :106. The expensive collisions are the numeric scales, collapsed explicitly: qs_json beginner|easy → easy, medium → normal, hard|expert → hard, falling back on ds (1–2 easy, 3 normal, 4–5 hard); wqd_questions level 2/3/4 → easy/normal/hard; refdemo_33 display_level_1_5 1–2/3/4–5 → easy/normal/hard.

Translations. One record per (question, language), linked by origin_id; the origin record’s origin_id is its own id. This is not a new mechanism: questions.json already models it (229 items = 58 origins × en/de/fr/es, is_origin true on the 58 en rows only) and the deployed plugin already stores it (qw_wqd_id holds the origin id on every language row — 58 distinct across 229 live rows). Invariants: choice count and correct_idx must agree across a family (a divergence is a conflict, not a silent fix); each row’s lang must satisfy the regex; the family is never merged into one row — the plugin has one post per (question, language). Origins with no en row: zero today; nothing is auto-translated.

Formats the plugin cannot render

15 non-renderable types over 7,223 rows, of which 5,780 are empty runway_queue slots carrying no content to lose.

TypeRowsWhy it blocksConvertible to multiple?
multi_choice1,335more than one correct answer; exactly one correct_idx (:88-95)only by editorial rewrite
short_text1,072no choice list at all; 2..8 non-empty choices required (:64)only by authoring ≥1 distractor per row
numeric802empty choices plus Unit/Tolerance gradingonly by authoring distractors and discarding tolerance
matching800answer is a maplossy — treat as blocked
cloze799multi-blank free textno
classification798answer is a partitionno
common_connection798correct answer is not among the presented elementsin principle, but the distractors do not exist
sequence798ordered answer plus Order mattersonly as a rewritten question
text6free textneeds authored distractors
sort5orderingno
free_text5acceptance rubricneeds authored distractors
multi2selection_count > 1no
composite1multi-part itemno
date1typed date answer with toleranceneeds authored distractors
ordering1same as sequenceno

Renderable for contrast: single_choice/single/mcq alias to multiple (:37-39), and boolean passes as-is with exactly 2 choices (:67). The 26 atlas rows have no type field; their implicit single-correct + 3 wrongs shape is renderable as multiple.

(e) Dedupe and conflict policy

Rule 1 — identity. Two tiers, in order: (1) same normalised stem hash, using the code quoted in section (b); (2) same upstream ID inside a measured lineage (the Gold chain carries 240/240, 360/360, 504/504 IDs). Falsifier: a reviewed pair with identical stem_hash turns out to ask different answerable questions, or a same-ID Gold row is proven to be a replacement rather than a revision.

Rule 2 — source precedence for answer/explanation conflicts, best first: live_679wqd_questionsgold504gold1008wave01_168gold360gold240refdemo_33atlas_physci_easyqs_jsonrunway_queue. Tie-break: longest non-empty explanation, then lexicographically lowest (bank, id), with all losing rows retained in merge provenance. Special-field exception: branch/topic/cycle come from gold504 even though gold1008 outranks it on content, because those 9 columns are absent there (Verdict 3). Falsifier: a higher-precedence source carries an older or wrong answer while a lower-precedence one carries the deployed, editorially approved answer.

Rule 3 — translations stay adjacent by origin_id. Inside a family identity is (origin_id, lang); never dedupe a translated row against its origin merely because origin_id matches. If an origin loses to a higher-precedence duplicate, re-attach its translations to the survivor; if no origin survives, reject the whole family together and keep it in the rejection ledger. Falsifier: a live language row without qw_wqd_id/qw_wqd_source provenance, or two different stems for one (origin_id, lang).

Rule 4 — unrenderable formats are rejected at the gate, never auto-converted. Rejected rows are retained with source bank, id, type, stem hash and original path. Measured effect after identity collapse: 796 rows rejected — multi_choice 144, short_text 108, cloze 85, sequence 84, classification 84, common_connection 84, numeric 83, matching 81, blank 32, text 6, sort 5. Falsifier: the deployed importer accepts and renders a non-multiple/non-boolean format with a deterministic single correct_idx.

Rule 5 — duplicate files are imported once, from the canonical path.

Duplicate setCanonical path (relative to the corpus directory)DuplicateMeasurement
Wave-01 workbookQwizz_Wave_01_Editorial_Candidates_168.xlsxQwizz_Wave_01_Editorial_Candidates_168 (1).xlsxsha256 identical, prefix 6a7161f1fce7b6fb
10k Runway workbookQwizz_10k_Content_Runway.xlsxQwizz_10k_Content_Runway_dfq.xlsxsha256 identical, prefix c46b8b7a8f6cb932
payload zipWorld Quiz Datase files 4 qwizz.zipquiz-lib_claude-jam_files.zipcontainer sha256 differs, member CRC + size lists match
extracted datasetWorld Quiz Datase files 4 qwizz/questions.jsonquiz-lib_claude-jam_files/questions.jsonsha256 identical, prefix b3004656d4028bf8

Nobody should re-import the same 168 rows or the same 8,992 slots twice. Falsifier: any pair shows a different content sha256 or member CRC list under a fresh hash run.

Rule 6 — never import what is already live. A candidate whose stem hash is in live_679, or a wqd_questions row resolving to an already-live qw_wqd_id / (origin_id, lang), is provenance for the live question, not a new import. Measured skipped-as-already-live after identity collapse and validator gating: 632 (wqd_questions 229, gold504 174, gold1008 171, wave01_168 57, qs_json 1). Corrected 2026-08-19 — the first run reported 339 (wqd_questions 223) against a live dump built from truncated post titles; see the correction note below. Falsifier: a row classified already-live is absent from a fresh bench dump by both stem hash and provenance id.

What the policy deletes on today’s corpus

StepSubtractionRemainingEvidence
rows in, all 11 banks12,271overlap headline, section (b)
remove the deployed reference live_679−67911,592bench Phase-0 dump: 679 published and indexed
drop stemless production slots−8,9922,600runway_queue 8,992 rows / 0 stems
drop duplicate-file rows−02,600duplicates were never loaded as separate banks — canonical paths only
collapse duplicate stem identities−1,1121,4882,600 rows / 1,488 distinct hashes, section (b)
reject unrenderable formats−796692validator floor multiple | boolean; breakdown in Rule 4
skip already-live corpus questions−63260live reconciliation + dedupe_ledger.py (corrected 2026-08-19)

The arithmetic closes visibly:

12,271 − 679 − 8,992 − 0 − 1,112 − 796 − 632 = 60 importable questions.

Survivors by bank: atlas_physci_easy 24, qs_json 19, gold1008 9, gold504 5, wave01_168 3, wqd_questions 0.

Correction 2026-08-19 — 353 was wrong, 60 is measured

This page first published 353 importable questions with wqd_questions 6 survivors, contradicting the provenance section above (wqd 229/229 live, 0 conflicts). The provenance section was right. The dedupe ledger compared candidate stems against a live dump built from qw_question post titles, and WordPress truncates 321 of the 679 titles with an ellipsis, so every long live stem failed hash comparison. The 6 alleged wqd survivors are live posts 410, 387, 365, 407, 363 and 374: their qw_wqd_id equals the questions.json item id, and those 58 origin rows carry origin_id = null, so an origin_id-only check could never match them. The live dump is now taken from post_content (0 of 679 truncated), already-live rose 339 → 632, and survivors fell 353 → 60. Falsifier: a fresh post_content dump shows any of the 60 survivors already published.

Artifacts and re-run commands

Machine-readable outputs live outside the vault, on the author’s machine under ~/dev/qwizz/05 EXPORTS/2026-08-19-corpus-forensics/:

DeliverableHuman formMachine formProducer
(a) censuscensus.mdcensus.jsonextract_census.py
(b) overlapoverlap.mdoverlap.json (763 clusters, full matrix)extract_overlap.py
(c) provenanceprovenance.mdprovenance.jsonextract_provenance.py
(d) schemaschema.mdschema.jsonhand-authored from the bench validator quotes
(e) dedupe policydedupe-policy.mddedupe-policy.jsongenerate_dedupe_policy.py, ledger by dedupe_ledger.py
live referencelive-stems.json (679 rows, bench dump)bench wp eval-file
repaired copyqs-json-repaired.json1 substitution, original untouched
# corpus side (local, read-only on the corpus directory)
cd "~/dev/qwizz/05 EXPORTS/2026-08-19-corpus-forensics"
python3 extract_census.py && python3 extract_overlap.py
python3 extract_provenance.py && python3 generate_dedupe_policy.py && python3 dedupe_ledger.py
 
# bench side (ground truth, validator quotes, pack binding)
cd ~/dev/wrdp
sudo -n docker compose --profile cli run --rm \
  -v /tmp/qw_phase0.php:/tmp/qw_phase0.php wpcli wp eval-file /tmp/qw_phase0.php

Open questions and next actions

  1. Authoring vs merging. 796 rows are blocked purely because the deployed validator scores by one correct_idx. Either the renderer grows a second scoring contract, or those rows stay out. This audit does not decide it.
  2. Where branch/topic/cycle live. Verdict 3 forces a merge run to read two workbooks for one row. Decide whether qw_subject gains the branch level or the branch stays in qw_topic_tags.
  3. The 8,992 Runway slots. They are work orders, not content. Decide whether they enter the programme at all before anyone counts them as corpus again.
  4. qs_json lang normalisation. zh-Hans and pt-BR must be lowercased before import; the 19 surviving rows are the smallest place to prove that path.
  5. Import provenance keys. The proposal adds qw_source_ref beside the existing qw_wqd_id / qw_opentdb_id convention. Confirm one key namespace before the first merge writes meta.