File size: 15,124 Bytes
7d3eb8f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
"""Production pipeline pieces for the bug-fixed base comparison (runs/CSEL/FIXED_BASE_PREREG.md, job 170).

Per arm (SPLIT = runs/CANVAS_SPLIT, FIXED = runs/CANVAS_FIXBASE) and seed block b (seeds 64b .. 64b+63), in D:
  merge D NT SEED0 [NS]  D/cands/c{s}.jsonl, s = SEED0..SEED0+NS-1 (NS = 64) -> D/cands.jsonl (flag and coverage asserts)
  picks D              D/typhoon_top8.jsonl rank 0 -> D/pick.jsonl (id, prompt, poem)
  locmap D             D/pick_scores.jsonl -> D/line.json (argmin copy-masked canvas line mean; vetomore.locmap)
  variants D           D/pick.jsonl + form-passing D/ref/r*_*.jsonl -> D/rcands.jsonl (105's >= 8 per pick floor)
  accept D             vetomore.accept verbatim (+0.5 margin, topic-keep) -> D/final.jsonl
Across arms:
  gates ROOT B         pre-registered drift gates on block B, FIXED vs SPLIT; writes ROOT/jp_{split,fixed}_b{B}.jsonl
                       (judge inputs, id = topic_s{B}) and ROOT/GATES_b{B}.json; exit 2 = killed before judging
  score ROOT NB        after judging: paired form / clean / aligned / all-three, topic-clustered bootstrap, decision
  test                 CPU unit tests of the pure functions
"""
import os, re, sys, json, glob, random, collections
sys.path.insert(0, "."); sys.path.insert(0, "scripts")

DARGS_FLAGS = {"topic_zero": "row", "boost_mode": "flat"}


def rows(p): return [json.loads(l) for l in open(p, encoding="utf-8") if l.strip()]


def wjsonl(p, rs):
    with open(p + ".tmp", "w", encoding="utf-8") as f:
        for r in rs: f.write(json.dumps(r, ensure_ascii=False) + "\n")
    os.replace(p + ".tmp", p)


def merge(d, nt, s0, ns=64):
    nt, s0, ns = int(nt), int(s0), int(ns)
    rs = []
    for s in range(s0, s0 + ns):
        f = f"{d}/cands/c{s}.jsonl"; x = rows(f)
        assert len(x) == nt, f"{f}: {len(x)} rows, want {nt}"
        rs += [dict(r, seed=s) for r in x]
    bad = [r["id"] for r in rs if any(r.get(k) != v for k, v in DARGS_FLAGS.items()) or r.get("topic_boost") not in (1.5, 3.0)]
    assert not bad, f"{len(bad)} rows decoded with the wrong flags"
    tb = collections.defaultdict(set)
    for r in rs: tb[r["id"]].add(r["topic_boost"])
    assert len(tb) == nt and all(len(v) == 1 for v in tb.values()), "topic coverage / topic_boost differs across seeds"
    wjsonl(f"{d}/cands.jsonl", rs)
    print(f"  {d}: {len(rs)} candidates for {nt} topics, seeds {s0}-{s0 + ns - 1}; form {100 * sum(r['all_pass'] for r in rs) / len(rs):.1f}%")


def picks(d):
    top = [r for r in rows(f"{d}/typhoon_top8.jsonl") if r["sel_rank"] == 0]
    assert len({r["topic_id"] for r in top}) == len(top), "two rank-0 picks for one topic"
    wjsonl(f"{d}/pick.jsonl", [{"id": r["topic_id"], "prompt": r["prompt"], "poem": r["poem"]} for r in sorted(top, key=lambda r: r["topic_id"])])
    print(f"  {d}: {len(top)} Typhoon picks")


