"""Out-of-distribution (OOD) benchmark — breaking the circularity. The in-distribution (ID) benchmark generates conversations from the SAME template families the μ=0 extractor's patterns were authored against, so ID scores are an upper bound for template-shaped text, not a capability claim. This module measures the honest generalization gap: 1. EXTRACTION RECALL per style — ground-truth facts from persona registries are re-rendered by an independent LLM in styles the pattern author never saw (paraphrase / negation / indirect / informal / non_english / code_switch). The μ=0 extractor runs on the renderings; recall is matched against the ground-truth match keys. 2. END-TO-END RETRIEVAL — the OOD corpus (renderings + the same distractor machinery) flows through the full memory fabric and is probed by the SAME probe builder + judges as the ID benchmark, so ID-vs-OOD deltas are apples-to-apples. 3. LLM-JUDGE CROSS-CHECK — probe/context pairs are exported in the canonical BEAM judge format for independent LLM grading. Renderer omissions (facts the LLM failed to convey) are tracked separately from extraction failures so neither layer can silently blame the other. """ from __future__ import annotations import random from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from cortexm.bench.abilities import ABILITIES, build_probes, judge from cortexm.bench.generator import (Corpus, distractor_paragraph, smalltalk_message) from cortexm.util import month_name, normalize, token_estimate T0 = datetime(2026, 3, 1, tzinfo=timezone.utc) # Match-tier vocabulary: how hard each fact is to extract from re-phrased text. CATEGORY_TIERS = { "core": "explicit entity facts (name, employer, city, family, prefs)", "relational": "multi-hop chains (manager->team->tech)", "temporal": "dated events and interval changes", } def _d(y, m, day=1): return f"{y:04d}-{m:02d}-{day:02d}" def _mn(iso_date: str) -> str: return month_name(int(iso_date[5:7])) # ---------------------------------------------------------------- manifest def export_manifest(personas: list, t0: datetime = T0) -> dict: """Persona ground truth as LLM-renderable fact manifests. Mirrors the generator's session structure (parts 0-7) so the SAME probes remain answerable; facts are stated as semantic text, never as the generator's template phrasings. """ out = [] for p in personas: e0, e1 = p.employers[0], p.employers[1] e_last = p.employers[-1] c0, c1 = p.cities sister_full = p.family[0][0] sessions: list[dict] = [] def S(part: int, facts: list[dict]) -> None: date = t0 + timedelta(days=part * 21) sessions.append({"session": part, "date": date.date().isoformat(), "facts": [ {"id": f"s{part}f{i}", "text": t, "type": ty, "match": m} for i, (ty, t, m) in enumerate(facts)]}) # ---- part 0: introduction f0 = [ ("name", f"The user's full name is {p.full_name}.", [p.full_name, p.first]), ("employment", f"The user works at {e0[0]} as a {p.roles[0][0]}.", [e0[0]]), ("city", f"The user lives in {c0[0]}.", [c0[0]]), ("birthday", f"The user's birthday is {month_name(p.birthday[0])} " f"{p.birthday[1]}.", [f"{month_name(p.birthday[0]).lower()} {p.birthday[1]}"]), ("family", f"The user has a sister named {sister_full}.", [sister_full.split()[0]]), ] if p.nickname: f0.append(("alias", f"The user goes by the nickname " f"\"{p.nickname}\".", [p.nickname])) _instr_keys = (["french"] if "french" in p.instruction[1].lower() else ["short", "concise", "brief"]) f0.append(("instruction", f"The user gave the assistant a standing instruction: " f"\"{p.instruction[0]}\"", _instr_keys)) f0.append(("employment_since", f"The user has been at {e0[0]} since {_mn(e0[1])} {e0[1][:4]}.", [e0[0]])) S(0, f0) # ---- part 1: preferences + skills f1 = [] for i in range(0, len(p.prefs), 2): cat, v_old = p.prefs[i][0], p.prefs[i][1] v_new = p.prefs[i + 1][1] if i + 1 < len(p.prefs) else v_old f1.append(("preference", f"For {cat}, the user used to like {v_old} but now " f"prefers {v_new}.", [v_old, v_new])) for s in p.skills: f1.append(("skill", f"The user knows {s}.", [s])) f1.append(("hobby", f"In their free time the user enjoys " f"{p.hobbies[0]}.", [p.hobbies[0]])) if len(p.hobbies) > 1: f1.append(("hobby", f"The user also enjoys {p.hobbies[1]}.", [p.hobbies[1]])) S(1, f1) # ---- part 2: job change f2 = [] m_end = int(e0[2][5:7]) if e0[2] else 6 f2.append(("left_job", f"The user left {e0[0]} in {_mn(_d(2024, m_end))} " f"{e0[2][:4] if e0[2] else ''}.".strip(), [e0[0]])) m_new = int(e1[1][5:7]) f2.append(("joined_job", f"The user joined {e1[0]} in {_mn(e1[1])} {e1[1][:4]} " f"as a {p.roles[0][0]}.", [e1[0]])) if len(p.employers) > 2: mid = p.employers[1] m_mid = int(mid[2][5:7]) if mid[2] else 6 f2.append(("left_job", f"The user later left {mid[0]} in {_mn(_d(2024, m_mid))} " f"{mid[2][:4] if mid[2] else ''}.".strip(), [mid[0]])) f2.append(("employment", f"These days the user works at {e_last[0]}.", [e_last[0]])) S(2, f2) # ---- part 3: relocation + family m_move = int(c1[1][5:7]) f3 = [ ("moved", f"The user moved to {c1[0]} in {_mn(c1[1])} {c1[1][:4]}, " f"previously living in {c0[0]}.", [c1[0], c0[0]]), ] S(3, f3) # ---- part 4: work structure (multi-hop) mgr, team = p.manager tname, tech = p.team_tech S(4, [ ("manager", f"The user's manager is {mgr}.", [mgr]), ("manager_team", f"{mgr} manages the {tname} team.", [tname]), ("team_tech", f"The {tname} team uses {tech}.", [tech]), ("on_team", f"The user is on the {tname} team.", [tname]), ]) # ---- part 5: projects f5 = [] for name, start, end in p.projects: if end: f5.append(("project", f"The user worked on {name}, finished in " f"{_mn(end)} {end[:4]}.", [name])) else: f5.append(("project", f"The user is currently working on {name}.", [name])) S(5, f5) # ---- parts 6/7: dated events + preference flip f6 = [] for date, desc in p.events[:2]: f6.append(("event", f"On {month_name(int(date[5:7]))} {int(date[8:10])}, " f"{date[:4]}, the user {desc}.", [desc])) S(6, f6) f7 = [] for date, desc in p.events[2:]: f7.append(("event", f"On {month_name(int(date[5:7]))} {int(date[8:10])}, " f"{date[:4]}, the user {desc}.", [desc])) cat = p.prefs[2][0] if len(p.prefs) > 2 else "coffee" vals = [v for (c, v, s, e) in p.prefs if c == cat] if len(vals) >= 2: f7.append(("preference", f"The user has since switched to {vals[-1]} for {cat}.", [vals[-1]])) S(7, f7) out.append({"user_id": p.user_id, "full_name": p.full_name, "sessions": sessions}) return {"personas": out} # ------------------------------------------------------------ corpus build def build_ood_corpus(rendered: dict, persona, t0: datetime = T0, target_tokens: int = 120_000, seed: int = 7) -> Corpus: """Assemble an evaluation Corpus from one rendered persona-style row.""" rng = random.Random(seed) sessions = [] total = 0 for s in sorted(rendered.get("sessions", []), key=lambda x: x.get("session", 0)): date = t0 + timedelta(days=int(s.get("session", 0)) * 21 + rng.randrange(0, 5)) msgs = [("user", str(t)) for t in s.get("messages", []) if str(t).strip()] for _, txt in msgs: total += token_estimate(txt) sessions.append((persona.user_id, date, msgs)) # distractor volume — same machinery as the ID generator guard = 0 while total < target_tokens and guard < 200_000: guard += 1 uid, date, msgs = sessions[rng.randrange(len(sessions))] if rng.random() < 0.45: txt = distractor_paragraph(rng) else: txt = smalltalk_message(rng) + " " + smalltalk_message(rng) k = rng.randrange(0, max(1, len(msgs))) msgs.insert(k, ("user", txt)) total += token_estimate(txt) return Corpus(bucket="ood", target_tokens=target_tokens, sessions=sessions, personas=[persona], total_tokens=total, generation_seconds=0.0) # ------------------------------------------------- extraction-layer recall def extraction_recall(rendered: dict, persona, config) -> dict: """Run the μ=0 extractor over one rendered persona-style row. Returns per-fact match results against the manifest match keys — the direct, honest measure of pattern generalization. """ from cortexm.bridge.extractor import Extractor from cortexm.bridge.patterns import ExtractionContext manifest = export_manifest([persona], T0)["personas"][0] manifest_facts = {f["id"]: f for s in manifest["sessions"] for f in s["facts"]} extractor = Extractor(config) candidates = [] name = None for s in sorted(rendered.get("sessions", []), key=lambda x: x.get("session", 0)): ts = T0 + timedelta(days=int(s.get("session", 0)) * 21) for text in s.get("messages", []): ctx = ExtractionContext(user_id=persona.user_id, ts=ts, speaker="user", subject_name=name, lexicon=set()) try: cands = extractor.extract(str(text), ctx) except Exception: cands = [] candidates.extend(cands) for c in cands: if c.relation == "name" and name is None: name = c.value blob = " \n ".join( f"{normalize(c.subject)} | {normalize(c.relation)} | {normalize(c.value)}" for c in candidates if c.pattern != "mention_fallback") matches = {} for fid, f in manifest_facts.items(): hit = any(normalize(k) and normalize(k) in blob for k in f["match"]) matches[fid] = {"hit": hit, "type": f["type"]} conveyed = set(rendered.get("conveyed", [])) total = hit = 0 per_type: dict[str, list[int]] = {} for fid, r in matches.items(): if fid not in conveyed: continue # renderer omitted it — not an extraction failure total += 1 hit += int(r["hit"]) per_type.setdefault(r["type"], [0, 0]) per_type[r["type"]][1] += 1 per_type[r["type"]][0] += int(r["hit"]) return { "user_id": persona.user_id, "recall": round(hit / total, 4) if total else None, "n_ground_truth": total, "n_renderer_omitted": len(manifest_facts) - len(conveyed & set(manifest_facts)), "n_candidates": len([c for c in candidates if c.pattern != "mention_fallback"]), "per_type": {k: round(v[0] / v[1], 4) for k, v in per_type.items() if v[1]}, "missed": [fid for fid, r in matches.items() if not r["hit"] and fid in conveyed], "definition": "entity-level recall: share of ground-truth facts " "whose key appears in any non-fallback candidate; " "temporal precision is measured end-to-end by the " "TR/EO probes, not here", } # ------------------------------------------------------- end-to-end eval @dataclass class OODResult: style: str overall: float = 0.0 per_ability: dict = field(default_factory=dict) n_questions: int = 0 ingest: dict = field(default_factory=dict) extraction: dict = field(default_factory=dict) details: list = field(default_factory=list) def to_dict(self) -> dict: return {k: v for k, v in self.__dict__.items()} def run_ood_eval(corpus: Corpus, personas: list, style: str, db_path: str = ":memory:", max_probes: int | None = None, judge_fn=None, enrich_fn=None) -> OODResult: """Ingest an OOD corpus and evaluate with the standard probe/judge pair. With ``enrich_fn(memory) -> report`` the probe set is evaluated twice — before and after async LLM enrichment — quantifying the graceful- degradation fallback's recovery on OOD text. """ import time from cortexm import metrics from cortexm.api.memory import Memory from cortexm.config import Config t0 = time.time() res = OODResult(style=style) cfg = Config(db_path=db_path) if db_path != ":memory:" else Config() cfg.apply_rules_each_add = False memory = Memory(cfg) metrics.reset_counters() t_ing = time.time() n_msgs = 0 for user_id, date, msgs in corpus.sessions: payload = [{"role": role, "content": text, "timestamp": date} for role, text in msgs] n_msgs += len(msgs) memory.add(payload, user_id=user_id, timestamp=date) memory.apply_rules() ingest_s = time.time() - t_ing stats = memory.stats() res.ingest = { "wall_seconds": round(ingest_s, 2), "messages": n_msgs, "tokens": corpus.total_tokens, "tokens_per_second": int(corpus.total_tokens / max(ingest_s, 1e-9)), "llm_calls": metrics.counters()["llm_calls"], "u0_protocol": stats["u0_protocol"], "facts": stats["facts"], } def _eval() -> tuple[float, dict, list]: rng = random.Random(hash((style, "probes")) & 0xFFFFFFFF) probes = build_probes(personas, rng) by_ability: dict[str, list] = {a: [] for a in ABILITIES} for p in probes: by_ability[p.ability].append(p) if max_probes: for a in ABILITIES: by_ability[a] = by_ability[a][:max_probes] n_q = sum(len(v) for v in by_ability.values()) score_fn = judge_fn or judge per_ability = {a: 0.0 for a in ABILITIES} counts = {a: len(by_ability[a]) for a in ABILITIES} details = [] for ability in ABILITIES: for probe in by_ability[ability]: out = memory.search(probe.question, user_id=probe.user_id, k=12) score, detail = score_fn(probe, out["context_block"]) per_ability[ability] += score details.append({"ability": ability, "question": probe.question, "score": score, "detail": detail, "context": out["context_block"][:600]}) overall = round(sum(per_ability.values()) / max(n_q, 1), 4) per_ability = {a: round(per_ability[a] / max(counts[a], 1), 4) for a in ABILITIES if counts[a]} return overall, per_ability, details res.overall, res.per_ability, res.details = _eval() res.n_questions = len(res.details) res.extraction = {"facts": stats["facts"]} if enrich_fn is not None: rep = enrich_fn(memory) res.ingest["enrichment"] = rep post_overall, post_per_ability, _post = _eval() res.enriched = {"overall": post_overall, "per_ability": post_per_ability, "report": rep} # type: ignore[attr-defined] memory.close() res.ingest["wall_seconds_total"] = round(time.time() - t0, 2) return res # ---------------------------------------------------- LLM-judge item export def describe_expected(probe) -> list[str]: """Human-readable nugget description for the canonical LLM judge.""" exp = probe.expected if probe.ability == "AB": return [f"NOTHING — '{probe.question}' concerns an attribute never " f"mentioned; correct behaviour is abstention."] if "events" in exp: return [f"Event '{d}' occurred on {iso}." for d, iso in exp["events"]] if "set" in exp: return ["The complete set of projects: " + ", ".join(exp["set"]) + "."] if "current" in exp and "old" in exp: return [f"Current employer: {exp['current']}.", f"Previous employer (superseded): {exp['old']}."] out = [] for k in exp.get("contains_any", []): out.append(f"The answer must mention: {k}.") for k in exp.get("also_any", []): out.append(f"Supporting evidence should also mention: {k}.") return out or ["(no nugget description)"] def export_judge_items(result: OODResult, personas: list, per_ability_limit: int = 4) -> list[dict]: """Probe/context pairs in the canonical judge JSONL format. The deterministic judge's score is attached (det_score) so agreement between the two graders can be computed after the LLM pass. """ items = [] per_ability: dict[str, int] = {} probe_by_q = {pr.question: pr for pr in build_probes(personas, random.Random(1234))} for i, d in enumerate(result.details): if per_ability.get(d["ability"], 0) >= per_ability_limit: continue per_ability[d["ability"]] = per_ability.get(d["ability"], 0) + 1 probe = probe_by_q.get(d["question"]) items.append({ "id": f"{result.style}-{i}", "ability": d["ability"], "question": d["question"], "expected": describe_expected(probe) if probe else ["(probe metadata unavailable)"], "context": d["context"], "det_score": d["score"], }) return items