Skip to content

Commit af4415c

Browse files
jpheinclaude
andcommitted
feat(corpora): HotpotQA loader for Cat 2c multi-hop cross-validation (#43)
Adds sme/corpora/hotpotqa/ — the Phase-1 multi-hop calibration surface from upstream M0nkeyFl0wer#43. HotpotQA's sentence-level annotated supporting facts give Cat 2c (multi-hop retrieval recall by depth) a public, 1000s-scale corpus with known 2-hop evidence, extending the construct-validity story beyond jp-realm-v0.1's 10 hand-authored multi-hop questions. Mirrors the locomo/longmemeval/beam loader interface exactly: - HotpotQuestion / HotpotParagraph dataclasses; load_questions(path) iterator; materialize_sme_corpus(questions, output_dir) writing per-question vaults (each question owns its ~10-paragraph distractor haystack, LongMemEval-style). - Pinned-subset contract: SUBSET=dev_distractor, SETTING=distractor, SUBSET_QUESTION_COUNT=7405, every question 2-hop by construction (HOTPOT_MIN_HOPS=2 — the Cat 2c key). type (bridge/comparison) exposed as the multi-hop shape; all map to sme_category cat_2c. - gold paragraphs (those carrying a supporting fact) flagged is_gold; expected_sources = gold titles; gold_only materialization for an oracle-retrieval upper bound. Tolerant parsing: out-of-range sent_ids and malformed supporting_facts entries are skipped, not raised. - Dataset NOT committed (CC BY-SA 4.0, ~44 MB) — .gitignore + README download instructions, matching the locomo/beam convention. tests/test_hotpotqa_loader.py — 16 schema-fidelity tests against an inline 3-question fixture (bridge / comparison / edge cases), no download needed. Pins the comparability constants so a silent subset change fails loudly. scripts/hotpotqa_retrieval_smoke.py — dependency-free lexical (IDF) retrieval smoke reporting multi-hop recall (both gold paragraphs in top-K). Verified against the real dev-distractor split (downloaded, reshaped from the HF mirror to the original JSON format): parses to exactly 7,405 records, all 2-hop with 2 gold paragraphs each; top-5 recall 64% full / 98% partial, with the expected bridge (>comparison) split that Cat 2c is designed to surface. docs/sme_spec_v8.md — flip the Cat 2c public-corpus appendix cell from "HotpotQA integration pending" to the landed loader + smoke numbers; full Cat 2c cross-val run noted as downstream. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fe95b18 commit af4415c

7 files changed

Lines changed: 1040 additions & 1 deletion

File tree

docs/sme_spec_v8.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1047,7 +1047,7 @@ The appendix is populated incrementally as standard-corpus integration ([#43]) a
10471047
| Category | Hand-authored evidence | Public-corpus evidence | Real-system case studies |
10481048
|---|---|---|---|
10491049
| **Cat 1 — The Lookup** | jp-realm-v0.1 (24 notes, 8 questions) | LongMemEval substrate parity (R@5 = 0.9660 byte-identical to upstream) ||
1050-
| **Cat 2c — The Stairway (multi-hop)** | jp-realm-v0.1 (10 multi-hop questions at hop depths 1/2/3) | HotpotQA integration pending ([#43]) ||
1050+
| **Cat 2c — The Stairway (multi-hop)** | jp-realm-v0.1 (10 multi-hop questions at hop depths 1/2/3) | HotpotQA loader landed ([#43] Phase 1): `sme/corpora/hotpotqa/` parses the dev-distractor split (7,405 questions, all 2-hop, 2 gold paragraphs each); lexical retrieval smoke recovers both gold paragraphs for 64% / ≥1 for 98% at top-5. Full Cat 2c cross-val run downstream. ||
10511051
| **Cat 3 — The Dissonance** | jp-realm-v0.1 (2 contradiction pairs) |||
10521052
| **Cat 4 — The Threshold (Ingestigation)** | jp-realm-v0.1 (3 alias pairs, 5 seeded defects) | MINE integration pending ([#43]) | Content-engine baseline pass 2026-05-05 (canonical-collision bug surfaced + rebuild informed; drafts at [`docs/issue-drafts/2026-05-05-content-engine-feedback/`](issue-drafts/2026-05-05-content-engine-feedback/)) |
10531053
| **Cat 5 — The Missing Room** | jp-realm-v0.1 (1 structural gap between auth_engineering and privacy_research) | MINE / GraphRAG-Bench integration pending ([#43]) ||
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#!/usr/bin/env python
2+
"""HotpotQA retrieval smoke — Cat 2c multi-hop recall at the smallest slice.
3+
4+
Runs a dependency-free lexical (token-overlap / IDF) retrieval over the
5+
smallest slice of the HotpotQA dev distractor split and reports multi-hop
6+
recall: for each question, did the top-K retrieved paragraphs include BOTH
7+
annotated gold paragraphs? This is the same shape the SME Cat 2c reading
8+
takes, exercised end-to-end through the loader without needing chromadb or an
9+
embedding model — a true retrieval smoke, not just a parse check.
10+
11+
The full dev split (hotpot_dev_distractor_v1.json, ~44 MB, CC BY-SA 4.0) is
12+
gitignored; download per sme/corpora/hotpotqa/README.md. This script reads
13+
that file if present and otherwise falls back to the inline test fixture so
14+
it always runs.
15+
16+
Usage:
17+
python scripts/hotpotqa_retrieval_smoke.py # auto-detect data
18+
python scripts/hotpotqa_retrieval_smoke.py --n 50 # first 50 questions
19+
python scripts/hotpotqa_retrieval_smoke.py --k 5 # top-5 retrieval
20+
python scripts/hotpotqa_retrieval_smoke.py --data path/to/hotpot.json
21+
"""
22+
from __future__ import annotations
23+
24+
import argparse
25+
import math
26+
import re
27+
from collections import Counter
28+
from pathlib import Path
29+
30+
from sme.corpora.hotpotqa import HotpotQuestion, load_questions
31+
32+
_WORD_RE = re.compile(r"[a-z0-9]+")
33+
34+
# Default location the README's download instructions write to.
35+
_DEFAULT_DATA = (
36+
Path(__file__).resolve().parent.parent
37+
/ "sme/corpora/hotpotqa/data/hotpot_dev_distractor_v1.json"
38+
)
39+
40+
41+
def _tokens(text: str) -> list[str]:
42+
return _WORD_RE.findall(text.lower())
43+
44+
45+
def _idf_retrieve(question: HotpotQuestion, k: int) -> list[str]:
46+
"""Rank the question's context paragraphs by IDF-weighted token overlap
47+
with the question, returning the top-k paragraph titles.
48+
49+
A minimal lexical retriever — enough to demonstrate the loader feeds a
50+
retriever correctly and that multi-hop recall is measurable. The real
51+
Cat 2c run uses the daemon/flat adapter; this is the smoke.
52+
"""
53+
paragraphs = question.paragraphs
54+
n_docs = len(paragraphs) or 1
55+
# document frequency per token across this question's paragraphs
56+
df: Counter[str] = Counter()
57+
para_tokens: list[set[str]] = []
58+
for p in paragraphs:
59+
toks = set(_tokens(p.title + " " + p.text))
60+
para_tokens.append(toks)
61+
for t in toks:
62+
df[t] += 1
63+
64+
q_tokens = _tokens(question.question)
65+
scored: list[tuple[float, str]] = []
66+
for p, toks in zip(paragraphs, para_tokens):
67+
score = 0.0
68+
for t in q_tokens:
69+
if t in toks:
70+
idf = math.log((n_docs + 1) / (df[t] + 0.5))
71+
score += idf
72+
scored.append((score, p.title))
73+
scored.sort(key=lambda s: -s[0])
74+
return [title for _, title in scored[:k]]
75+
76+
77+
def main() -> int:
78+
ap = argparse.ArgumentParser(description=__doc__)
79+
ap.add_argument("--data", type=Path, default=None)
80+
ap.add_argument("--n", type=int, default=20, help="questions to score")
81+
ap.add_argument("--k", type=int, default=5, help="top-k retrieval")
82+
args = ap.parse_args()
83+
84+
data_path = args.data or (_DEFAULT_DATA if _DEFAULT_DATA.exists() else None)
85+
if data_path is None:
86+
print(
87+
"no dev split found at "
88+
f"{_DEFAULT_DATA} — download per sme/corpora/hotpotqa/README.md; "
89+
"falling back to the inline fixture for a parse-only smoke."
90+
)
91+
from tests.test_hotpotqa_loader import FIXTURE # type: ignore
92+
import json
93+
import tempfile
94+
95+
tmp = Path(tempfile.mkdtemp()) / "fixture.json"
96+
tmp.write_text(json.dumps(FIXTURE))
97+
data_path = tmp
98+
99+
questions = []
100+
for q in load_questions(data_path):
101+
questions.append(q)
102+
if len(questions) >= args.n:
103+
break
104+
105+
print(f"HotpotQA retrieval smoke — {data_path}")
106+
print(f"scoring {len(questions)} questions, top-{args.k} lexical retrieval\n")
107+
108+
full_recall = 0 # both gold paragraphs in top-k
109+
partial_recall = 0 # at least one gold paragraph in top-k
110+
by_type: Counter[str] = Counter()
111+
by_type_full: Counter[str] = Counter()
112+
for q in questions:
113+
retrieved = set(_idf_retrieve(q, args.k))
114+
gold = set(q.gold_titles)
115+
hit = gold & retrieved
116+
by_type[q.qtype] += 1
117+
if gold and gold <= retrieved:
118+
full_recall += 1
119+
by_type_full[q.qtype] += 1
120+
if hit:
121+
partial_recall += 1
122+
123+
n = len(questions)
124+
print(f" multi-hop recall (both gold paragraphs in top-{args.k}): "
125+
f"{full_recall}/{n} = {full_recall / n:.1%}")
126+
print(f" partial recall (>=1 gold paragraph): "
127+
f"{partial_recall}/{n} = {partial_recall / n:.1%}")
128+
print(" by type:")
129+
for t in sorted(by_type):
130+
print(f" {t:12} {by_type_full[t]}/{by_type[t]} full multi-hop recall")
131+
132+
# Smoke gate: lexical retrieval must surface at least one gold paragraph
133+
# for the overwhelming majority — if it can't, the loader is feeding the
134+
# retriever garbage.
135+
ok = n > 0 and partial_recall / n >= 0.9
136+
print(f"\n smoke: {'PASS' if ok else 'FAIL'} "
137+
f"(partial recall >= 90%)")
138+
return 0 if ok else 1
139+
140+
141+
if __name__ == "__main__":
142+
raise SystemExit(main())

sme/corpora/hotpotqa/.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# HotpotQA upstream JSON — not committed.
2+
# Dev distractor split (hotpot_dev_distractor_v1.json ~44 MB) and the
3+
# train split (hotpot_train_v1.1.json ~535 MB). Download per README.md.
4+
data/
5+
*.json
6+
!questions.yaml
7+
!*.yml

sme/corpora/hotpotqa/README.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# HotpotQA — SME corpus loader
2+
3+
This directory holds the loader and SME-shape conversion for the
4+
**HotpotQA** benchmark (Yang et al., EMNLP 2018; arXiv 1809.09600).
5+
The dataset itself is **not committed** to this repo; see the download
6+
section below.
7+
8+
HotpotQA is the Phase-1 multi-hop calibration surface from the
9+
standard-corpora integration plan (upstream
10+
`M0nkeyFl0wer/multipass-structural-memory-eval#43`). It is the public,
11+
1000s-scale corpus with **sentence-level annotated supporting facts**
12+
that lets SME demonstrate — not just design — construct validity for
13+
**Cat 2c** (multi-hop retrieval recall by depth): *"Cat 2c ran against
14+
HotpotQA's known 2-hop evidence and recovered N of M gold paragraphs."*
15+
It mirrors the LoCoMo / LongMemEval loaders' interface exactly.
16+
17+
## Pinned subset (the comparability contract)
18+
19+
HotpotQA cross-comparisons are unreliable unless the split and
20+
retrieval setting are pinned. This loader pins:
21+
22+
| Constant | Value | Meaning |
23+
|---|---|---|
24+
| `SUBSET` | `"dev_distractor"` | the `hotpot_dev_distractor_v1.json` split |
25+
| `SETTING` | `"distractor"` | 10-paragraph haystack (2 gold + 8 distractor) |
26+
| `SUBSET_QUESTION_COUNT` | `7405` | questions in the dev distractor split |
27+
| `HOTPOT_MIN_HOPS` | `2` | every question is 2-hop by construction |
28+
29+
**Any reading published from this loader must state the split and
30+
setting.** The **fullwiki** setting (retrieve over all of Wikipedia) is
31+
a different, IR-heavy task and is out of scope for a loader. The train
32+
split (`hotpot_train_v1.1.json`, 90,447 questions) is also loadable —
33+
pass its path explicitly — but the *pinned* comparability subset is the
34+
dev distractor split.
35+
36+
## Dataset download
37+
38+
```bash
39+
mkdir -p sme/corpora/hotpotqa/data
40+
cd sme/corpora/hotpotqa/data
41+
# dev distractor split (~44 MB) — the pinned subset
42+
wget http://curtis.ml.cmu.edu/datasets/hotpot/hotpot_dev_distractor_v1.json
43+
# optional: train split (~535 MB)
44+
# wget http://curtis.ml.cmu.edu/datasets/hotpot/hotpot_train_v1.1.json
45+
```
46+
47+
The `data/` directory is gitignored — keep the upstream JSON local.
48+
HotpotQA is released under **CC BY-SA 4.0**; redistribution requires
49+
attribution and share-alike, so the corpus is downloaded per-machine
50+
rather than vendored here.
51+
52+
## Hop depth (the Cat 2c join)
53+
54+
SME Cat 2c groups questions by `min_hops`. HotpotQA does not annotate an
55+
explicit integer hop depth, but **every released question is 2-hop by
56+
construction** (two gold supporting paragraphs), so the loader assigns
57+
`min_hops = 2` to every record. The `type` field is the qualitative
58+
multi-hop *shape*:
59+
60+
| `type` | shape | retrieval behavior |
61+
|---|---|---|
62+
| `bridge` | sequential 2-hop | resolve a bridge entity in paragraph A, then use it to answer from paragraph B (true chaining) |
63+
| `comparison` | parallel 2-hop | retrieve a fact from each of two paragraphs and compare them (both must be found, no chaining) |
64+
65+
Both require ≥2 distinct gold paragraphs, hence `min_hops = 2`. A
66+
deeper-hop corpus (e.g. MuSiQue) would extend the depth axis; HotpotQA
67+
pins the 2-hop calibration point that jp-realm-v0.1 cannot reach at
68+
scale.
69+
70+
## Format mapping (HotpotQA → SME)
71+
72+
| HotpotQA field | SME mapping |
73+
|---|---|
74+
| `_id` | `question_id` (preserved as the SME question `id` and the per-question vault dir) |
75+
| `question` | `text` |
76+
| `answer` | `gold_answer` (QA judge target; `"yes"`/`"no"` for comparison questions) |
77+
| `type` (`comparison`/`bridge`) | preserved under `hotpotqa.type`; named via `HOTPOT_TYPE_NAMES`; all map to SME `cat_2c` |
78+
| `level` (`easy`/`medium`/`hard`) | preserved under `hotpotqa.level` |
79+
| `supporting_facts` (`[[title, sent_id], …]`) | preserved under `hotpotqa.supporting_facts`; gold titles → `expected_sources`; sentence texts via `expected_sources_sentence_level()` |
80+
| `context` (`[[title, [sentence, …]], …]`) | one markdown file per paragraph under `vault/<question_id>/<title>.md`; gold paragraphs flagged `is_gold: true` |
81+
| — (assigned) | `min_hops: 2`, `sme_category: cat_2c` |
82+
83+
## Architectural note: per-question vaults (not per-sample)
84+
85+
LoCoMo shares one conversation across all of a sample's questions, so
86+
its loader writes a vault per *sample*. HotpotQA instead gives each
87+
question its own ~10-paragraph haystack, so this loader writes a vault
88+
per *question* (`vault/<question_id>/`) — the same per-question scoping
89+
as LongMemEval. A cross-validation run loops per question:
90+
91+
```python
92+
for q in load_questions(dev_distractor_path):
93+
adapter.reset()
94+
adapter.ingest_corpus_from_dir(vault_dir / q.question_id)
95+
result = adapter.query(q.text, n_results=5)
96+
sme_score = sme_substring_match(result, q.expected_sources_paragraph_level())
97+
# multi-hop recall: did retrieval surface BOTH gold paragraphs?
98+
record(q.question_id, sme_score, min_hops=q.min_hops)
99+
```
100+
101+
`materialize_sme_corpus(..., gold_only=True)` drops the distractors for
102+
an oracle-retrieval upper bound; the default writes the full distractor
103+
haystack (the standard setting).
104+
105+
## Status
106+
107+
- `loader.py``HotpotQuestion` / `HotpotParagraph` dataclasses,
108+
`load_questions(path)` iterator,
109+
`materialize_sme_corpus(questions, output_dir)` for per-question vault
110+
rendering. Pinned-subset constants exported.
111+
- `tests/test_hotpotqa_loader.py` — schema-fidelity tests against an
112+
inline fixture (no download needed).
113+
- **Pending (downstream):** the Cat 2c cross-validation run against the
114+
daemon. The loader is the prerequisite; the run is a separate task.
115+
116+
## Citation
117+
118+
Yang, Z., Qi, P., Zhang, S., Bengio, Y., Cohen, W. W., Salakhutdinov,
119+
R., & Manning, C. D. (2018). *HotpotQA: A Dataset for Diverse,
120+
Explainable Multi-hop Question Answering.* EMNLP 2018. arXiv:1809.09600.
121+
122+
Upstream repo: https://github.com/hotpotqa/hotpot
123+
Project page: https://hotpotqa.github.io/
124+
License: CC BY-SA 4.0

sme/corpora/hotpotqa/__init__.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""HotpotQA corpus loader for SME cross-validation.
2+
3+
HotpotQA (Yang et al., EMNLP 2018; arXiv 1809.09600) is a Wikipedia-based
4+
multi-hop question-answering benchmark with sentence-level annotated
5+
supporting facts. This package loads the released benchmark JSON and produces
6+
SME-shape question records so SME's **Cat 2c** (multi-hop retrieval recall by
7+
depth) reading can be cross-validated against a public, 1000s-scale corpus
8+
with known multi-hop evidence — the construct-validity demonstration the
9+
hand-authored corpora cannot give (upstream
10+
M0nkeyFl0wer/multipass-structural-memory-eval#43, Phase 1).
11+
12+
PINNED SUBSET: ``dev_distractor`` — ``hotpot_dev_distractor_v1.json``, the
13+
**distractor** setting (10-paragraph haystack: 2 gold + 8 distractor),
14+
**7405** questions. Every question is 2-hop by construction, so the loader
15+
assigns ``min_hops = 2`` to every record (the Cat 2c key) and exposes the
16+
``type`` field (comparison vs bridge) as the multi-hop shape. See loader.py's
17+
module docstring for the comparability contract and the hop-depth rationale.
18+
19+
The dataset itself is NOT committed to this repo. Download with:
20+
21+
mkdir -p sme/corpora/hotpotqa/data
22+
cd sme/corpora/hotpotqa/data
23+
wget http://curtis.ml.cmu.edu/datasets/hotpot/hotpot_dev_distractor_v1.json
24+
25+
See README.md for details and the SME mapping table.
26+
"""
27+
28+
from sme.corpora.hotpotqa.loader import (
29+
HOTPOT_MIN_HOPS,
30+
HOTPOT_SME_CATEGORY,
31+
HOTPOT_TYPE_NAMES,
32+
SETTING,
33+
SUBSET,
34+
SUBSET_QUESTION_COUNT,
35+
HotpotParagraph,
36+
HotpotQuestion,
37+
load_questions,
38+
materialize_sme_corpus,
39+
)
40+
41+
__all__ = [
42+
"HOTPOT_MIN_HOPS",
43+
"HOTPOT_SME_CATEGORY",
44+
"HOTPOT_TYPE_NAMES",
45+
"SETTING",
46+
"SUBSET",
47+
"SUBSET_QUESTION_COUNT",
48+
"HotpotParagraph",
49+
"HotpotQuestion",
50+
"load_questions",
51+
"materialize_sme_corpus",
52+
]

0 commit comments

Comments
 (0)