yams/scripts/prepare_longmemeval_s.py
Trevon 111988c6e4
feat(topology): typed event time and session for topology inputs (#305)
* feat(topology): typed event time and session for topology inputs

Add `eventTime` (epoch seconds, UTC) and `sessionId` to
`TopologyDocumentInput`. The extractor fills them from two documented
metadata keys: `event_time` (integer epoch seconds, or ISO-8601
`YYYY-MM-DD` / `YYYY-MM-DD[T ]HH:MM[:SS[.frac]][Z|±HH:MM]`, naive
datetimes are UTC) takes precedence over the document createdTime; an
unparsable value is skipped with a debug log, never an error, and a
zero createdTime leaves the field unset. `session_id` is copied
verbatim and empty when absent. The keys are only consulted when the
extraction loads metadata; otherwise createdTime is the only source.

`parseTopologyEventTime` is public for tests and future callers; the
calendar math is Hinnant's days_from_civil with range-checked fields.
Extraction stats count the event time source and invalid values.

Nothing downstream reads the fields yet. Artifacts, codecs and both
routing fingerprints are unchanged, which the new test pins.

Tests (tests/unit/topology/topology_input_event_time_catch2_test.cpp):
parser accept/reject tables, metadata precedence over createdTime,
invalid ignored, session_id, includeMetadata=false fallback, and
byte-identical snapshot payload plus equal construction and road
fingerprints with and without the typed fields.

Signed-off-by: trvon <me@trevon.dev>

* bench(longmemeval): event_time and session_id metadata, round docs, haystack sidecar

Every exported corpus document now carries `metadata.event_time`
(ISO-8601, normalized from the LongMemEval `YYYY/MM/DD (Day) HH:MM`
haystack date, first-seen date per session) and `metadata.session_id`.
Document ids, titles, texts, queries and qrels are unchanged in the
default mode.

`--round-docs` adds one document per session round (`<sid>__r<n>`): a
user turn opens a round and the replies that follow join it; a leading
assistant turn is its own round. Round docs share the session's
`event_time`/`session_id` and add `round_index`; qrels stay at the
session level.

`--full-haystack` also writes `haystacks.jsonl`, one line per question
with `haystack_session_ids` and the normalized `haystack_dates`, so
per-question membership survives the global corpus deduplication
(the 2026-10-10 preflight lost it).

The converter is split into `convert` / `write_outputs`; `--self-test`
runs both on a three-session synthetic fixture without a download and
is the regression check for the default output.

Signed-off-by: trvon <me@trevon.dev>

* bench(retrieval): carry BEIR corpus metadata onto ingested documents

`retrieval_quality_bench` ingests the BEIR corpus as a directory, which
cannot carry per-file metadata, so `event_time`, `session_id` and
`round_index` from corpus.jsonl never reached the topology extractor.

`BEIRDocument::metadata` now holds the corpus line's `metadata` object
(strings as is, numbers and booleans stringified; both the generic and
the SciFact loader read it), and `applyBenchmarkDocumentMetadata`
copies it onto the stored documents matched by exact file path through
the metadata repository (`ON CONFLICT DO UPDATE`, so warm caches can
repeat it). The bench runs it after the ingest wait, for cold and
reused corpora alike, and logs how many documents were updated or not
found.

Tests: loader parses and coerces metadata; apply helper writes the
keys, skips documents without metadata, counts missing ones and is
idempotent against a real MetadataRepository.

The portability allowlist entries for the two touched bench files are
re-pointed at their shifted lines; no raw environment read was added.

Signed-off-by: trvon <me@trevon.dev>

---------

Signed-off-by: trvon <me@trevon.dev>
2026-10-14 08:47:04 -06:00

489 lines
18 KiB
Python
Executable file

#!/usr/bin/env python3
"""Convert LongMemEval_S from HuggingFace to BEIR format for yams benchmarking.
Downloads the cleaned LongMemEval_S dataset and converts it to BEIR format
(corpus.jsonl, queries.jsonl, qrels/test.tsv) so the existing
retrieval_quality_bench can consume it directly.
Every corpus document carries a `metadata` object with `event_time` (ISO-8601,
from the session's haystack date) and `session_id`; the bench copies it onto the
ingested documents and the topology extractor reads both keys. `--round-docs`
adds one document per session round (a user turn plus the replies that follow)
with the same `event_time`/`session_id` plus `round_index`. `--full-haystack`
also writes `haystacks.jsonl`, one line per question with its haystack session
ids and dates, so per-question evaluation can scope the shared corpus.
Usage:
uv run --with datasets scripts/prepare_longmemeval_s.py
uv run --with datasets scripts/prepare_longmemeval_s.py --max-questions 50
uv run --with datasets scripts/prepare_longmemeval_s.py --full-haystack --round-docs
python3 scripts/prepare_longmemeval_s.py --self-test
Output: ~/.cache/yams/benchmarks/longmemeval_s/
"""
import argparse
import json
import re
import sys
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
# LongMemEval haystack dates look like "2023/05/20 (Sat) 02:21".
_LME_DATE = re.compile(
r"^(\d{4})/(\d{2})/(\d{2})(?:\s*\([A-Za-z]+\))?(?:\s+(\d{2}):(\d{2})(?::(\d{2}))?)?$"
)
_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:?\d{2})?)?$")
def normalize_event_time(raw) -> str:
"""Return an ISO-8601 `event_time` for a haystack date.
`YYYY/MM/DD (Day) HH:MM` becomes `YYYY-MM-DDTHH:MM:00`; an ISO value passes
through; anything else is returned unchanged (the extractor ignores values it
cannot parse).
"""
text = str(raw).strip()
match = _LME_DATE.match(text)
if match:
year, month, day, hour, minute, second = match.groups()
if hour is None:
return f"{year}-{month}-{day}"
return f"{year}-{month}-{day}T{hour}:{minute}:{second or '00'}"
if _ISO_DATE.match(text):
return text
return text
def _as_turn(turn) -> dict:
if isinstance(turn, str):
turn = json.loads(turn)
return turn
def _turn_lines(turn: dict) -> str:
role = turn.get("role", "unknown")
content = turn.get("content", "")
label = "User" if role == "user" else "Assistant"
return f"{label}: {content}"
def serialize_session(turns: list, date: str) -> str:
"""Serialize a chat session into searchable text.
Each turn may be a dict or a JSON string encoding {role, content}.
"""
lines = [f"[{date}]"]
for turn in turns:
lines.append(_turn_lines(_as_turn(turn)))
return "\n".join(lines)
def session_rounds(turns: list) -> list:
"""Group turns into rounds: a user turn opens a round, replies join it."""
rounds = []
for turn in turns:
turn = _as_turn(turn)
if turn.get("role") == "user" or not rounds:
rounds.append([turn])
else:
rounds[-1].append(turn)
return rounds
def serialize_round(round_turns: list, date: str) -> str:
lines = [f"[{date}]"]
lines.extend(_turn_lines(turn) for turn in round_turns)
return "\n".join(lines)
def round_doc_id(session_id: str, index: int) -> str:
return f"{session_id}__r{index}"
@dataclass
class Conversion:
corpus: dict = field(default_factory=dict) # doc id -> {_id, title, text, metadata}
queries: list = field(default_factory=list) # [{_id, text, metadata}]
qrels: list = field(default_factory=list) # [(query_id, session_id, score)]
haystacks: list = field(default_factory=list) # [{question_id, haystack_session_ids, ...}]
abstention_count: int = 0
question_type_counts: dict = field(default_factory=dict)
session_count: int = 0
round_doc_count: int = 0
def convert(records, *, full_haystack=False, include_abstention=False, round_docs=False):
"""Build the BEIR corpus/queries/qrels (and the haystack sidecar) in memory."""
needed_sids = set() # session IDs referenced by answer_session_ids
if not full_haystack:
for item in records:
for sid in item.get("answer_session_ids", []):
needed_sids.add(sid)
conv = Conversion()
# One document per unique session, deduplicated across questions; the first
# question that mentions a session supplies its date.
for item in records:
qid = item["question_id"]
qtype = item.get("question_type", "unknown")
conv.question_type_counts[qtype] = conv.question_type_counts.get(qtype, 0) + 1
is_abstention = qid.endswith("_abs") or qtype == "false_premise"
session_ids = item["haystack_session_ids"]
dates = item["haystack_dates"]
sessions = item["haystack_sessions"]
for sid, date, turns in zip(session_ids, dates, sessions):
if sid in conv.corpus or not (full_haystack or sid in needed_sids):
continue
event_time = normalize_event_time(date)
conv.corpus[sid] = {
"_id": sid,
"title": f"Chat Session - {date}",
"text": serialize_session(turns, date),
"metadata": {"event_time": event_time, "session_id": sid},
}
conv.session_count += 1
if round_docs:
for index, round_turns in enumerate(session_rounds(turns)):
rid = round_doc_id(sid, index)
conv.corpus[rid] = {
"_id": rid,
"title": f"Chat Session - {date} - round {index}",
"text": serialize_round(round_turns, date),
"metadata": {
"event_time": event_time,
"session_id": sid,
"round_index": index,
},
}
conv.round_doc_count += 1
if full_haystack:
conv.haystacks.append(
{
"question_id": qid,
"haystack_session_ids": list(session_ids),
"haystack_dates": [normalize_event_time(d) for d in dates],
}
)
conv.queries.append(
{"_id": qid, "text": item["question"], "metadata": {"question_type": qtype}}
)
answer_sids = item.get("answer_session_ids", [])
if is_abstention:
conv.abstention_count += 1
if include_abstention:
for sid in answer_sids:
conv.qrels.append((qid, sid, 0))
else:
for sid in answer_sids:
conv.qrels.append((qid, sid, 1))
return conv
def write_outputs(out: Path, conv: Conversion, *, include_abstention, full_haystack, round_docs):
out.mkdir(parents=True, exist_ok=True)
(out / "qrels").mkdir(exist_ok=True)
with open(out / "corpus.jsonl", "w") as f:
f.writelines(json.dumps(doc, ensure_ascii=False) + "\n" for doc in conv.corpus.values())
with open(out / "queries.jsonl", "w") as f:
f.writelines(json.dumps(q, ensure_ascii=False) + "\n" for q in conv.queries)
with open(out / "qrels" / "test.tsv", "w") as f:
f.write("query-id\tcorpus-id\tscore\n")
f.writelines(f"{qid}\t{did}\t{score}\n" for qid, did, score in conv.qrels)
if full_haystack:
with open(out / "haystacks.jsonl", "w") as f:
f.writelines(json.dumps(entry, ensure_ascii=False) + "\n" for entry in conv.haystacks)
meta = {
"dataset": "LongMemEval_S",
"source": "xiaowu0162/longmemeval-cleaned",
"split": "longmemeval_s_cleaned",
"total_questions": len(conv.queries),
"total_sessions": conv.session_count,
"total_documents": len(conv.corpus),
"total_round_documents": conv.round_doc_count,
"total_qrels": len(conv.qrels),
"abstention_questions": conv.abstention_count,
"include_abstention": include_abstention,
"full_haystack": full_haystack,
"round_docs": round_docs,
"question_type_counts": conv.question_type_counts,
}
with open(out / "metadata.json", "w") as f:
json.dump(meta, f, indent=2)
return meta
def _synthetic_records():
"""Three sessions shared across two questions, plus an abstention question."""
s1 = [
{"role": "user", "content": "I adopted a dog named Pepper."},
{"role": "assistant", "content": "Congratulations on Pepper!"},
{"role": "user", "content": "She likes carrots."},
{"role": "assistant", "content": "Many dogs do."},
]
s2 = [
json.dumps({"role": "user", "content": "Planning a trip to Lisbon in June."}),
json.dumps({"role": "assistant", "content": "Lisbon is warm in June."}),
]
s3 = [
{"role": "assistant", "content": "Reminder: dentist on Friday."},
{"role": "user", "content": "Thanks."},
]
return [
{
"question_id": "q1",
"question_type": "single-session-user",
"question": "What is the name of my dog?",
"haystack_session_ids": ["s1", "s2"],
"haystack_dates": ["2023/05/20 (Sat) 02:21", "2023/05/21 (Sun) 10:00"],
"haystack_sessions": [s1, s2],
"answer_session_ids": ["s1"],
},
{
"question_id": "q2",
"question_type": "temporal-reasoning",
"question": "Where am I travelling in June?",
"haystack_session_ids": ["s2", "s3"],
"haystack_dates": ["2023/05/21 (Sun) 10:00", "2023/06/01 (Thu) 08:30"],
"haystack_sessions": [s2, s3],
"answer_session_ids": ["s2"],
},
{
"question_id": "q3_abs",
"question_type": "false_premise",
"question": "When did I sell my boat?",
"haystack_session_ids": ["s3"],
"haystack_dates": ["2023/06/01 (Thu) 08:30"],
"haystack_sessions": [s3],
"answer_session_ids": ["s3"],
},
]
def self_test() -> int:
"""Exercise the converter on a synthetic fixture; no dataset download."""
assert normalize_event_time("2023/05/20 (Sat) 02:21") == "2023-05-20T02:21:00"
assert normalize_event_time("2023/05/20") == "2023-05-20"
assert normalize_event_time("2023-05-20T02:21:00Z") == "2023-05-20T02:21:00Z"
assert normalize_event_time("someday") == "someday"
records = _synthetic_records()
# Default: answer sessions only (abstention answers included, as before), session-level
# docs, metadata on every doc, no sidecar.
default = convert(records)
assert set(default.corpus) == {"s1", "s2", "s3"}, default.corpus.keys()
assert default.session_count == 3
assert default.corpus["s1"]["title"] == "Chat Session - 2023/05/20 (Sat) 02:21"
assert default.corpus["s1"]["text"].startswith("[2023/05/20 (Sat) 02:21]\nUser: I adopted")
assert default.corpus["s1"]["metadata"] == {
"event_time": "2023-05-20T02:21:00",
"session_id": "s1",
}
assert default.corpus["s2"]["metadata"]["event_time"] == "2023-05-21T10:00:00"
assert [q["_id"] for q in default.queries] == ["q1", "q2", "q3_abs"]
assert default.qrels == [("q1", "s1", 1), ("q2", "s2", 1)]
assert default.abstention_count == 1
assert default.haystacks == []
assert default.round_doc_count == 0
with_abs = convert(records, include_abstention=True)
assert ("q3_abs", "s3", 0) in with_abs.qrels
assert with_abs.corpus == default.corpus
# Full haystack: every session, per-question membership and dates kept.
full = convert(records, full_haystack=True)
assert set(full.corpus) == {"s1", "s2", "s3"}
assert full.corpus == default.corpus # the fixture's haystacks are all answers
assert full.haystacks == [
{
"question_id": "q1",
"haystack_session_ids": ["s1", "s2"],
"haystack_dates": ["2023-05-20T02:21:00", "2023-05-21T10:00:00"],
},
{
"question_id": "q2",
"haystack_session_ids": ["s2", "s3"],
"haystack_dates": ["2023-05-21T10:00:00", "2023-06-01T08:30:00"],
},
{
"question_id": "q3_abs",
"haystack_session_ids": ["s3"],
"haystack_dates": ["2023-06-01T08:30:00"],
},
]
# Round docs: one per user-opened round, session docs unchanged.
rounds = convert(records, full_haystack=True, round_docs=True)
assert rounds.session_count == 3
# s1: 2 user rounds; s2: 1; s3: a leading assistant turn is its own round, then 1 user round.
assert rounds.round_doc_count == 5, rounds.round_doc_count
assert rounds.corpus["s1"] == full.corpus["s1"]
r0 = rounds.corpus["s1__r0"]
assert r0["text"] == (
"[2023/05/20 (Sat) 02:21]\nUser: I adopted a dog named Pepper.\n"
"Assistant: Congratulations on Pepper!"
)
assert r0["metadata"] == {
"event_time": "2023-05-20T02:21:00",
"session_id": "s1",
"round_index": 0,
}
assert rounds.corpus["s1__r1"]["metadata"]["round_index"] == 1
assert rounds.corpus["s3__r0"]["text"].endswith("]\nAssistant: Reminder: dentist on Friday.")
assert rounds.corpus["s3__r1"]["text"].endswith("]\nUser: Thanks.")
assert "s3__r2" not in rounds.corpus
assert rounds.qrels == default.qrels # qrels stay at session level
# Files: default layout has no sidecar; full haystack writes it.
with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp) / "default"
meta = write_outputs(
out, default, include_abstention=False, full_haystack=False, round_docs=False
)
assert sorted(p.name for p in out.iterdir()) == [
"corpus.jsonl",
"metadata.json",
"qrels",
"queries.jsonl",
]
lines = (out / "corpus.jsonl").read_text().splitlines()
assert [json.loads(line)["_id"] for line in lines] == ["s1", "s2", "s3"]
assert json.loads(lines[0])["metadata"]["session_id"] == "s1"
assert (out / "qrels" / "test.tsv").read_text() == (
"query-id\tcorpus-id\tscore\nq1\ts1\t1\nq2\ts2\t1\n"
)
assert meta["total_sessions"] == 3 and meta["total_round_documents"] == 0
out_full = Path(tmp) / "full"
meta_full = write_outputs(
out_full, rounds, include_abstention=False, full_haystack=True, round_docs=True
)
side = [
json.loads(line) for line in (out_full / "haystacks.jsonl").read_text().splitlines()
]
assert [e["question_id"] for e in side] == ["q1", "q2", "q3_abs"]
assert side[1]["haystack_session_ids"] == ["s2", "s3"]
assert meta_full["total_documents"] == 8 and meta_full["round_docs"] is True
print("self-test passed")
return 0
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--output-dir",
type=Path,
default=Path.home() / ".cache" / "yams" / "benchmarks" / "longmemeval_s",
help="Output directory for BEIR-format files",
)
parser.add_argument(
"--max-questions",
type=int,
default=None,
help="Limit number of questions (for quick iteration)",
)
parser.add_argument(
"--include-abstention",
action="store_true",
help="Include false-premise (abstention) questions in qrels with score=0",
)
parser.add_argument(
"--full-haystack",
action="store_true",
help="Include all haystack sessions in corpus (default: only qrel-referenced) and "
"write haystacks.jsonl with each question's haystack session ids and dates",
)
parser.add_argument(
"--round-docs",
action="store_true",
help="Also emit one document per session round (<session>__r<n>) with round_index metadata",
)
parser.add_argument(
"--self-test",
action="store_true",
help="Run the converter on a synthetic fixture and exit (no download)",
)
args = parser.parse_args()
if args.self_test:
sys.exit(self_test())
try:
from datasets import load_dataset
except ImportError:
print(
"Error: 'datasets' package required.\n"
" uv pip install datasets\n"
"Or run with:\n"
" uv run --with datasets scripts/prepare_longmemeval_s.py",
file=sys.stderr,
)
sys.exit(1)
print("Loading LongMemEval_S from HuggingFace...")
# Stream to avoid downloading all splits (the _m split is >2GB and
# overflows pyarrow's int32 block size).
ds = load_dataset(
"xiaowu0162/longmemeval-cleaned",
split="longmemeval_s_cleaned",
streaming=True,
)
# Materialize the streamed dataset into a list
ds = list(ds)
if args.max_questions and args.max_questions < len(ds):
ds = ds[: args.max_questions]
print(f"Subsampled to {len(ds)} questions")
conv = convert(
ds,
full_haystack=args.full_haystack,
include_abstention=args.include_abstention,
round_docs=args.round_docs,
)
if not args.full_haystack:
print(f"Qrel-referenced sessions: {conv.session_count} (use --full-haystack for all)")
out = args.output_dir
write_outputs(
out,
conv,
include_abstention=args.include_abstention,
full_haystack=args.full_haystack,
round_docs=args.round_docs,
)
print(f"\nDone! Output: {out}")
print(f" Corpus: {conv.session_count} sessions, {len(conv.corpus)} documents")
if args.round_docs:
print(f" Rounds: {conv.round_doc_count} round documents")
print(f" Queries: {len(conv.queries)} questions")
print(f" Qrels: {len(conv.qrels)} judgments")
print(f" Abstention (excluded): {conv.abstention_count}")
print(f" Question types: {conv.question_type_counts}")
if args.full_haystack:
print(f" Haystacks: {out / 'haystacks.jsonl'}")
print(
"\nRun benchmark:\n"
" YAMS_TEST_SAFE_SINGLE_INSTANCE=1 YAMS_BENCH_DATASET=longmemeval_s "
"./builddir/tests/benchmarks/retrieval_quality_bench"
)
if __name__ == "__main__":
main()