v0.3: structural query encoding (patterns + slots) for generalization. Expert.from_qa_pairs now accepts patterns=[...]; questions matching a template are encoded as bind(pattern_hv, slot_hv). On a capitals holdout benchmark, char-level returns empty 100% of the time on unseen slots; structural returns a well-formed answer 100% of the time (+100 pts graceful degradation). Honest: format-generalized guesses by analogy, not factual correctness (no embeddings). 91 tests passing.
27038ac verified | """Benchmark: generalization to unseen slots — char-level vs structural. | |
| Measures the ONE thing the structural encoder is for: does the expert answer | |
| *something well-formed* for a question whose slot was NEVER in training? | |
| Train on capitals of {france, germany, italy, japan, egypt}. | |
| Hold out: {spain, portugal, greece, brazil, norway, india, mexico, kenya}. | |
| Metric: % of holdout questions returning a non-empty answer (the format- | |
| generalization guarantee), char-level vs structural. | |
| Honest framing: this does NOT measure factual correctness (without semantic | |
| embeddings, the system can't know spain's capital is madrid). It measures | |
| whether the system degrades gracefully (well-formed guess) vs silently | |
| (emptiness) on the unknown — a real, meaningful axis of generalization. | |
| Usage: | |
| python bench/run_generalization.py --D 5000 --out results_gen.json | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import sys | |
| from dataclasses import asdict, dataclass | |
| from pathlib import Path | |
| import numpy as np | |
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | |
| from ensemble import Expert, Brain | |
| TRAIN = [ | |
| ("what is the capital of france", "the capital of france is paris"), | |
| ("what is the capital of germany", "the capital of germany is berlin"), | |
| ("what is the capital of italy", "the capital of italy is rome"), | |
| ("what is the capital of japan", "the capital of japan is tokyo"), | |
| ("what is the capital of egypt", "the capital of egypt is cairo"), | |
| ] | |
| # Ground-truth answers (for the factual-correctness metric, informational) | |
| HOLDOUT = [ | |
| ("what is the capital of spain", "madrid"), | |
| ("what is the capital of portugal", "lisbon"), | |
| ("what is the capital of greece", "athens"), | |
| ("what is the capital of brazil", "brasilia"), | |
| ("what is the capital of norway", "oslo"), | |
| ("what is the capital of india", "new delhi"), | |
| ("what is the capital of mexico", "mexico city"), | |
| ("what is the capital of kenya", "nairobi"), | |
| ] | |
| PATTERN = "what is the capital of {country}" | |
| class GenResult: | |
| mode: str # "char" | "structural" | |
| D: int | |
| n_train: int | |
| n_holdout: int | |
| nonempty_rate: float # % holdout with non-empty answer (format guarantee) | |
| avg_len: float # avg answer length (proxy for format richness) | |
| answers: list[tuple[str, str, str]] # (question, truth, got) | |
| def run_mode(mode: str, D: int, repeat: int) -> GenResult: | |
| pairs = TRAIN * repeat | |
| if mode == "structural": | |
| e = Expert.from_qa_pairs(pairs, domain="geo", D=D, patterns=[PATTERN]) | |
| else: | |
| e = Expert.from_qa_pairs(pairs, domain="geo", D=D) | |
| brain = Brain() | |
| brain.add_expert(e) | |
| nonempty = 0 | |
| total_len = 0 | |
| answers = [] | |
| for q, truth in HOLDOUT: | |
| got = brain.query(q, max_new_tokens=25).answer.strip() | |
| if got: | |
| nonempty += 1 | |
| total_len += len(got) | |
| answers.append((q, truth, got)) | |
| n = len(HOLDOUT) | |
| return GenResult( | |
| mode=mode, D=D, n_train=len(pairs), n_holdout=n, | |
| nonempty_rate=nonempty / n, | |
| avg_len=total_len / max(nonempty, 1), | |
| answers=answers, | |
| ) | |
| def main() -> int: | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--D", type=int, default=5000) | |
| ap.add_argument("--repeat", type=int, default=4, help="training repeats") | |
| ap.add_argument("--out", default="results_gen.json") | |
| args = ap.parse_args() | |
| results = [] | |
| for mode in ("char", "structural"): | |
| print(f"\n=== {mode.upper()} (D={args.D}) ===", flush=True) | |
| r = run_mode(mode, args.D, args.repeat) | |
| results.append(r) | |
| print(f" non-empty answers: {r.nonempty_rate:.1%} " | |
| f"({int(r.nonempty_rate*r.n_holdout)}/{r.n_holdout})") | |
| print(f" avg answer length: {r.avg_len:.1f} chars") | |
| for q, truth, got in r.answers: | |
| tag = "OK " if got else "EMPTY" | |
| print(f" [{tag}] {q.split()[-1]:10} truth={truth:12} got={got!r}") | |
| with open(args.out, "w", encoding="utf-8") as f: | |
| json.dump([asdict(r) for r in results], f, indent=2, ensure_ascii=False) | |
| # summary | |
| char = next(r for r in results if r.mode == "char") | |
| struct = next(r for r in results if r.mode == "structural") | |
| print(f"\n{'='*60}") | |
| print(f"GENERALIZATION (format guarantee on unseen slots)") | |
| print(f" char-level non-empty: {char.nonempty_rate:.1%}") | |
| print(f" structural non-empty: {struct.nonempty_rate:.1%}") | |
| delta = struct.nonempty_rate - char.nonempty_rate | |
| print(f" structural lift: +{delta*100:.0f} points") | |
| print(f"{'='*60}") | |
| print(f"\nwrote {args.out}", flush=True) | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |