File size: 12,305 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
"""Confirmation 2 (job 189, runs/CSEL/CONFIRM2_PREREG.md), stage 0: fresh held-out titles + human controls.

The TEST side of the title split (data/modern_bot_test_prov.jsonl) is the only source of titles the models never saw.
fresh120 (scripts/fresh_titles.py) took EVERY test title that passed its filters: re-running those filters today,
minus fresh120, leaves 0 titles. Confirmation 2 therefore uses the remaining test titles under a RELAXED rule,
written before any decode (the relaxations are listed in the prereg and in `rule` below):

  exclusions  every key in data/prompts/fresh_exclusion_keys.json (frozen 2026-09-28: all prompt files, judge batch
              topics, runs/*.jsonl prompts) + a CURRENT rescan with fresh_titles.used_keys() (picks up everything
              decoded or judged since) + all 106 fresh120 titles (incl. the user's exclusions F017/F039/F076) +
              dev49_pilot / test150 / test30_pilot (asserted) + every TRAIN title key. Frozen at first build to
              data/prompts/confirm2_exclusion_keys.json so a rebuild cannot drift.
  title       decoration-free key (split_titles.key); every raw spelling of the key: no forum-noise keyword (fresh120's
              list + KW_EXTRA), no letter outside the Thai block (Latin, digits, CJK ... - the stripped topic would
              differ from what the poet wrote to); no doubled or leading combining mark (typing noise). RELAXED: 2-12 syllables (fresh120: 6-22 characters).
  control     the human first stanza under the title, spaces stripped, 4 lines. RELAXED tiers (fresh120: tier 1 only):
                1 = opening post, stanza 0 (fresh120's rule)
                2 = opening post, its first 4-line klon8 stanza (the opener began with a non-klon8 stanza)
                3 = the thread's first 4-line klon8 stanza in any post (lowest post, then stanza)
              ties by lowest source id. The tier is recorded; per-tier rates are a secondary, descriptive split.
  order       sorted keys, seeded shuffle (seed 11), ids G001.. ; all eligible titles are kept (no cap).

Usage: confirm2_select.py build      (asserts >= 40 titles; prints count + sha256)
       confirm2_select.py test [env C2_SEL_FROZEN=1: skip the fresh rescan, as inside the job]                                 (CPU checks on real data, writes only to a temp dir)
"""
import os, sys, re, json, random, hashlib, argparse, collections, unicodedata, tempfile
sys.path[:0] = [".", "scripts"]

KW_EXTRA = ("สวัสดี", "ทักทาย", "แต่งโดย", "วิจารณ์", "บทละคร", "กลอนเจ็ด", "คำกลอน", "หัววรรค", "บ้านกลอน",
            "อิอิ", "ครับ", "ค่ะ")
NON_THAI_LETTER = lambda t: any(unicodedata.category(c).startswith(("L", "N")) and not ("฀" <= c <= "๿")
                                for c in unicodedata.normalize("NFC", t))
THAI_DIGIT = re.compile(r"[๐-๙]")
# typing noise: a key that starts with a combining mark or doubles one is not a clean topic
MARK_NOISE = re.compile(r"^[ัิ-ฺ็-๎]|([ัิ-ฺ็-๎])\1")
SEED, MIN_SYL, MAX_SYL = 11, 2, 12
OUT, HUMAN, EXCL = "data/prompts/confirm2.jsonl", "data/pools/confirm2_human.jsonl", "data/prompts/confirm2_exclusion_keys.json"
PRIOR_SETS = ("data/prompts/fresh120.jsonl", "data/prompts/dev49_pilot.jsonl", "data/prompts/test150.jsonl",
              "data/prompts/test30_pilot.jsonl")


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


def prior_keys():
    import fresh_titles as FT
    ks = set()
    for f in PRIOR_SETS:
        for r in rows(f): ks.add(FT.key(r.get("text") or r.get("prompt")))
    return ks


def exclusion_keys(path):
    """frozen at first build; the rescan skips this confirmation's own files"""
    import fresh_titles as FT
    if os.path.exists(path): return set(json.load(open(path, encoding="utf-8")))
    hide = [OUT]                                  # our own prompt file must not exclude itself on the first build
    moved = [(p, p + ".c2hide") for p in hide if os.path.exists(p)]
    for a, b in moved: os.replace(a, b)
    try: now = FT.used_keys()
    finally:
        for a, b in moved: os.replace(b, a)
    ex = set(json.load(open("data/prompts/fresh_exclusion_keys.json", encoding="utf-8"))) | now | prior_keys()
    ex.discard("")
    json.dump(sorted(ex), open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=0)
    return ex


