File size: 14,032 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
"""Pipeline helpers for job 186 (two-layer canvas, runs/CSEL/TWOLAYER_PREREG.md). CPU only, no model code.

  gates OUT A-B CTL=DIR ARM=DIR ...     pre-judge drift gates of every arm vs the control (170's FIXED raw candidates),
                                        seeds A..B; writes OUT/gates.json; prints the arms that pass (exit 0 always)
  pools OUT SET A-B CTL_DIR ARM=DIR ... judge inputs for one pool set (control + <= 3 arms), seeds A..B, per seed and
                                        topic half (25/24): OUT/t{SET}_{name}_s{seed}h{h}.jsonl, RAW text (no ๆ write-back),
                                        a (topic, seed) kept only when the control AND every arm of the set pass form
                                        (equal counts per arm, so build_pool's balance assert holds)
  score OUT A-B SETSPEC ...             after judging runs/TL{SET}_{CLEAN,ALIGN}{seed}{h}/run1.json; SETSPEC =
                                        SET:arm1,arm2,... ; primary = judged clean rate, arm - control on the paired set,
                                        topic-clustered 95% CI, exact McNemar p, Holm across the arms of each MODEL
                                        (h_* and p_*), decision per the prereg; void pool (anchors < 70% clean) -> exit 3
  test                                  CPU tests of the pure parts
"""
import os, sys, json, math, glob, collections
sys.path.insert(0, "."); sys.path.insert(0, "scripts")


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


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


def halves(ids):   # = fixbase_pipe.halves / fixon_raw.halves
    ids = sorted(ids); h = (len(ids) + 1) // 2
    return [ids[:h], ids[h:]]


def seeds(spec):
    a, b = map(int, spec.split("-")); return range(a, b + 1)


# ---------------------------------------------------------------- gates

WF = None


def word_logfreq():
    """= fixtrain_gates.word_logfreq (copied: that module imports canvas_bot)"""
    global WF
    if WF is None:
        from pythainlp.tokenize import word_tokenize
        c = collections.Counter()
        for l in open("data/modern_bot_train.jsonl", encoding="utf-8"):
            for ln in json.loads(l)["poem"].split("\n"): c.update(w for w in word_tokenize(ln.replace(" ", ""), engine="newmm") if w.strip())
        WF = (c, sum(c.values()))
    return WF


def stats(R):
    """= fixtrain_gates.stats (syllables by the v1 segmenter, as every earlier gate) + cross-line content repeats"""
    from pythainlp.tokenize import word_tokenize
    from syl_align import syls_aligned as SA
    from klon8 import prosody as P
    c_, tot_ = word_logfreq()
    finals = collections.Counter(); bi = set(); nbi = 0; stut = 0; lf = []; rep = 0
    for r in R:
        seen_lines = []
        for ln in r["poem"].split("\n"):
            sy = SA(ln, 12)
            if sy: finals[sy[-1]] += 1
            pairs = list(zip(sy, sy[1:])); nbi += len(pairs); bi.update(pairs); stut += sum(a == b for a, b in pairs)
            ws = [w for w in word_tokenize(ln, engine="newmm") if w.strip()]
            if ws: lf.append(math.log((c_[ws[-1]] + 1) / (tot_ + len(c_))))
            content = {w for w in ws if len(P.g2p_line(w)) >= 2}          # multi-syllable words repeated across lines
            rep += sum(any(w in s for s in seen_lines) for w in content); seen_lines.append(content)
    tot = sum(finals.values())
    return {"n": len(R), "form": 100 * sum(bool(r["all_pass"]) for r in R) / len(R),
            "final_bits": -sum(c / tot * math.log2(c / tot) for c in finals.values()),
            "distinct2": len(bi) / max(nbi, 1), "stutters": stut / len(R), "repeats": rep / len(R),
            "final_word_logfreq": sum(lf) / max(len(lf), 1),
            "final_word_unseen": sum(x < math.log(1.5 / (tot_ + len(c_))) for x in lf) / max(len(lf), 1)}


