"""BEAM-10M dataset loader — fetches real conversations from HF. BEAM-10M (Mohammadta/BEAM-10M) is a LongMemEval-style benchmark: each row is a long, multi-session conversation with: - conversation_id - user_profile (user_info + user_relationships) — ground-truth facts - narratives — a structured timeline of events - chat — 10 plans, each with 10 batches of ~60 turns - probing_questions — questions to test memory of the conversation - plans — alternate plans structure The HuggingFace datasets API is rate-limited from many sandboxes (429 Too Many Requests from CloudFront). The datasets-server endpoint (hosted separately) IS reachable. This loader pulls via datasets-server so the bench works even when direct HF datasets API is blocked. BULK DOWNLOAD PATHS (added for full-dataset benchmarks): - Local cache directory of per-row JSON files (preferred — produced by scripts/download_beam_full.sh OR by the .github/workflows/ beam-cache.yml workflow that runs on a GitHub Actions runner) - Local parquet file (if you have downloaded the full parquet via `huggingface-cli download Mohammadta/BEAM-10M` on a runner whose IP is not rate-limited; the file is ~342MB). Pass the path via the BEAM_PARQUET env var or the parquet_path argument. - datasets-server /rows endpoint as fallback (works in sandboxes) Usage: from cortexm.bench.beam_loader import load_beam_rows rows = load_beam_rows(n=10) # fetch all 10 conversations for r in rows: print(r['conversation_id']) print(r['user_profile']['user_info']) """ from __future__ import annotations import json import os import urllib.request from pathlib import Path from typing import Iterator DATASETS_SERVER = "https://datasets-server.huggingface.co" DATASET_NAME = "Mohammadta/BEAM-10M" CONFIG = "default" SPLIT = "10M" TOTAL_ROWS = 10 # the 10M split has 10 conversations, each ~10M tokens def _fetch_rows(offset: int = 0, length: int = 1) -> dict: """Fetch BEAM-10M rows via the datasets-server endpoint.""" url = (f"{DATASETS_SERVER}/rows?dataset={DATASET_NAME}" f"&config={CONFIG}&split={SPLIT}" f"&offset={offset}&length={length}") req = urllib.request.Request( url, headers={"User-Agent": "context-m-bench/1.0"}) with urllib.request.urlopen(req, timeout=600) as resp: return json.loads(resp.read().decode("utf-8")) def _load_parquet(parquet_path: str | Path, n: int = TOTAL_ROWS) -> list[dict]: """Load rows from a local parquet file (requires pyarrow or pandas). Each parquet row has the same shape as the datasets-server /rows endpoint's `row` field. We return just the row dicts (no envelope). """ try: import pyarrow.parquet as pq except ImportError: try: import pandas as pd except ImportError: raise ImportError( "loading BEAM-10M from parquet requires pyarrow or " "pandas — install with `pip install pyarrow` or " "`pip install pandas`") df = pd.read_parquet(parquet_path) rows = [] for _, r in df.head(n).iterrows(): rows.append(r.to_dict() if hasattr(r, "to_dict") else dict(r)) return rows table = pq.read_table(parquet_path) n = min(n, table.num_rows) rows = [] # batch-convert to Python dicts (column-at-a-time is faster than row) cols = {name: table.column(name).to_pylist() for name in table.column_names} for i in range(n): rows.append({name: cols[name][i] for name in cols}) return rows def load_beam_rows(n: int = 2, *, cache_dir: str | None = None, parquet_path: str | None = None) -> list[dict]: """Load N BEAM-10M conversations. Resolution order (first available wins): 1. parquet_path argument (or BEAM_PARQUET env var) — a single parquet file containing all 10 rows 2. cache_dir/beam_row_.json — per-row JSON files (the format produced by download_beam_full.sh and beam-cache.yml) 3. datasets-server /rows endpoint — streamed on demand Each row is a dict with keys: conversation_id, conversation_seed, narratives, user_profile, conversation_plan, user_questions, chat, probing_questions, plans. The full 10M dataset has 10 conversations totaling ~975MB in memory. We fetch one row at a time (each is ~50-110MB). """ # 1. parquet path? pq_path = parquet_path or os.environ.get("BEAM_PARQUET") if pq_path and Path(pq_path).exists(): return _load_parquet(pq_path, n=min(n, TOTAL_ROWS)) cache = Path(cache_dir) if cache_dir else None if cache: cache.mkdir(parents=True, exist_ok=True) rows: list[dict] = [] n = min(n, TOTAL_ROWS) for i in range(n): # 2. cached row? cached_path = cache / f"beam_row_{i}.json" if cache else None if cached_path and cached_path.exists(): try: data = json.loads(cached_path.read_text()) if "rows" in data and data["rows"]: rows.append(data["rows"][0]["row"]) continue # some manifests store the row directly without envelope if "conversation_id" in data: rows.append(data) continue except (json.JSONDecodeError, KeyError): # corrupt cache — re-fetch cached_path.unlink(missing_ok=True) # 3. fetch from datasets-server data = _fetch_rows(offset=i, length=1) if cached_path: cached_path.write_text(json.dumps(data)) if "rows" in data and data["rows"]: rows.append(data["rows"][0]["row"]) return rows def parse_user_facts(row: dict) -> list[dict]: """Extract ground-truth facts from a BEAM row's user_profile. The user_profile has: user_info: free text "Name: Jennifer Mccall / Age: 59 / ..." user_relationships: free text with bullet points We extract structured facts via regex (zero LLM calls). """ import re up = row.get("user_profile", {}) info = up.get("user_info", "") or "" rels = up.get("user_relationships", "") or "" facts: list[dict] = [] conv_id = row.get("conversation_id", "unknown") user_id = f"beam_{conv_id}" # name m = re.search(r"Name:\s*([^\n]+)", info) if m: facts.append({"subject": user_id, "relation": "name", "value": m.group(1).strip()}) # age m = re.search(r"Age:\s*(\d+)", info) if m: facts.append({"subject": user_id, "relation": "age", "value": m.group(1).strip()}) # gender m = re.search(r"Gender:\s*([^\n]+)", info) if m: facts.append({"subject": user_id, "relation": "gender", "value": m.group(1).strip()}) # location m = re.search(r"Location:\s*([^\n]+)", info) if m: facts.append({"subject": user_id, "relation": "location", "value": m.group(1).strip()}) # profession m = re.search(r"Profession:\s*([^\n]+)", info) if m: facts.append({"subject": user_id, "relation": "profession", "value": m.group(1).strip()}) # relationships — extract names + relations # relationship sections start with all-caps headers like # "PARENTS & GUARDIANS:" "ROMANTIC PARTNER:" "CHILDREN:" etc. cur_section = None for line in rels.split("\n"): line = line.strip() if not line: continue # detect header — line is all caps + colon if line.endswith(":") and line[:-1] == line[:-1].upper(): cur_section = line[:-1] continue # bullet line — extract name + age m = re.match(r"•\s*([^\(]+)\s*\(.*?\)", line) if m and cur_section: name = m.group(1).strip().rstrip(",") # convert section to relation rel_map = { "PARENTS & GUARDIANS": "parent", "ROMANTIC PARTNER": "partner", "CHILDREN": "child", "SIBLINGS": "sibling", "FRIENDS": "friend", "COLLEAGUES": "colleague", } relation = rel_map.get(cur_section, cur_section.lower()) facts.append({"subject": user_id, "relation": relation, "value": name}) return facts def parse_chat_turns(row: dict) -> list[dict]: """Flatten a row's chat history into a list of user turns. Each turn has: content, id, index, question_type, role, time_anchor. We only keep role='user' turns (these are the messages the user actually said — what Context-M needs to extract facts from). """ chat = row.get("chat", []) if not isinstance(chat, list): return [] turns_out: list[dict] = [] for plan_obj in chat: if not isinstance(plan_obj, dict): continue # plan_obj has one key like "plan-1" for plan_name, batches in plan_obj.items(): if not isinstance(batches, list): continue for batch in batches: if not isinstance(batch, dict): continue turns = batch.get("turns", []) if not isinstance(turns, list): continue for turn_list in turns: if not isinstance(turn_list, list): continue for turn in turn_list: if not isinstance(turn, dict): continue if turn.get("role") == "user": turns_out.append({ "content": turn.get("content", ""), "time_anchor": turn.get("time_anchor"), "question_type": turn.get("question_type"), "plan": plan_name, "batch": batch.get("batch_number"), }) return turns_out def beam_rows_to_personas(rows: list[dict], *, max_turns_per_persona: int = 100, include_profile: bool = True) -> list[dict]: """Convert BEAM rows into the persona dict format the bench expects. Returns list of: {user_id, text, facts} where: user_id — derived from conversation_id text — concatenation of user_profile (if include_profile=True) + the first N user turns. The user_profile contains the ground-truth facts (Name, Age, Location, etc.) which the μ=0 extractor should pull from the explicit "Name: X" lines; the chat turns are the conversational context. facts — ground-truth structured facts parsed from user_profile (separate from text so the bench can check recall) """ personas = [] for row in rows: conv_id = row.get("conversation_id", "unknown") user_id = f"beam_{conv_id}" facts = parse_user_facts(row) turns = parse_chat_turns(row) # cap turns to keep ingest tractable turns = turns[:max_turns_per_persona] # build the ingest text: profile first (so facts are stated # explicitly), then the conversation turns parts = [] if include_profile: up = row.get("user_profile", {}) info = up.get("user_info", "") rels = up.get("user_relationships", "") if info: parts.append(info) if rels: parts.append(rels) # append the chat turns for t in turns: if t.get("content"): parts.append(t["content"]) text = "\n".join(parts) personas.append({ "user_id": user_id, "text": text, "facts": facts, "n_turns": len(turns), "conversation_id": conv_id, }) return personas __all__ = [ "load_beam_rows", "parse_user_facts", "parse_chat_turns", "beam_rows_to_personas", "DATASET_NAME", ]