File size: 6,417 Bytes
cbc33fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
"""
Qualitative diff across arms: actually READ the stories.

Aggregate metrics can say "effective rank 2.0/16" without conveying that six of
the sixteen ships are literally named *Aethel*. This module extracts the
concrete signature of collapse -- shared openings, repeated proper nouns,
shared closing cadence, tone/genre spread -- so a human can see what changed.

Works on either a scored pool (`--pool`) or an eval dump (`--eval`), so the
base model, every checkpoint and every arm are read the same way.
"""
from __future__ import annotations

import argparse
import json
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path

import numpy as np

ROOT = Path(__file__).resolve().parent.parent

OPEN_FRAME = re.compile(r"^The\s+\*?([A-Z][\w' ]{2,20})\*?\s+(\w+ed|\w+s)\s+"
                        r"(through|over|above|at|into|across)\b")
ITALIC_NAME = re.compile(r"\*([A-Z][\w' ]{2,24})\*")
CADENCE = [
    (r"for the first time in (centuries|years|generations|decades|a long time)",
     "'for the first time in X'"),
    (r"\b(had )?begun to\b", "'begun to'"),
    (r"\bwasn'?t\s+\w+[^.]{0,50},?\s+it\s+was\b", "'it wasn't X, it was Y'"),
    (r"\n\n[^\n]{1,60}\.\s*$", "one-line closing paragraph"),
]
# crude genre/register probes -- presence of ANY is weak, but the SPREAD across
# a 16-sample set is the interesting number
REGISTER = {
    "elegiac/literary": r"\b(silence|ache|hollow|dust|memory|grief|quiet)\b",
    "comic": r"\b(ridiculous|absurd|joke|laughed|idiot|bureaucra|paperwork)\b",
    "horror": r"\b(scream|blood|rot|teeth|corpse|terror|wrong)\b",
    "technical/SF": r"\b(protocol|sensor|reactor|alloy|deploy|calibrat|drone)\b",
    "dialogue-driven": r'"[^"]{10,}"',
    "second person": r"\bYou (are|were|walk|feel|stand|know)\b",
    "epistolary": r"\b(Dear |entry |log:|Report |memo)\b",
}


def first_sentence(t: str, n: int = 130) -> str:
    return re.split(r"(?<=[.!?])\s", t.strip())[0][:n]


def last_sentence(t: str, n: int = 120) -> str:
    return re.split(r"(?<=[.!?])\s", t.strip())[-1][:n]


def analyze_group(texts: list[str]) -> dict:
    n = len(texts)
    names = Counter(x for t in texts for x in ITALIC_NAME.findall(t))
    opens_the = sum(1 for t in texts if t.strip().startswith("The "))
    frame = sum(1 for t in texts if OPEN_FRAME.match(t.strip()))
    # repeated content words across stories (excluding prompt-driven ones is
    # impossible in general, so we report the top shared nouns as-is)
    stop = set("the a an and or but of to in on it its was were is are that this "
               "with for as at by from had have has been be he she they we you i "
               "his her their our my not no so then than there here what when "
               "which who all more most into over under out up down".split())
    per_story_vocab = [set(w for w in re.sub(r"[^a-z\s]", " ", t.lower()).split()
                           if w not in stop and len(w) > 3) for t in texts]
    shared = Counter(w for v in per_story_vocab for w in v)
    ubiquitous = {w: c for w, c in shared.most_common(400) if c >= max(3, int(0.6 * n))}
    cad = {}
    for pat, label in CADENCE:
        cad[label] = sum(1 for t in texts if re.search(pat, t, re.I | re.M))
    reg = {}
    for label, pat in REGISTER.items():
        reg[label] = sum(1 for t in texts if re.search(pat, t, re.I))
    return {
        "n": n,
        "opens_with_The": opens_the,
        "opening_frame_match": frame,
        "distinct_first_5_words": len({" ".join(t.strip().split()[:5]).lower() for t in texts}),
        "top_names": dict(names.most_common(6)),
        "max_name_reuse": max(names.values()) if names else 0,
        "ubiquitous_words": dict(list(ubiquitous.items())[:12]),
        "cadence": cad,
        "register_spread": reg,
        "registers_present": sum(1 for v in reg.values() if v >= max(2, int(0.15 * n))),
    }


def load_pool(tag: str, split: str, prompt_id: str | None):
    rows = [json.loads(l) for l in
            open(ROOT / "outputs" / f"pool_{tag}" / f"pool_{split}.jsonl") if l.strip()]
    by = defaultdict(list)
    for r in rows:
        by[r["prompt_id"]].append(r)
    if prompt_id:
        return {prompt_id: by[prompt_id]}
    return dict(by)


def load_eval(label: str, out: str):
    p = ROOT / out / "samples" / f"{label}_full.json"
    data = json.load(open(p))
    return {d["prompt_id"]: [{"text": t, "prompt": d["prompt"]} for t in d["texts"]]
            for d in data}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--pool", help="pool tag, e.g. 4b")
    ap.add_argument("--eval", help="eval label, e.g. E0-baseline")
    ap.add_argument("--out", default="outputs/eval")
    ap.add_argument("--split", default="train")
    ap.add_argument("--prompt-id", default=None)
    ap.add_argument("--show", type=int, default=16, help="first sentences to print")
    ap.add_argument("--limit-prompts", type=int, default=40)
    args = ap.parse_args()

    groups = load_pool(args.pool, args.split, args.prompt_id) if args.pool \
        else load_eval(args.eval, args.out)
    keys = list(groups)[: args.limit_prompts]

    agg = defaultdict(list)
    for pid in keys:
        texts = [r["text"] for r in groups[pid]]
        if len(texts) < 4:
            continue
        a = analyze_group(texts)
        agg["opens_with_The"].append(a["opens_with_The"] / a["n"])
        agg["opening_frame"].append(a["opening_frame_match"] / a["n"])
        agg["distinct_openers"].append(a["distinct_first_5_words"] / a["n"])
        agg["max_name_reuse"].append(a["max_name_reuse"])
        agg["registers_present"].append(a["registers_present"])
        for k, v in a["cadence"].items():
            agg[f"cadence:{k}"].append(v / a["n"])

    name = args.eval or f"pool-{args.pool}"
    print(f"\n===== QUALITATIVE PROFILE: {name}  ({len(keys)} prompts) =====")
    for k, v in agg.items():
        print(f"  {k:38} {np.mean(v):.3f}")

    pid = args.prompt_id or keys[0]
    texts = [r["text"] for r in groups[pid]][: args.show]
    print(f"\n--- prompt {pid} ---")
    print("   ", groups[pid][0].get("prompt", "")[:220])
    print("\nOPENINGS:")
    for i, t in enumerate(texts):
        print(f"  {i+1:2d}. {first_sentence(t)}")
    print("\nCLOSINGS:")
    for i, t in enumerate(texts):
        print(f"  {i+1:2d}. ...{last_sentence(t)}")
    return 0


if __name__ == "__main__":
    sys.exit(main())