def tier_pick(rs):
    t1 = [r for r in rs if r["post"] == 0 and r["stanza"] == 0]
    if t1: return 1, min(t1, key=lambda r: r["id"])
    t2 = [r for r in rs if r["post"] == 0]
    if t2: return 2, min(t2, key=lambda r: (r["stanza"], r["id"]))
    return 3, min(rs, key=lambda r: (r["post"], r["stanza"], r["id"]))


def eligible(prov, excl, train_keys, g2p_len):
    import fresh_titles as FT
    by_key = collections.defaultdict(list)
    for r in prov: by_key[FT.key(r["title"])].append(r)
    why, keep = collections.Counter(), {}
    for k in sorted(by_key):
        rs = by_key[k]; raws = {r["title"] for r in rs}
        if not k: why["empty key"] += 1; continue
        if k in excl: why["used before"] += 1; continue
        if k in train_keys: why["train title"] += 1; continue
        if any(any(w in t for w in FT.KW + KW_EXTRA) for t in raws): why["forum keyword"] += 1; continue
        if any(NON_THAI_LETTER(t) or THAI_DIGIT.search(t) for t in raws): why["non-Thai letter/digit"] += 1; continue
        if not FT.THAI_ONLY.fullmatch(k): why["not Thai-only"] += 1; continue
        if MARK_NOISE.search(k): why["doubled/leading combining mark"] += 1; continue
        n = g2p_len(k)
        if not MIN_SYL <= n <= MAX_SYL: why[f"syllables outside {MIN_SYL}-{MAX_SYL}"] += 1; continue
        keep[k] = rs
    return keep, why, len(by_key)


def build(out=OUT, human=HUMAN, excl_path=EXCL, prov_path="data/modern_bot_test_prov.jsonl"):
    import fresh_titles as FT
    from klon8 import prosody as P
    train_keys = {FT.key(json.loads(l)["title"]) for l in open("data/modern_bot_train.jsonl", encoding="utf-8")}
    excl = exclusion_keys(excl_path)
    assert prior_keys() <= excl, "a prior set is not excluded"
    keep, why, n_keys = eligible(rows(prov_path), excl, train_keys, lambda k: len(P.g2p_line(k)))
    ks = sorted(keep); random.Random(SEED).shuffle(ks)
    assert len(ks) >= 40, f"only {len(ks)} titles"
    assert not (set(ks) & excl) and not (set(ks) & train_keys) and not (set(ks) & prior_keys())
    tiers = collections.Counter()
    os.makedirs(os.path.dirname(human), exist_ok=True)
    with open(out, "w", encoding="utf-8") as f, open(human, "w", encoding="utf-8") as h:
        for i, k in enumerate(ks, 1):
            tier, op = tier_pick(keep[k]); tiers[tier] += 1
            poem = op["poem"].replace(" ", "")
            assert len(poem.split("\n")) == 4 and all(poem.split("\n")), f"{k}: control is not 4 lines"
            gid = f"G{i:03d}"
            f.write(json.dumps({"id": gid, "form": "topic", "text": k, "raw_title": op["title"], "set": "CONFIRM2",
                                "syl": len(P.g2p_line(k)), "control_tier": tier}, ensure_ascii=False) + "\n")
            h.write(json.dumps({"id": gid, "prompt": k, "poem": poem, "src_id": op["id"], "board": op.get("board"),
                                "thread": op["thread"], "post": op["post"], "stanza": op["stanza"], "tier": tier},
                               ensure_ascii=False) + "\n")
    P.save_cache()
    print(f"  test-split title keys {n_keys}; excluded: " + ", ".join(f"{k} {v}" for k, v in why.most_common()))
    print(f"  kept {len(ks)} titles (seed {SEED}); control tiers {dict(sorted(tiers.items()))}; exclusion keys {len(excl)}")
    print(f"  {out} sha256 {sha(out)}\n  {human} sha256 {sha(human)}\n  {excl_path} sha256 {sha(excl_path)}")
    return len(ks)