def locmap(d):
    P_, S = rows(f"{d}/pick.jsonl"), rows(f"{d}/pick_scores.jsonl")
    assert [p["id"] for p in P_] == [s["id"] for s in S] and all(p["poem"] == s["poem"] for p, s in zip(P_, S)), "scores do not belong to the picks"
    m = {s["id"]: min(range(4), key=lambda i: s["score"]["line_mean"][i]) + 1 for s in S if s["score"]}
    miss = [s["id"] for s in S if not s["score"]]
    print(f"  {d}: {len(m)} picks located, {len(miss)} without a score (kept unchanged, as round 1): {miss}")
    assert len(miss) <= max(3, len(S) // 20), "too many picks fail to round-trip"
    json.dump(m, open(f"{d}/line.json", "w"), indent=0)


def variants(d):
    base = rows(f"{d}/pick.jsonl"); loc = json.load(open(f"{d}/line.json"))
    out = [dict(r, variant="orig") for r in base]; n = 0
    for f in sorted(glob.glob(f"{d}/ref/r*_*.jsonl")):
        for r in rows(f):
            if r.get("refill_line") and r["all_pass"]: out.append(dict(r, variant=os.path.basename(f))); n += 1
    wjsonl(f"{d}/rcands.jsonl", out)
    print(f"  {d}: {len(base)} picks + {n} form-passing variants")
    assert n >= len(loc) * 8, "fewer than half the expected variants (105's floor)"


def accept(d):
    import vetomore
    best, uns = vetomore.accept(rows(f"{d}/rcands.jsonl"), rows(f"{d}/rcands_scores.jsonl"),
                                {k: int(v) for k, v in json.load(open(f"{d}/line.json")).items()})
    from klon8 import prosody as P
    fin = [{"id": i, "prompt": best[i]["prompt"], "poem": best[i]["poem"], "variant": best[i]["variant"],
            "form": bool(P.score(best[i]["poem"], n_bot_expected=1)["all_pass"])} for i in sorted(best)]
    assert len(fin) == len(rows(f"{d}/pick.jsonl"))
    wjsonl(f"{d}/final.jsonl", fin); P.save_cache()
    print(f"  {d}: {len(fin)} final poems, {sum(r['variant'] != 'orig' for r in fin)} refilled, {len(uns)} unscored, "
          f"form {100 * sum(r['form'] for r in fin) / len(fin):.1f}%")


# ---- drift metrics (same segmenter for all arms: syl_align v1, as every earlier gate) ----
ARMS = ("SPLIT", "CTL12", "FIXED")
_REDUP = None


def redup():
    """Syllables the corpus writes with ไม้ยมก (Xๆ); excluded from the stutter count in EVERY arm, since FIXED
    writes them out (X X) internally."""
    global _REDUP
    if _REDUP is None:
        import fixbase_data
        _REDUP = fixbase_data.redup_syllables()
    return _REDUP


def stutters(p, exclude=frozenset()):
    from syl_align import syls_aligned as SA
    n = 0
    for ln in p.split("\n"):
        s = SA(ln, 12); n += sum(a == b and a not in exclude for a, b in zip(s, s[1:]))
    return n


def xrep(p):
    from syl_align import syls_aligned as SA
    from canvas_mlm import STOP_SYL
    ls = [set(x for x in SA(l, 12) if x not in STOP_SYL and len(x) > 1) for l in p.split("\n")]
    c = collections.Counter(x for s_ in ls for x in s_); return sum(v - 1 for v in c.values() if v > 1)


def distinct2(ps):
    from syl_align import syls_aligned as SA
    bg = [(a, b) for p in ps for ln in p.split("\n") for s in [SA(ln, 12)] for a, b in zip(s, s[1:])]
    return len(set(bg)) / max(len(bg), 1)


def judged_text(poem, _w=[]):
    """The text the judge sees: reduplication written back as ๆ (human anchors use ๆ), the same rule for every
    arm. Form, stutters and repeats are measured on the text the model produced (before this)."""
    import fixbase_data, syl_align_v2
    if not _w:
        from pythainlp.corpus import thai_words
        _w.append(thai_words())
    return "\n".join(fixbase_data.contract_yamok(ln, lambda t: syl_align_v2.syls_aligned(t, 12), _w[0]) for ln in poem.split("\n"))


def halves(ids):
    """Two pools per block (pinned recipe: <= 5 system poems per anchor, 20 anchors): 3 arms x 49 topics do not fit
    one pool, so topics split by sorted id; the three arms of a topic are always in the same pool."""
    ids = sorted(ids); h = (len(ids) + 1) // 2
    return [ids[:h], ids[h:]]


def gates(root, b):
    """Pre-registered (FIXED_BASE_PREREG.md), FIXED vs its comparator CTL12: FIXED form >= 98% and >= CTL12 - 2pp;
    stutters (corpus reduplications excluded) <= CTL12 + 0.10 per poem; cross-line repeats <= CTL12 + 0.50;
    distinct-2 >= CTL12 - 0.05. SPLIT is reported alongside. Then the judge inputs (ๆ written back)."""
    R = {a: {r["id"]: r for r in rows(f"{root}/{a}_b{b}/final.jsonl")} for a in ARMS}
    ids = sorted(R["FIXED"])
    assert all(set(R[a]) == set(ids) and all(R[a][i]["prompt"] == R["FIXED"][i]["prompt"] for i in ids) for a in ARMS), \
        "arms cover different topics"
    ex = redup()
    m = lambda f, a: sum(f(R[a][i]["poem"]) for i in ids) / len(ids)
    g = {"n": len(ids)}
    for k, f in (("form", lambda a: 100 * sum(R[a][i]["form"] for i in ids) / len(ids)),
                 ("stutters", lambda a: m(lambda p: stutters(p, ex), a)), ("stutters_raw", lambda a: m(stutters, a)),
                 ("xrep", lambda a: m(xrep, a)), ("distinct2", lambda a: distinct2([R[a][i]["poem"] for i in ids])),
                 ("refilled", lambda a: sum(R[a][i]["variant"] != "orig" for i in ids))):
        g[k] = {a: f(a) for a in ARMS}
    F_, C_ = "FIXED", "CTL12"
    fail = []
    if g["form"][F_] < 98 or g["form"][F_] < g["form"][C_] - 2: fail.append("form")
    if g["stutters"][F_] > g["stutters"][C_] + 0.10: fail.append("stutters")
    if g["xrep"][F_] > g["xrep"][C_] + 0.50: fail.append("cross-line repeats")
    if g["distinct2"][F_] < g["distinct2"][C_] - 0.05: fail.append("distinct-2")
    g["fail"] = fail
    for k, v in g.items():
        if isinstance(v, dict): print(f"  block {b} {k:13s} " + "  ".join(f"{a} {v[a]:.3f}" for a in ARMS))
    changed = 0
    for h, part in enumerate(halves(ids)):
        for a in ARMS:
            out = []
            for i in part:
                t = judged_text(R[a][i]["poem"]); changed += t != R[a][i]["poem"]
                out.append({"id": f"{i}_s{b}", "prompt": R[a][i]["prompt"], "poem": t})
            wjsonl(f"{root}/jp_{a.lower()}_b{b}h{h}.jsonl", out)
    g["yamok_written_back"] = changed
    json.dump(g, open(f"{root}/GATES_b{b}.json", "w"), indent=1)
    print(f"  block {b}: {changed} judged poems carry reduplication written back as ๆ")
    if fail:
        print(f"  KILLED before judging, block {b}: {fail}"); sys.exit(2)
    print(f"  block {b}: drift gates pass; judge inputs {root}/jp_*_b{b}h{{0,1}}.jsonl")


def paired_stats(a, b, seed=0, n_boot=10000):
    """a, b: {key: bool}, key = (topic, block). Difference b - a in pp, up/down, topic-clustered percentile CI."""
    keys = sorted(set(a) & set(b))
    by = collections.defaultdict(list)
    for k in keys: by[k[0]].append(k)
    T = sorted(by); rng = random.Random(seed); boots = []
    for _ in range(n_boot):
        ks = [k for t in (rng.choice(T) for _ in T) for k in by[t]]
        boots.append(100 * (sum(b[k] for k in ks) - sum(a[k] for k in ks)) / len(ks))
    boots.sort()
    d = 100 * (sum(b[k] for k in keys) - sum(a[k] for k in keys)) / len(keys)
    return {"n": len(keys), "a": 100 * sum(a[k] for k in keys) / len(keys), "b": 100 * sum(b[k] for k in keys) / len(keys),
            "diff": d, "up": sum(b[k] and not a[k] for k in keys), "down": sum(a[k] and not b[k] for k in keys),
            "ci": (boots[int(0.025 * n_boot)], boots[int(0.975 * n_boot) - 1])}


def decide(allthree):
    """Pre-registered: CONTINUE if all-three >= +5pp with the lower 95% bound > 0; GREY if >= +5pp with the CI
    including 0; otherwise STOP. Applied to the PRIMARY contrast FIXED vs CTL12 on all blocks."""
    if allthree["diff"] >= 5 and allthree["ci"][0] > 0: return "CONTINUE"
    if allthree["diff"] >= 5: return "GREY"
    return "STOP"


def score(root, nb):
    lab = {"clean": {}, "aligned": {}, "form": {}}
    for b in range(int(nb)):
        for a in ARMS:
            for r in rows(f"{root}/{a}_b{b}/final.jsonl"): lab["form"][(a.lower(), r["id"], b)] = r["form"]
        for h in (0, 1):
            for kind, pre in (("clean", "FBJ_CLEAN"), ("aligned", "FBJ_ALIGN")):
                d = f"runs/{pre}{b}{h}"
                km = json.load(open(f"{d}/keymap.json", encoding="utf-8")); res = {r["item"]: r for r in json.load(open(f"{d}/run1.json", encoding="utf-8"))}
                assert set(res) == set(km), f"{d}: judge output does not cover the pool"
                for k, v in km.items():
                    if v["src"] == "human": continue
                    r = res[k]
                    val = (not any(e.get("severity") in ("critical", "major") for e in r["errors"])) if kind == "clean" else int(r["align"]) >= 4
                    t, bb = v["id"].rsplit("_s", 1); assert int(bb) == b
                    assert (v["src"], t, b) not in lab[kind], f"{v} judged twice"
                    lab[kind][(v["src"], t, b)] = val
    for kind in ("clean", "aligned"):
        assert set(lab[kind]) == set(lab["form"]), f"{kind}: judged poems differ from the pipeline output"
    lab["all-three"] = {k: lab["form"][k] and lab["clean"][k] and lab["aligned"][k] for k in lab["form"]}
    out = {}
    for name, (A, B) in (("PRIMARY fixes (FIXED vs CTL12)", ("ctl12", "fixed")), ("schedule (CTL12 vs SPLIT)", ("split", "ctl12")),
                         ("whole (FIXED vs SPLIT)", ("split", "fixed"))):
        out[name] = {}
        print(f"  {name}")
        for kind, L in lab.items():
            a = {k[1:]: v for k, v in L.items() if k[0] == A}; bb = {k[1:]: v for k, v in L.items() if k[0] == B}
            st = out[name][kind] = paired_stats(a, bb)
            print(f"    {kind:9s} {A} {st['a']:5.1f}%  {B} {st['b']:5.1f}%  diff {st['diff']:+.1f}pp  ({st['up']} up / {st['down']} down, "
                  f"n={st['n']})  95% CI [{st['ci'][0]:+.1f}, {st['ci'][1]:+.1f}]")
    out["decision"] = decide(out["PRIMARY fixes (FIXED vs CTL12)"]["all-three"])
    json.dump(out, open(f"{root}/RESULT.json", "w"), indent=1)
    print(f"  DECISION (pre-registered, primary): {out['decision']}")


def _test():
    # paired stats and the decision rule
    a = {(f"T{i}", 0): i % 2 == 0 for i in range(40)}
    b = {(f"T{i}", 0): (i % 2 == 0) or i < 20 for i in range(40)}
    s = paired_stats(a, b, n_boot=2000)
    assert s["n"] == 40 and s["up"] == 10 and s["down"] == 0 and abs(s["diff"] - 25) < 1e-9 and s["ci"][0] > 0
    assert decide(s) == "CONTINUE"
    assert decide({"diff": 6.0, "ci": (-2.0, 14.0)}) == "GREY" and decide({"diff": 4.9, "ci": (1.0, 9.0)}) == "STOP"
    same = paired_stats(a, a, n_boot=500); assert same["diff"] == 0 and same["ci"] == (0, 0)
    # clustering: two blocks of the same topic resample together
    a2 = {**a, **{(t, 1): v for (t, _), v in a.items()}}; b2 = {**b, **{(t, 1): v for (t, _), v in b.items()}}
    s2 = paired_stats(a2, b2, n_boot=2000); assert s2["n"] == 80 and abs(s2["diff"] - 25) < 1e-9
    # stutters: written-out reduplication is excluded only when asked
    ex = redup()
    assert ex, "no reduplication found in the training poems"
    w = next(iter(sorted(ex)))
    p = f"{w}{w}ไปแล้ว\nก\nข\nค"
    assert stutters(p) >= 1 and stutters(p, ex) == stutters(p) - 1
    assert xrep("ความรักไป\nความรักมา\nก\nข") >= 1
    # pools: every topic in exactly one half, halves differ by at most one
    h = halves([f"T{i:03d}" for i in range(49)]); assert [len(x) for x in h] == [25, 24] and not set(h[0]) & set(h[1])
    # judged text: reduplication written back, nothing else changed, round trip to the model's cells
    import fixbase_data, syl_align_v2
    assert judged_text("ค่อยช้าช้าไปดีดี\nนานาชาติ") == "ค่อยช้าๆไปดีๆ\nนานาชาติ"
    assert judged_text("สายลมพัดผ่าน") == "สายลมพัดผ่าน"
    t = "ทุกทุกวันคิดถึง"; seg = lambda x: syl_align_v2.syls_aligned(x, 12)
    assert fixbase_data.expand_yamok(seg(judged_text(t))) == seg(t)
    print(f"fixbase_pipe: tests pass ({len(ex)} corpus reduplication syllables)")


if __name__ == "__main__":
    cmd, args = sys.argv[1], sys.argv[2:]
    {"merge": merge, "picks": picks, "locmap": locmap, "variants": variants, "accept": accept, "gates": gates,
     "score": score, "test": _test}[cmd](*args)