File size: 4,733 Bytes
27038ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
"""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}"


@dataclass
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())