def gate_fail(c, f):
    """pre-registered (TWOLAYER_PREREG.md): form >= 98%; stutters <= CTL + 0.10/poem; cross-line repeats <= CTL + 0.5;
    distinct-2 >= CTL - 0.05; line-final syllable entropy >= CTL - 0.3 bits; line-final unseen-word share <= CTL + 0.02
    (the open vocabulary must not fill line ends with strange words); line-final word log-frequency within 0.3 of CTL"""
    dead = []
    if f["form"] < 98: dead.append("form < 98%")
    if f["stutters"] > c["stutters"] + 0.10: dead.append("stutters")
    if f["repeats"] > c["repeats"] + 0.5: dead.append("cross-line repeats")
    if f["distinct2"] < c["distinct2"] - 0.05: dead.append("distinct-2")
    if f["final_bits"] < c["final_bits"] - 0.3: dead.append("rhyme-word variety")
    if f["final_word_unseen"] > c["final_word_unseen"] + 0.02: dead.append("line-final unseen words")
    if abs(f["final_word_logfreq"] - c["final_word_logfreq"]) > 0.3: dead.append("line-final word frequency")
    return dead


def gates(out, spec, *arms):
    A = [tuple(x.split("=", 1)) for x in arms]
    assert A[0][0] == "CTL", "first arm must be CTL=DIR"
    S = {n: stats([r for s in seeds(spec) for r in rows(f"{d}/c{s}.jsonl")]) for n, d in A}
    res = {"arms": S, "dead": {}}
    for n, s in S.items():
        print(f"  {n:10s} n {s['n']}, form {s['form']:.1f}%, final bits {s['final_bits']:.2f}, distinct-2 {s['distinct2']:.3f}, "
              f"stutters {s['stutters']:.3f}, repeats {s['repeats']:.2f}, final-word log-freq {s['final_word_logfreq']:.2f}, unseen {s['final_word_unseen']:.3f}")
        if n != "CTL":
            res["dead"][n] = gate_fail(S["CTL"], s)
            print(f"  {n}: " + ("KILLED before judging: " + ", ".join(res["dead"][n]) if res["dead"][n] else "pre-judge gates PASS"))
    os.makedirs(out, exist_ok=True); json.dump(res, open(f"{out}/gates.json", "w"), indent=1)
    print("PASSING " + " ".join(n for n, d in res["dead"].items() if not d))


# ---------------------------------------------------------------- pools

def pools(out, set_name, spec, ctl, *arms):
    A = [("fixbase", ctl)] + [tuple(x.split("=", 1)) for x in arms]
    assert len({n for n, _ in A}) == len(A) and 2 <= len(A) <= 4, "control + 1..3 arms per pool, unique names"
    os.makedirs(out, exist_ok=True); n = dropped = 0
    for s in seeds(spec):
        R = {name: {r["id"]: r for r in rows(f"{d}/c{s}.jsonl")} for name, d in A}
        ids = sorted(R["fixbase"])
        assert all(set(R[k]) == set(ids) for k in R), f"seed {s}: arms cover different topics"
        assert all(R[k][i]["prompt"] == R["fixbase"][i]["prompt"] for k in R for i in ids), f"seed {s}: prompts differ"
        for h, part in enumerate(halves(ids)):
            keep = [i for i in part if all(R[k][i]["all_pass"] for k in R)]
            dropped += len(part) - len(keep)
            for name, _ in A:
                J = [{"id": f"{i}_s{s}", "prompt": R[name][i]["prompt"], "poem": R[name][i]["poem"]} for i in keep]
                bad = [r["id"] for r in J if len(r["poem"].split("\n")) != 4]
                assert not bad, f"{name} seed {s}: poems without 4 lines: {bad}"
                wrows(f"{out}/t{set_name}_{name}_s{s}h{h}.jsonl", J); n += len(J)
    print(f"  pool set {set_name}: arms {[x for x, _ in A]}, seeds {spec}, {n} poems; (topic, seed) dropped for form in any arm: {dropped}")


# ---------------------------------------------------------------- score

