🔬 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| Measurement | Value |
|---|---|
published qw_question posts | 679 |
wp_qwizz_question_index rows | 679 (drift 0) |
index rows lang='en' | 508 |
qw_subject terms | 456 |
wp_qwizz_question_subjects rows | 908 |
| language split over the 679 | en 508 · de 57 · fr 57 · es 57 |
| type split | multiple 588 · boolean 91 |
| difficulty split | easy 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).
| Bank | Rows (measured / claimed) | Cols/keys | Language axis | Difficulty vocabulary | Type distribution (top) |
|---|---|---|---|---|---|
gold240 | 240 / 240 | 13 | none; English text | easy · medium · hard (96/84/60 medium/easy/hard) | single_choice 108, multi_choice 24, short_text 24, boolean 12, +6 rich types ×12 |
gold360 | 360 / 360 | 14 | none | easy · medium · hard (144/120/96) | single_choice 120, multi_choice 36, short_text 36, boolean 24, +6 ×24 |
gold504 | 504 / 504 | 22 | none | easy · medium · hard (204/168/132) | single_choice 144, multi_choice 60, short_text 48, boolean 36, +6 ×36 |
gold1008 | 1008 / 1008 | 17 | none | easy · medium · hard (408/336/264) | single_choice 288, multi_choice 120, short_text 96, boolean 72, +6 ×72 |
runway_queue | 8992 / 8992 | 24 | none | easy · medium · hard (3640/2997/2355) | slots only — the Question column is empty on 8,992 of 8,992 rows |
wave01_168 | 168 / 168 | 17 | none | easy · medium · hard (60 hard/60 medium/48 easy) | single_choice 48, multi_choice 24, boolean 12, +7 ×12 |
qs_json | 32 / no self-claim | 11 | meta.languages claims 6; inline translations{} on 2 of 32 rows | 5 words (medium 11, easy 6, hard 6, expert 5, beginner 4) plus numeric ds 1–5 | boolean 7, multiple 7, single 7, text 6, sort 5 |
wqd_questions | 229 / 229 | 18 | one row per (question, language): lang, is_origin, origin_id | numeric levels 2/3/4 → easy/medium/hard (77/76/76) | mcq 217, boolean 12 |
refdemo_33 | 33 / no self-claim | 17 | per-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_easy | 26 / no self-claim | 9 | none | easy on all 26 | implicit single-correct + 3 wrongs, no type field |
live_679 | 679 / 679 | 13 | per-row lang (en 508, de 57, fr 57, es 57) | easy 276 · normal 240 · hard 163 | multiple 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)
| Comparison | sha256 A (24 hex) | sha256 B (24 hex) | Identical | Verdict |
|---|---|---|---|---|
Wave-01 168 vs its (1) copy | 6a7161f1fce7b6fbf4eb5c80 | 6a7161f1fce7b6fbf4eb5c80 | yes | byte-identical duplicate |
10k Runway vs _dfq | c46b8b7a8f6cb93200d53010 | c46b8b7a8f6cb93200d53010 | yes | byte-identical duplicate |
| the two payload zips | 0c2263a19d56ed77aabe88c1 | 89dcf5ccd048e21851c9e73b | no | same payload, different container bytes — member name/size/CRC lists identical |
the two extracted questions.json | b3004656d4028bf8e3f4abbb | b3004656d4028bf8e3f4abbb | yes | byte-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.
gold240 | gold360 | gold504 | gold1008 | runway_queue | wave01_168 | qs_json | wqd_questions | refdemo_33 | atlas_physci_easy | live_679 | |
|---|---|---|---|---|---|---|---|---|---|---|---|
gold240 | 240 | 239 | 239 | 239 | 0 | 1 | 1 | 1 | 1 | 0 | 65 |
gold360 | 239 | 360 | 360 | 360 | 0 | 1 | 1 | 1 | 1 | 0 | 70 |
gold504 | 239 | 360 | 504 | 504 | 0 | 1 | 1 | 1 | 1 | 0 | 70 |
gold1008 | 239 | 360 | 504 | 1008 | 0 | 1 | 1 | 1 | 1 | 0 | 101 |
runway_queue | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
wave01_168 | 1 | 1 | 1 | 1 | 0 | 165 | 0 | 0 | 0 | 0 | 19 |
qs_json | 1 | 1 | 1 | 1 | 0 | 0 | 32 | 1 | 1 | 0 | 2 |
wqd_questions | 1 | 1 | 1 | 1 | 0 | 0 | 1 | 229 | 1 | 0 | 205 |
refdemo_33 | 1 | 1 | 1 | 1 | 0 | 0 | 1 | 1 | 33 | 0 | 1 |
atlas_physci_easy | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 24 | 0 |
live_679 | 65 | 70 | 70 | 101 | 0 | 19 | 2 | 205 | 1 | 0 | 678 |
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
| Scope | Total rows | Distinct stem hashes | Duplication (rows − distinct) |
|---|---|---|---|
| all 11 banks | 12,271 | 1,841 | 10,430 |
excluding runway_queue | 3,279 | 1,841 | 1,438 |
excluding live_679 | 11,592 | 1,488 | 10,104 |
excluding runway_queue and live_679 | 2,600 | 1,488 | 1,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/gold1008idQZ-LIT-001: “Who painted the Mona Lisa?”qs_jsonidq17: “Who painted the Mona Lisa?”wqd_questionsidwqd:opentdb:269f37b5c3427be2:en: “Who painted the ‘Mona Lisa’?”refdemo_33(id empty): “Who painted the Mona Lisa?”live_679id357: “Who painted the ‘Mona Lisa’?” and id1915: “Who painted the Mona Lisa?”
Collision 2 — hash 49006b8c43cef9bb. Normalised: quel est le plus petit etat souverain du monde par la superficie.
wqd_questionsidwqd:opentdb:798427cca75149b7:fr: “Quel est le plus petit État souverain du monde par la superficie ?”live_679id485: “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_questionsidwqd:opentdb:905ac5cae3b0c65a:fr: “Quelle est la somme des angles intérieurs d’un triangle en géométrie euclidienne ?”live_679id503: “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_jsonidq15(6fc7c38ad33710fd): “Which country gifted the Statue of Liberty to the United States?” →which country gifted the statue of liberty to the united stateslive_679id104(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.
| # | Hypothesis | Verdict |
|---|---|---|
| 1 | Gold content chain 240 → 360 → 504 → 1008 | PROVEN |
| 2 | wave01_168 is an ancestor in that chain | REFUTED |
| 3 | gold1008 columns ⊇ gold504 columns | REFUTED |
| 4 | Runway = Gold 1008 + a queue of empty slots | PROVEN, one qualifier |
| 5 | Live 229 qw_wqd_id rows came from questions.json | PROVEN |
| 6 | The live 40 OpenTDB questions have a local source file | REFUTED |
| 7 | Each draft quiz binds to exactly one pack digest | PROVEN (was UNDECIDED) |
| 8 | The draft packs are source-less generated content | REFUTED |
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 effect → The 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:
| Quiz | Title | Questions | Pack digest (first 8) |
|---|---|---|---|
| 1556 | QuizWizz English Import Smoke Test | 10 | 7966a781 |
| 1657 | QuizWizz English General Knowledge | 100 | fabae9e7 |
| 1758 | QuizWizz English Science and Technology | 100 | 79f32477 |
| 1859 | QuizWizz English History and Geography | 100 | c22b78d0 |
| 1960 | QuizWizz English Arts and Entertainment | 100 | 9b142bed |
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
| Bank | Live stems found in bank | Share of 679 | Bank distinct stems | Bank stems already live | Share of bank live |
|---|---|---|---|---|---|
gold240 | 125 | 18.41% | 240 | 124 | 51.67% |
gold360 | 147 | 21.65% | 360 | 146 | 40.56% |
gold504 | 183 | 26.95% | 504 | 182 | 36.11% |
gold1008 | 354 | 52.14% | 1008 | 353 | 35.02% |
wave01_168 | 57 | 8.39% | 165 | 57 | 34.55% |
runway_queue | 0 | 0% | 0 | 0 | n/a |
wqd_questions | 230 | 33.87% | 229 | 229 | 100% |
qs_json | 3 | 0.44% | 32 | 2 | 6.25% |
refdemo_33 | 2 | 0.29% | 33 | 1 | 3.03% |
atlas_physci_easy | 0 | 0% | 24 | 0 | 0% |
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.
| # | Constraint | Error code | file:line |
|---|---|---|---|
| 1 | type must be one of multiple | boolean | qw_invalid_type | QuestionValidator.php:40 |
| 2 | legacy alias single → multiple before the type check | — | :37-39 |
| 3 | stem required, length ≤ 2000 | qw_invalid_stem | :45 |
| 4 | 2..8 choices, none empty | qw_invalid_choices | :64 |
| 5 | boolean requires exactly 2 choices | qw_invalid_boolean_choices | :67 |
| 6 | a single choice ≤ 1000 chars | qw_invalid_choice | :71 |
| 7 | choices distinct under comparison_key() (lowercase + whitespace collapse) | qw_duplicate_choices | :75-86, key at :15-33 |
| 8 | correct_idx int or all-digit string and an existing choice index | qw_invalid_correct | :88-95 |
| 9 | explanation ≤ 4000 chars | qw_invalid_explanation | :98 |
| 10 | lang matches /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/ | qw_invalid_language | :101-104, regex at :102 |
| 11 | difficulty in easy | normal | hard, medium aliased to normal | qw_invalid_difficulty | :105-109, alias at :106 |
| 12 | accepted 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:
| Disposition | Fields | Examples |
|---|---|---|
keep-as-postmeta | 23 | qw_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-taxonomy | 4 | category → qw_subject top slug; subcategory → leaf slug; gold504 Primary branch and Primary topic |
drop | 12 | Review 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-import | 9 | Accepted/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.
| Type | Rows | Why it blocks | Convertible to multiple? |
|---|---|---|---|
multi_choice | 1,335 | more than one correct answer; exactly one correct_idx (:88-95) | only by editorial rewrite |
short_text | 1,072 | no choice list at all; 2..8 non-empty choices required (:64) | only by authoring ≥1 distractor per row |
numeric | 802 | empty choices plus Unit/Tolerance grading | only by authoring distractors and discarding tolerance |
matching | 800 | answer is a map | lossy — treat as blocked |
cloze | 799 | multi-blank free text | no |
classification | 798 | answer is a partition | no |
common_connection | 798 | correct answer is not among the presented elements | in principle, but the distractors do not exist |
sequence | 798 | ordered answer plus Order matters | only as a rewritten question |
text | 6 | free text | needs authored distractors |
sort | 5 | ordering | no |
free_text | 5 | acceptance rubric | needs authored distractors |
multi | 2 | selection_count > 1 | no |
composite | 1 | multi-part item | no |
date | 1 | typed date answer with tolerance | needs authored distractors |
ordering | 1 | same as sequence | no |
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_679 → wqd_questions → gold504 → gold1008 → wave01_168 → gold360 → gold240 → refdemo_33 → atlas_physci_easy → qs_json → runway_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 set | Canonical path (relative to the corpus directory) | Duplicate | Measurement |
|---|---|---|---|
| Wave-01 workbook | Qwizz_Wave_01_Editorial_Candidates_168.xlsx | Qwizz_Wave_01_Editorial_Candidates_168 (1).xlsx | sha256 identical, prefix 6a7161f1fce7b6fb |
| 10k Runway workbook | Qwizz_10k_Content_Runway.xlsx | Qwizz_10k_Content_Runway_dfq.xlsx | sha256 identical, prefix c46b8b7a8f6cb932 |
| payload zip | World Quiz Datase files 4 qwizz.zip | quiz-lib_claude-jam_files.zip | container sha256 differs, member CRC + size lists match |
| extracted dataset | World Quiz Datase files 4 qwizz/questions.json | quiz-lib_claude-jam_files/questions.json | sha256 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
| Step | Subtraction | Remaining | Evidence |
|---|---|---|---|
| rows in, all 11 banks | — | 12,271 | overlap headline, section (b) |
remove the deployed reference live_679 | −679 | 11,592 | bench Phase-0 dump: 679 published and indexed |
| drop stemless production slots | −8,992 | 2,600 | runway_queue 8,992 rows / 0 stems |
| drop duplicate-file rows | −0 | 2,600 | duplicates were never loaded as separate banks — canonical paths only |
| collapse duplicate stem identities | −1,112 | 1,488 | 2,600 rows / 1,488 distinct hashes, section (b) |
| reject unrenderable formats | −796 | 692 | validator floor multiple | boolean; breakdown in Rule 4 |
| skip already-live corpus questions | −632 | 60 | live 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
353importable questions withwqd_questions6 survivors, contradicting the provenance section above (wqd229/229 live, 0 conflicts). The provenance section was right. The dedupe ledger compared candidate stems against a live dump built fromqw_questionpost titles, and WordPress truncates 321 of the 679 titles with an ellipsis, so every long live stem failed hash comparison. The 6 allegedwqdsurvivors are live posts 410, 387, 365, 407, 363 and 374: theirqw_wqd_idequals thequestions.jsonitemid, and those 58 origin rows carryorigin_id = null, so anorigin_id-only check could never match them. The live dump is now taken frompost_content(0 of 679 truncated), already-live rose 339 → 632, and survivors fell 353 → 60. Falsifier: a freshpost_contentdump 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/:
| Deliverable | Human form | Machine form | Producer |
|---|---|---|---|
| (a) census | census.md | census.json | extract_census.py |
| (b) overlap | overlap.md | overlap.json (763 clusters, full matrix) | extract_overlap.py |
| (c) provenance | provenance.md | provenance.json | extract_provenance.py |
| (d) schema | schema.md | schema.json | hand-authored from the bench validator quotes |
| (e) dedupe policy | dedupe-policy.md | dedupe-policy.json | generate_dedupe_policy.py, ledger by dedupe_ledger.py |
| live reference | — | live-stems.json (679 rows, bench dump) | bench wp eval-file |
| repaired copy | — | qs-json-repaired.json | 1 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.phpOpen questions and next actions
- 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. - Where branch/topic/cycle live. Verdict 3 forces a merge run to read two workbooks for one row. Decide whether
qw_subjectgains the branch level or the branch stays inqw_topic_tags. - 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.
qs_jsonlang normalisation.zh-Hansandpt-BRmust be lowercased before import; the 19 surviving rows are the smallest place to prove that path.- Import provenance keys. The proposal adds
qw_source_refbeside the existingqw_wqd_id/qw_opentdb_idconvention. Confirm one key namespace before the first merge writes meta.