"""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)