def mcnemar_p(up, down):
    """exact two-sided sign test on the discordant pairs"""
    n = up + down
    if n == 0: return 1.0
    k = min(up, down)
    return min(1.0, 2 * sum(math.comb(n, i) for i in range(k + 1)) / 2 ** n)


def holm(ps):
    """Holm step-down adjusted p-values, {name: p}"""
    order = sorted(ps, key=ps.get); m = len(ps); out = {}; run = 0.0
    for r, k in enumerate(order):
        run = max(run, min(1.0, (m - r) * ps[k])); out[k] = run
    return out


def decide(clean, aligned, p_holm):
    """pre-registered: CONTINUE if clean >= +5pp, Holm-adjusted p < 0.05 and aligned >= -3pp; KILL if clean < +2pp;
    otherwise GREY"""
    if clean["diff"] >= 5 and p_holm < 0.05 and aligned["diff"] >= -3: return "CONTINUE"
    if clean["diff"] < 2: return "KILL"
    return "GREY"


FAMILIES = {m: [f"{m}_{u}" for u in ("syllable", "word", "wak", "bot")] for m in ("h", "p")}


def score(out, spec, *setspecs, root_pools="runs"):
    import fixon_raw, fixbase_pipe
    a, b = map(int, spec.split("-"))
    R = {"sets": {}, "arms": {}}; void = {}
    for ss in setspecs:
        set_name, arms = ss.split(":"); arms = arms.split(",")
        lab, anchors = fixon_raw.labels(f"TL{set_name}", a, b, root_pools)
        void.update({d: v for d, v in anchors.items() if v < 0.70}); R["sets"][set_name] = anchors
        for arm in arms:
            st = {}
            for kind in ("clean", "aligned"):
                c = {k[1:]: v for k, v in lab[kind].items() if k[0] == "fixbase"}
                t = {k[1:]: v for k, v in lab[kind].items() if k[0] == arm}
                assert c and set(c) == set(t), f"set {set_name}: {arm} and the control were judged on different poems"
                st[kind] = fixbase_pipe.paired_stats(c, t)
            st["clean"]["p"] = mcnemar_p(st["clean"]["up"], st["clean"]["down"]); st["set"] = set_name
            R["arms"][arm] = st
    # Holm families FIXED in advance (prereg): the 4 units of each model; an arm not judged (gate kill, set C skipped)
    # enters its family with p = 1, so skipping a set can never make the others' correction weaker
    for fam in FAMILIES:
        ps = {arm: (R["arms"][arm]["clean"]["p"] if arm in R["arms"] else 1.0) for arm in FAMILIES[fam]}
        for arm, ph in holm(ps).items():
            if arm not in R["arms"]: continue
            st = R["arms"][arm]; st["clean"]["p_holm"] = ph
            st["decision"] = decide(st["clean"], st["aligned"], ph)
            c, al = st["clean"], st["aligned"]
            print(f"  {arm:12s} clean control {c['a']:5.1f}% arm {c['b']:5.1f}% diff {c['diff']:+.1f}pp ({c['up']} up / {c['down']} down, "
                  f"n={c['n']}) CI [{c['ci'][0]:+.1f}, {c['ci'][1]:+.1f}] p {c['p']:.4f} Holm {ph:.4f}; aligned {al['diff']:+.1f}pp -> {st['decision']}")
    os.makedirs(out, exist_ok=True); R["void_pools"] = sorted(void)
    json.dump(R, open(f"{out}/RESULT.json", "w"), indent=1)
    if void:
        print(f"  VOID pools (anchors < 70% clean): {void} - re-judge those pools before any decision"); sys.exit(3)


# ---------------------------------------------------------------- tests