def _test():
    import fresh_titles as FT
    # tier rule on hand-built rows
    R = lambda i, p, s: {"id": i, "post": p, "stanza": s}
    assert tier_pick([R("b", 0, 0), R("a", 0, 0), R("c", 1, 0)]) == (1, R("a", 0, 0))
    assert tier_pick([R("b", 0, 3), R("a", 0, 5), R("c", 1, 0)]) == (2, R("b", 0, 3))
    assert tier_pick([R("b", 2, 0), R("a", 1, 4), R("c", 1, 2)]) == (3, R("c", 1, 2))
    # title rule on hand-built titles
    assert NON_THAI_LETTER("รัก VS ใจ") and NON_THAI_LETTER("恭喜 รวย") and not NON_THAI_LETTER("…รัก(ใจ)!!")
    fake = [dict(title=t, id=f"x{n}", post=0, stanza=0, poem="a\nb\nc\nd", thread=1) for n, t in enumerate(
        ["รักเธอ", "ครูครับ", "วันเกิดเธอ", "ใจ VS ใจ", "ฝนตก", "ฝน", "used", "ที่่รัก", "่่รัก"])]
    keep, why, _ = eligible(fake, {"ฝนตก"}, {"รักเธอ"}, lambda k: 1 if k == "ฝน" else 3)
    assert set(keep) == set() and why["used before"] == 1 and why["train title"] == 1 and why["forum keyword"] == 2 \
        and why["non-Thai letter/digit"] == 1 and why["syllables outside 2-12"] == 1 \
        and why["doubled/leading combining mark"] == 2, (keep, why)
    assert not MARK_NOISE.search("น้ำตาพราวที่ราวฟ้า") and not MARK_NOISE.search("เก็บมาลัยไว้กราบแม่")
    # the real build into a temp dir (the exclusion list is REBUILT there: it must equal a fresh derivation)
    d = tempfile.mkdtemp(prefix="c2sel_", dir=os.environ.get("C2_TMP") or None)
    if os.environ.get("C2_SEL_FROZEN"):        # inside job 189: runs/ now holds this confirmation's own outputs, so a
        import shutil                          # fresh rescan would exclude its titles; check the frozen derivation only
        shutil.copy(EXCL, f"{d}/ex.json")
    n = build(f"{d}/p.jsonl", f"{d}/h.jsonl", f"{d}/ex.json")
    P_, H_ = rows(f"{d}/p.jsonl"), rows(f"{d}/h.jsonl")
    assert len(P_) == len(H_) == n and [r["id"] for r in P_] == [r["id"] for r in H_]
    assert all(p["text"] == h["prompt"] for p, h in zip(P_, H_))
    ex = set(json.load(open(f"{d}/ex.json", encoding="utf-8")))
    train_keys = {FT.key(json.loads(l)["title"]) for l in open("data/modern_bot_train.jsonl", encoding="utf-8")}
    for f in PRIOR_SETS + ("data/prompts/fresh_exclusion_keys.json",):
        ks = set(json.load(open(f))) if f.endswith(".json") else {FT.key(r.get("text") or r.get("prompt")) for r in rows(f)}
        assert not ({r["text"] for r in P_} & ks), f"overlap with {f}"
    fresh = rows("data/prompts/fresh120.jsonl"); assert len(fresh) == 106
    assert not ({r["text"] for r in P_} & {r["text"] for r in fresh} | set()) and {"F017", "F039", "F076"} <= {r["id"] for r in fresh}
    assert not ({r["text"] for r in P_} & train_keys) and {r["text"] for r in P_} <= set(r["text"] for r in P_) and not ({r["text"] for r in P_} & ex)
    test_keys = {FT.key(r["title"]) for r in rows("data/modern_bot_test_prov.jsonl")}
    assert {r["text"] for r in P_} <= test_keys, "a title is not from the test split"
    prov = {r["id"]: r for r in rows("data/modern_bot_test_prov.jsonl")}
    for h in H_[:3] + H_[-3:]:                 # controls by content: the source row, under the same key, spaces stripped
        src = prov[h["src_id"]]
        assert FT.key(src["title"]) == h["prompt"] and src["poem"].replace(" ", "") == h["poem"]
    if os.path.exists(OUT):                    # the frozen list must be reproducible from the frozen exclusion file
        d2 = tempfile.mkdtemp(prefix="c2sel2_", dir=os.environ.get("C2_TMP") or None)
        import shutil; shutil.copy(EXCL, f"{d2}/ex.json")
        build(f"{d2}/p.jsonl", f"{d2}/h.jsonl", f"{d2}/ex.json")
        assert open(f"{d2}/p.jsonl", "rb").read() == open(OUT, "rb").read(), "frozen title list does not reproduce"
        assert open(f"{d2}/h.jsonl", "rb").read() == open(HUMAN, "rb").read(), "frozen human controls do not reproduce"
        print("  frozen list reproduces byte-for-byte from the frozen exclusion keys")
    print(f"confirm2_select: tests pass ({n} titles, temp {d})")


if __name__ == "__main__":
    if sys.argv[1] == "build": build()
    elif sys.argv[1] == "test": _test()
    else: sys.exit("usage: confirm2_select.py build|test")