def _test():
    import tempfile
    t = tempfile.mkdtemp()
    poem = "ฝนตกลงมาที่บ้านเกิด\nลม\nพัด\nมา"
    for d, bad in (("ctl", {3}), ("h_word", {1}), ("h_bot", set())):
        os.makedirs(f"{t}/{d}")
        wrows(f"{t}/{d}/c0.jsonl", [{"id": f"T{i:02d}", "prompt": "ฝน", "poem": poem, "all_pass": i not in bad} for i in range(5)])
    pools(f"{t}/p", "A", "0-0", f"{t}/ctl", f"h_word={t}/h_word", f"h_bot={t}/h_bot")
    # kept only where every arm passes form: T00,T02 in half 0 (T01 fails in h_word), T04 in half 1 (T03 fails in ctl)
    for name in ("fixbase", "h_word", "h_bot"):
        assert [r["id"] for r in rows(f"{t}/p/tA_{name}_s0h0.jsonl")] == ["T00_s0", "T02_s0"], name
        assert [r["id"] for r in rows(f"{t}/p/tA_{name}_s0h1.jsonl")] == ["T04_s0"], name
    try: pools(f"{t}/p", "B", "0-0", f"{t}/ctl", *[f"a{i}={t}/h_bot" for i in range(4)]); raise AssertionError("5 arms accepted")
    except AssertionError as e: assert "control + 1..3" in str(e)
    assert abs(mcnemar_p(8, 0) - 2 / 256) < 1e-12 and mcnemar_p(3, 3) == 1.0
    hp = holm({"a": 0.01, "b": 0.04, "c": 0.03}); assert abs(hp["a"] - 0.03) < 1e-12 and abs(hp["c"] - 0.06) < 1e-12 and abs(hp["b"] - 0.06) < 1e-12, hp
    assert decide({"diff": 6}, {"diff": 0}, 0.01) == "CONTINUE" and decide({"diff": 6}, {"diff": 0}, 0.2) == "GREY"
    assert decide({"diff": 1}, {"diff": 0}, 0.5) == "KILL" and decide({"diff": 6}, {"diff": -4}, 0.01) == "GREY"
    # scoring on fake judged pools: control fails T00 (clean), arms clean everywhere
    for inst in ("CLEAN", "ALIGN"):
        for h in (0, 1):
            d = f"{t}/runs/TLA_{inst}0{h}"; os.makedirs(d); km, res = {}, []
            for name in ("fixbase", "h_word", "h_bot"):
                for r in rows(f"{t}/p/tA_{name}_s0h{h}.jsonl"):
                    k = f"{name}{r['id']}"; km[k] = {"src": name, "id": r["id"]}
                    badj = name == "fixbase" and r["id"] == "T00_s0"
                    res.append({"item": k, "errors": [{"severity": "major"}] if badj else [], "align": 2 if badj else 5})
            for j in range(3):
                km[f"h{j}"] = {"src": "human", "id": f"H{j}"}; res.append({"item": f"h{j}", "errors": [], "align": 5})
            json.dump(km, open(f"{d}/keymap.json", "w")); json.dump(res, open(f"{d}/run1.json", "w"))
    score(f"{t}/o", "0-0", "A:h_word,h_bot", root_pools=f"{t}/runs")
    Rr = json.load(open(f"{t}/o/RESULT.json"))
    c = Rr["arms"]["h_word"]["clean"]
    assert c["n"] == 3 and c["up"] == 1 and c["down"] == 0 and abs(c["diff"] - 100 / 3) < 1e-9, c
    assert abs(c["p_holm"] - 1.0) < 1e-12 and Rr["arms"]["h_word"]["decision"] == "GREY", Rr["arms"]["h_word"]
    # fixed family: 2 judged h arms with p = 0.01 each and 2 unjudged (p = 1): Holm = 4 x 0.01 and 3 x 0.01 -> 0.04
    hp = holm({"h_syllable": 1.0, "h_word": 0.01, "h_wak": 0.01, "h_bot": 1.0}); assert abs(hp["h_word"] - 0.04) < 1e-12, hp
    # gates: a clean arm passes, a stuttering arm fails
    base = {"n": 10, "form": 99, "final_bits": 7, "distinct2": 0.6, "stutters": 0.05, "repeats": 1.0, "final_word_logfreq": -9, "final_word_unseen": 0.01}
    assert gate_fail(base, dict(base)) == [] and gate_fail(base, dict(base, stutters=0.2, form=97)) == ["form < 98%", "stutters"]
    print("twolayer_pipe: tests pass")


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