Enrell commited on
Commit
4e76c6f
·
verified ·
1 Parent(s): 6ab672a

Upload folder using huggingface_hub

Browse files
app/PROVENANCE.json ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "service": "lain-parser",
3
+ "version": "1.1.0",
4
+ "frozen": "2026-10-02",
5
+ "artifact": {
6
+ "name": "e5-s2",
7
+ "source": "artifacts/e5-s2 (trained cell runs/parser-t4/e5-s2)",
8
+ "model_sha256": "d928668e5ed3c8ea4ac69e42f8d11c90f82120e3307dffaf3a02eb801c077e54",
9
+ "config_sha256": "fa76e5b7d0091d266e337b57e6bb74f703581136f8d740b9c361488417e5cbd0",
10
+ "evaluation": "docs/E-SERIES.md — real-final-v1 e5-s2 fp32 138/196; this int8 artifact 137/196 (69.9%) on CPU; arm +6.5 pp over the R38 baseline"
11
+ },
12
+ "snapshot": {
13
+ "model": "AniFileBERT DAPT-512-long",
14
+ "source": "artifacts/anifilebert-dapt512-long",
15
+ "weights_sha256": "509125db5cc4bee9f3949b44e1c17f86b89406c8efee6a7376c39de8b3db7a0d"
16
+ },
17
+ "vendored_app": {
18
+ "app/fp/parser2.py": {
19
+ "source": "fp/parser2.py",
20
+ "sha256": "89abf39364c34d1e159d56553f1044cce06040d23898c6b8c5fa849b4dae7efb"
21
+ },
22
+ "app/fp/corpus2.py": {
23
+ "source": "fp/corpus2.py",
24
+ "sha256": "a1953d116fa81f0a3103d25d5a20ba7216adc7405aab337888165f7b0a636893"
25
+ },
26
+ "app/fp/parser3_model.py": {
27
+ "source": "fp/parser3_model.py",
28
+ "sha256": "be0ef67cf753a730b7fa86538d36fe50cfddea53c71b70ac7a29dee1cbaf39ee"
29
+ },
30
+ "app/fp/normalize.py": {
31
+ "source": "fp/normalize.py",
32
+ "sha256": "7e5d096f9f36f2d3d289bf3d75b6d680bbfa17339431c154a00dc7fe8268ee01"
33
+ },
34
+ "app/fp/charclass.py": {
35
+ "source": "fp/charclass.py",
36
+ "sha256": "f1bae0df50d83d44bf4df4b2df013a604aa704ae81db70f49c88f758653e7644"
37
+ }
38
+ },
39
+ "claim": "development evidence; assistant-produced labels; see docs/E-SERIES.md"
40
+ }
app/fp/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Vendored subset of the filename-parser package (see ../PROVENANCE.json).
2
+
3
+ Only the inference path ships here: character encoder, BIO/interval head,
4
+ contract resolver and their shared corpus constants. The training modules are
5
+ not vendored on purpose, so the service cannot drift into training behaviour.
6
+ """
app/fp/charclass.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unicode class features for the character encoder (E2).
2
+
3
+ A character's identity embedding says nothing about a code point the model has
4
+ never seen; its Unicode general category and script do. Every character gets a
5
+ (category, script) pair, and the parser adds one learned embedding for each to
6
+ the identity embedding, so an unseen separator or bracket still arrives as
7
+ "opening punctuation" or "dash".
8
+
9
+ Class 0 is reserved for positions that are not characters ([CLS], [SEP],
10
+ padding). The table is built from this interpreter's `unicodedata` and exported
11
+ into the Go artifact as ranges, so both runtimes use the same assignment even
12
+ where Unicode versions differ.
13
+ """
14
+
15
+ import bisect
16
+ import unicodedata
17
+
18
+ import numpy as np
19
+
20
+ CATEGORIES = ("Lu", "Ll", "Lt", "Lm", "Lo", "Mn", "Mc", "Me", "Nd", "Nl", "No",
21
+ "Pc", "Pd", "Ps", "Pe", "Pi", "Pf", "Po", "Sm", "Sc", "Sk", "So",
22
+ "Zs", "Zl", "Zp", "Cc", "Cf", "Cs", "Co", "Cn")
23
+ # Block ranges, not the Unicode Script property: `unicodedata` does not expose
24
+ # scripts, and blocks are what separate the signals a filename cares about
25
+ # (fullwidth vs ASCII punctuation, kana vs Han). Unlisted code points are "other".
26
+ SCRIPT_BLOCKS = (
27
+ ("latin", ((0x0000, 0x024F), (0x1E00, 0x1EFF), (0x2C60, 0x2C7F), (0xA720, 0xA7FF),
28
+ (0xAB30, 0xAB6F))),
29
+ ("ipa-modifier", ((0x0250, 0x036F),)),
30
+ ("greek", ((0x0370, 0x03FF), (0x1F00, 0x1FFF))),
31
+ ("cyrillic", ((0x0400, 0x052F), (0x1C80, 0x1C8F), (0x2DE0, 0x2DFF), (0xA640, 0xA69F))),
32
+ ("armenian-hebrew", ((0x0530, 0x05FF),)),
33
+ ("arabic", ((0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF), (0xFB50, 0xFDFF),
34
+ (0xFE70, 0xFEFF))),
35
+ ("indic", ((0x0900, 0x0DFF),)),
36
+ ("southeast-asian", ((0x0E00, 0x0FFF), (0x1000, 0x109F), (0x1780, 0x17FF))),
37
+ ("hangul", ((0x1100, 0x11FF), (0x3130, 0x318F), (0xA960, 0xA97F), (0xAC00, 0xD7FF))),
38
+ ("general-punctuation", ((0x2000, 0x206F),)),
39
+ ("symbols", ((0x2070, 0x2BFF),)),
40
+ ("cjk-symbols", ((0x3000, 0x303F), (0x3200, 0x33FF), (0xFE10, 0xFE1F), (0xFE30, 0xFE4F))),
41
+ ("hiragana", ((0x3040, 0x309F),)),
42
+ ("katakana", ((0x30A0, 0x30FF), (0x31F0, 0x31FF), (0xFF66, 0xFF9F))),
43
+ ("han", ((0x2E80, 0x2FDF), (0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF),
44
+ (0x20000, 0x3FFFF))),
45
+ ("fullwidth", ((0xFF00, 0xFF65), (0xFFE0, 0xFFEF))),
46
+ ("emoji", ((0x1F000, 0x1FAFF),)),
47
+ )
48
+ SCRIPTS = tuple(name for name, _ in SCRIPT_BLOCKS) + ("other",)
49
+ N_CATEGORIES, N_SCRIPTS = len(CATEGORIES) + 1, len(SCRIPTS) + 1
50
+ LIMIT = 0x110000
51
+ # Categories that carry structure rather than content; identity dropout hits
52
+ # them harder (see Parser.character_embeddings).
53
+ STRUCTURAL = frozenset(i + 1 for i, name in enumerate(CATEGORIES) if name[0] in "PSZ")
54
+
55
+ _TABLES = None
56
+
57
+
58
+ def tables():
59
+ """(category ids, script ids) for every code point, as uint8 arrays."""
60
+ global _TABLES
61
+ if _TABLES is None:
62
+ category = np.empty(LIMIT, dtype=np.uint8)
63
+ index = {name: i + 1 for i, name in enumerate(CATEGORIES)}
64
+ for cp in range(LIMIT):
65
+ category[cp] = index[unicodedata.category(chr(cp))]
66
+ script = np.full(LIMIT, SCRIPTS.index("other") + 1, dtype=np.uint8)
67
+ for name, ranges in SCRIPT_BLOCKS:
68
+ for a, b in ranges:
69
+ script[a:b + 1] = SCRIPTS.index(name) + 1
70
+ _TABLES = category, script
71
+ return _TABLES
72
+
73
+
74
+ def classes(char):
75
+ category, script = tables()
76
+ return int(category[ord(char)]), int(script[ord(char)])
77
+
78
+
79
+ def ranges():
80
+ """Run-length [start, end, category, script] rows covering 0..0x10FFFF."""
81
+ category, script = tables()
82
+ combined = category.astype(np.uint16) << 8 | script
83
+ change = np.flatnonzero(np.diff(combined)) + 1
84
+ starts = np.concatenate(([0], change))
85
+ ends = np.concatenate((change - 1, [LIMIT - 1]))
86
+ return [[int(a), int(b), int(category[a]), int(script[a])] for a, b in zip(starts, ends)]
87
+
88
+
89
+ def lookup(table, cp):
90
+ """Reference implementation of the Go range lookup (for parity tests)."""
91
+ position = bisect.bisect_right([row[0] for row in table], cp) - 1
92
+ return table[position][2], table[position][3]
app/fp/corpus2.py ADDED
@@ -0,0 +1,777 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Contract v2.1 synthetic corpus where *layout* is a locked experimental axis.
2
+
3
+ The pilot (v1) trained on three layouts and one per harder split, so a held-out
4
+ layout scored zero and the result could not be attributed. This corpus makes
5
+ layout families first-class, named and disjoint across splits: arm A trains on
6
+ the narrow set, arm B on the broad set, and both are scored on identical rows
7
+ whose layouts neither arm ever saw. That is the difference between measuring
8
+ memorization and measuring generalization over formats.
9
+
10
+ Three things make the gold here trustworthy rather than convenient:
11
+
12
+ * Every record is derived by `fp.normalize.record`, the same contract code that
13
+ scores predictions. The generator never writes an output value by hand, so a
14
+ case the contract cannot represent fails here instead of becoming a silent
15
+ disagreement between gold and prediction.
16
+ * Season values come from span *text* through `normalize.season_value`. There is
17
+ no value head and no annotator number, so "the model learned the numbers"
18
+ cannot be confused with "the code parsed the numbers".
19
+ * Every row is strict: each atom carries a kind, including DISCARD for junk, so
20
+ unexplained debris is impossible by construction and `check_debris` proves it.
21
+
22
+ Layout parts are explicit so a typo cannot become silent text: a `str` is a
23
+ literal that must contain no word characters, `("a", key)` is an atom (a span),
24
+ and `("j", text)` is junk claimed as DISCARD.
25
+ """
26
+
27
+ import argparse
28
+ import hashlib
29
+ import json
30
+ import random
31
+ import re
32
+ from collections import Counter
33
+ from pathlib import Path
34
+
35
+ from fp.normalize import record as contract_record
36
+
37
+ ROOT = Path("data/parser-v2")
38
+ SEED = 20260915
39
+ MAX_CHARS = 120 # the character encoders cap at 128 positions; nothing truncates
40
+ # Tolerated exactly as `fp.gold` tolerates them: a real outcome to be reproduced,
41
+ # not a generator bug. Anything else means the row cannot be represented.
42
+ TOLERATED = {"season-unspecified", "dimensions-no-class"}
43
+
44
+ # The span kinds this experiment's head may emit, and therefore the kinds the
45
+ # corpus must demonstrate. Kept here, not in the trainer, so a kind that exists
46
+ # in one place only is a test failure rather than an unmeasured blind spot.
47
+ KINDS = ("TITLE", "EPISODE_TITLE", "GROUP", "SEASON", "EPISODE", "RANGE", "LIST", "YEAR",
48
+ "RESOLUTION", "DIMENSIONS", "DISCARD")
49
+
50
+ LANGS = ("en", "pt", "es", "ru", "ja", "zh")
51
+ WORDS = {
52
+ "en": ["Quiet", "Harbor", "Copper", "Moon", "Garden", "River", "Paper", "Forest",
53
+ "Station", "Lantern", "Winter", "Signal"],
54
+ "pt": ["Porto", "Lua", "Jardim", "Rio", "Papel", "Bosque", "Pedra", "Silencio",
55
+ "Estacao", "Farol", "Inverno", "Sinal"],
56
+ "es": ["Puerto", "Luna", "Jardin", "Rio", "Papel", "Bosque", "Piedra", "Silencio",
57
+ "Estacion", "Faro", "Invierno", "Senal"],
58
+ "ru": ["Тихий", "Порт", "Луна", "Сад", "Река", "Бумага", "Лес", "Камень",
59
+ "Вокзал", "Маяк", "Зима", "Сигнал"],
60
+ "ja": ["静かな", "港", "月", "庭", "川", "紙", "森", "石",
61
+ "駅", "灯台", "冬", "信号"],
62
+ "zh": ["安静", "港湾", "月亮", "花园", "河流", "纸张", "森林", "石头",
63
+ "车站", "灯塔", "冬天", "信号"],
64
+ }
65
+ # Authored second-script forms, not real transliterations: a controlled bilingual
66
+ # vocabulary for testing alias handling. The spans are the point, not the
67
+ # linguistics, and nobody should read these rows as language data.
68
+ ALIAS = {
69
+ "ru": ["Tihiy", "Port", "Luna", "Sad", "Reka", "Bumaga", "Les", "Kamen",
70
+ "Vokzal", "Mayak", "Zima", "Signal"],
71
+ "ja": ["Shizuka", "Minato", "Tsuki", "Niwa", "Kawa", "Kami", "Mori", "Ishi",
72
+ "Eki", "Toudai", "Fuyu", "Shingou"],
73
+ "zh": ["Anjing", "Gangwan", "Yueliang", "Huayuan", "Heliu", "Zhiwang", "Senlin",
74
+ "Shitou", "Chezhan", "Dengta", "Dongtian", "Xinhao"],
75
+ }
76
+ SUBTITLE = {
77
+ "en": ["Rain Memory", "The Last Promise", "A Quiet Harbor", "Paper Lanterns",
78
+ "North Wind", "Signals at Dusk"],
79
+ "pt": ["Memoria da Chuva", "A Ultima Promessa", "Noite Serena", "Vento Norte",
80
+ "Farol Apagado", "Cidade Velha"],
81
+ "es": ["Recuerdo de Lluvia", "La Ultima Promesa", "Noche Serena", "Viento Norte",
82
+ "Farol Apagado", "Ciudad Vieja"],
83
+ "ru": ["Память дождя", "Последнее обещание", "Тихая ночь", "Северный ветер",
84
+ "Погасший маяк", "Старый город"],
85
+ "ja": ["雨の記憶", "最後の約束", "静かな夜", "北の風", "消えた灯台", "古い街"],
86
+ "zh": ["雨的记忆", "最后的约定", "安静的夜", "北风", "消失的灯塔", "旧城"],
87
+ }
88
+ SEASON_MARKS = {
89
+ "en": ["Season {n}", "{ord} Season", "Season {n}", "s{n}"],
90
+ "pt": ["{ord} Temporada", "Temporada {n}", "temporada {n}"],
91
+ "es": ["{ord} Temporada", "Temporada {n}", "temp {n}"],
92
+ "ru": ["{ord} Сезон", "Сезон {n}", "сезон {n}"],
93
+ "ja": ["第{n}期", "シーズン{n}", "{ord}シーズン"],
94
+ "zh": ["第{ord}季", "第{n}季"],
95
+ }
96
+ ORDINALS = {
97
+ "en": ["First", "Second", "Third", "Fourth"],
98
+ "pt": ["Primeira", "Segunda", "Terceira", "Quarta"],
99
+ "es": ["Primera", "Segunda", "Tercera", "Cuarta"],
100
+ "ru": ["Первый", "Второй", "Третий", "Четвёртый"],
101
+ "ja": ["一", "二", "三", "四"],
102
+ "zh": ["一", "二", "三", "四"],
103
+ }
104
+ FINAL_MARK = {"en": "Final Season", "pt": "Temporada Final", "es": "Temporada Final",
105
+ "ru": "Последний Сезон", "ja": "最終シーズン", "zh": "最终季"}
106
+ GROUPS = ["Nebula-raws", "Lantern Works", "Aoi-Subs", "Meridian", "Kite-Team",
107
+ "Harbor-Encode", "Sable-raws"]
108
+ JUNK = ["REMASTERED", "BluRay", "x264", "MULTI-AUDIO", "DVDRIP", "Hi10P", "WEBRip",
109
+ "AC3", "PROPER", "字幕组", "双语", "Batch", "Complete", "CD1", "Vol02"]
110
+ EXTENSIONS = [".mkv", ".mp4", ".avi", ".webm"]
111
+ RESOLUTIONS = ["480p", "720p", "1080p", "2160p", "720P", "1080P"]
112
+ DIMENSIONS = ["1280x720", "1920x1080", "640x480", "720x576", "3840x2160"]
113
+
114
+
115
+ def digest(path):
116
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
117
+
118
+
119
+ def layout_inventory():
120
+ """(name, tag, parts). `narrow` mirrors the pilot's three shapes; `broad` is
121
+ the format-coverage hypothesis; `heldout-*` trains nothing in this experiment.
122
+ """
123
+ return [
124
+ ("n1", "narrow", ["[", ("a", "g"), "] ", ("a", "t"), " - ", ("a", "e"),
125
+ " [", ("a", "R"), "]"]),
126
+ ("n2", "narrow", [("a", "t"), " [", ("a", "s"), "] ", ("a", "e"), " ",
127
+ ("a", "R"), "-", ("a", "d1")]),
128
+ ("n3", "narrow", [("a", "t"), "_", ("a", "s"), "_", ("a", "e"), "_", ("a", "R"),
129
+ "_[", ("a", "d1"), "]"]),
130
+ ("b1", "broad", [("a", "g"), " ", ("a", "t"), " - ", ("a", "e"), " [", ("a", "R"),
131
+ "][", ("a", "d1"), "]"]),
132
+ ("b2", "broad", [("a", "t"), " (", ("a", "y"), ") - ", ("a", "e"), " [", ("a", "R"),
133
+ "] [", ("a", "d1"), "]"]),
134
+ ("b3", "broad", [("a", "t"), ".", ("a", "y"), ".", ("a", "R"), ".", ("a", "d1"),
135
+ "-", ("a", "d2"), "-", ("a", "g")]),
136
+ ("b4", "broad", [("a", "t"), " ", ("a", "x"), " [", ("a", "R"), "] [", ("a", "g"), "]"]),
137
+ ("b5", "broad", [("a", "g"), " - ", ("a", "t"), " - ", ("a", "e"), " - ", ("a", "x")]),
138
+ ("b6", "broad", [("a", "t"), " ", ("a", "a"), " [", ("a", "s"), "] [", ("a", "R"), "]"]),
139
+ ("b7", "broad", [("a", "t"), " ", ("a", "a"), " ", ("a", "s"), " ", ("a", "e")]),
140
+ ("b8", "broad", [("a", "t"), " ", ("a", "sc"), ("a", "ec"), " [", ("a", "R"), "]"]),
141
+ ("b9", "broad", ["[", ("a", "d1"), "] ", ("a", "t"), " [", ("a", "x"), "] ",
142
+ ("a", "e"), " (", ("a", "R"), ")"]),
143
+ ("b10", "broad", [("a", "t"), " - ", ("a", "e"), " ", ("a", "d1"), " ", ("a", "R"),
144
+ " [", ("a", "g"), "]"]),
145
+ ("b11", "broad", ["[", ("a", "g"), "] ", ("a", "t"), "(", ("a", "y"), ") ",
146
+ ("a", "s"), " ", ("a", "l")]),
147
+ ("b12", "broad", [("a", "t"), " ", ("a", "s"), " ", ("a", "r"), " ", ("a", "R"),
148
+ " ", ("a", "d1")]),
149
+ ("b13", "broad", [("a", "t"), " [", ("a", "D"), "] ", ("a", "e"), " [", ("a", "d1"),
150
+ "] [", ("a", "g"), "]"]),
151
+ ("b14", "broad", [("a", "t"), " ", ("a", "fm"), " ", ("a", "e"), " [", ("a", "R"), "]"]),
152
+ ("b15", "broad", [("a", "g"), "/", ("a", "t"), "/", ("a", "td"), " ", ("a", "e"),
153
+ " [", ("a", "R"), "]"]),
154
+ ("b16", "broad", [("a", "t"), " ", ("a", "s"), " ", ("a", "ej"), " [", ("a", "R"), "]"]),
155
+ # held-out format families
156
+ ("h1", "heldout-layout-a", [("j", "TV"), "/", ("a", "t"), "/", ("a", "s"), "/", ("a", "td"), " ",
157
+ ("a", "d1"), " ", ("a", "e")]),
158
+ ("h2", "heldout-layout-a", [("a", "t"), "(", ("a", "y"), ")", ("a", "sc"),
159
+ "(", ("a", "R"), ")(", ("a", "d1"), ")"]),
160
+ ("h3", "heldout-layout-a", [("a", "t"), " - ", ("a", "e3"), ("a", "vt"), " - ",
161
+ ("a", "x"), " [", ("a", "R"), "]"]),
162
+ ("h4", "heldout-layout-a", ["[", ("a", "g"), "] ", ("a", "t"), " ", ("a", "ej"),
163
+ " [", ("a", "D"), "]"]),
164
+ ("h5", "heldout-layout-b", ["【", ("a", "g"), "】", ("a", "t"), "【", ("a", "ej"),
165
+ "】【", ("a", "R"), "】"]),
166
+ ("h6", "heldout-layout-b", [("a", "t"), " | ", ("a", "a"), " | ", ("a", "e"), " | ",
167
+ ("a", "d1")]),
168
+ ("h7", "heldout-layout-b", ["(", ("a", "d1"), ") ", ("a", "t"), " ", ("a", "ej"),
169
+ " ", ("a", "x"), " ", ("a", "R")]),
170
+ ("h8", "heldout-layout-b", [("a", "t"), "_", ("a", "s"), "_", ("a", "e"), "-",
171
+ ("a", "x"), "_", ("a", "d1"), "_[", ("a", "g"), "]"]),
172
+ ("h9", "heldout-layout-b", [("j", "Series"), "/", ("a", "t"), "/", ("a", "epp"), "-", ("a", "td"),
173
+ " ", ("a", "d1")]),
174
+ ("h10", "heldout-layout-b", [("a", "t"), " ", ("a", "a"), " ", ("a", "tc"), " ",
175
+ ("a", "l"), " [", ("a", "R"), "]"]),
176
+ # held-out lexical markers in familiar shapes
177
+ ("k1", "heldout-lexical", [("a", "g"), " ", ("a", "t"), " ", ("a", "epw"), " [",
178
+ ("a", "R"), "]"]),
179
+ ("k2", "heldout-lexical", [("a", "t"), " ", ("a", "chp"), ("j", " of 24"), " [", ("a", "R"), "]"]),
180
+ ("k3", "heldout-lexical", [("a", "t"), " ", ("a", "sw"), " ", ("a", "epw")]),
181
+ # held-out values: 4-digit episodes, wide ranges, big season numbers
182
+ ("v1", "heldout-value", [("a", "t"), " - ", ("a", "e4"), " [", ("a", "R"), "] [",
183
+ ("a", "d1"), "]"]),
184
+ ("v2", "heldout-value", [("a", "t"), " ", ("a", "sbig"), " ", ("a", "rwide"), " [",
185
+ ("a", "R"), "]"]),
186
+ ("v3", "heldout-value", [("a", "t"), " ", ("a", "sc"), ("a", "e4"), " [", ("a", "R"),
187
+ "] [", ("a", "g"), "]"]),
188
+ # no work title: the row must abstain
189
+ ("z1", "narrow-neg", [("a", "d1"), ".", ("a", "d2"), ".", ("a", "R")]),
190
+ ("z2", "broad-neg", ["[", ("a", "d1"), "] ", ("a", "tk"), " ", ("a", "d2")]),
191
+ ("z3", "broad-neg", [("a", "tk"), "/", ("a", "s"), "/", ("a", "d1")]),
192
+ ("z4", "heldout-neg", [("a", "d1"), "-", ("a", "d2"), "-", ("a", "D"), " [",
193
+ ("a", "g"), "]"]),
194
+ ]
195
+
196
+
197
+ LAYOUTS = {name: (tag, parts) for name, tag, parts in layout_inventory()}
198
+ # Which layouts an arm may see, and which corpus splits those layouts produced.
199
+ ARM_TAGS = {"A-narrow": ["narrow", "narrow-neg"],
200
+ "B-broad": ["narrow", "broad", "narrow-neg", "broad-neg"]}
201
+ ARM_SPLITS = {"A-narrow": ["train-narrow"], "B-broad": ["train-narrow", "train-extra"]}
202
+ NEEDS_ALIAS = {"b6", "b7", "h6", "h10"}
203
+ LITERAL_OK = re.compile(r"^[\W_]*$", re.UNICODE)
204
+
205
+
206
+ def make_atoms(rng, lang, title, alias):
207
+ """The row's typed fragments. Probes reuse this dict and change exactly one key."""
208
+ season_number = rng.randrange(1, 5)
209
+ episode = rng.randrange(1, 60)
210
+ return {
211
+ "t": ("TITLE", title),
212
+ "a": ("TITLE", alias or title),
213
+ "td": ("DISCARD", title), # a path component repeating the title: no new evidence
214
+ "x": ("EPISODE_TITLE", rng.choice(SUBTITLE[lang])),
215
+ "s": ("SEASON", rng.choice(SEASON_MARKS[lang]).format(
216
+ n=season_number, p=rng.randrange(1, 3), ord=rng.choice(ORDINALS[lang]))),
217
+ "fm": ("SEASON", FINAL_MARK[lang]),
218
+ "sc": ("SEASON", f"S{season_number:02d}"),
219
+ "tc": ("SEASON", f"T{season_number:02d}"),
220
+ "sbig": ("SEASON", f"Season {rng.randrange(10, 24)}"),
221
+ "sw": ("SEASON", rng.choice(["Season number {n}", "Season {n}"]).format(n=season_number)),
222
+ "n2": ("EPISODE", f"{episode:02d}"),
223
+ "n3": ("EPISODE", f"{episode}"),
224
+ "epp": ("EPISODE", f"EP{episode:02d}"),
225
+ "e": ("EPISODE", f"{episode:02d}"),
226
+ "ec": ("EPISODE", f"E{episode:02d}"),
227
+ "e3": ("EPISODE", f"{episode:03d}"),
228
+ "e4": ("EPISODE", f"{episode * 100:04d}"),
229
+ "ej": ("EPISODE", f"第{episode:02d}话"),
230
+ "epw": ("EPISODE", f"Episode {episode}"),
231
+ "chp": ("EPISODE", f"Chapter {episode:02d}"),
232
+ "y": ("YEAR", str(rng.randrange(1985, 2026))),
233
+ "l": ("LIST", ",".join(f"{x:02d}" for x in sorted(rng.sample(range(1, 24), 3)))),
234
+ "r": ("RANGE", f"{rng.randrange(1, 12):02d}-{rng.randrange(13, 24):02d}"),
235
+ "rwide": ("RANGE", f"{rng.randrange(1, 5):02d} ~ {rng.randrange(20, 26):02d}"),
236
+ "R": ("RESOLUTION", rng.choice(RESOLUTIONS)),
237
+ "D": ("DIMENSIONS", rng.choice(DIMENSIONS)),
238
+ "g": ("GROUP", rng.choice(GROUPS)),
239
+ "d1": ("DISCARD", rng.choice(JUNK)),
240
+ "d2": ("DISCARD", rng.choice(JUNK)),
241
+ "tk": ("DISCARD", f"{rng.randrange(16 ** 7, 16 ** 8):x}"),
242
+ "vt": ("DISCARD", f"v{rng.randrange(2, 4)}"),
243
+ }
244
+
245
+
246
+ def render(atoms, parts, extension):
247
+ query, spans = "", []
248
+ for part in parts:
249
+ if isinstance(part, str):
250
+ if not LITERAL_OK.match(part):
251
+ raise AssertionError(f"literal carries word characters: {part!r}")
252
+ query += part
253
+ continue
254
+ kind, text = atoms[part[1]] if part[0] == "a" else ("DISCARD", part[1])
255
+ spans.append({"kind": kind, "text": text, "start": len(query),
256
+ "end": len(query) + len(text)})
257
+ query += text
258
+ return query + extension, spans
259
+
260
+
261
+ def finalize(query, spans, layout, lang, family, used_queries, quarantine):
262
+ """Contract-validate one rendered row, or quarantine it with the reason."""
263
+ if len(query) > MAX_CHARS or query in used_queries:
264
+ quarantine["collision-or-too-long"] += 1
265
+ return None
266
+ derived, issues = contract_record(query, spans, check_debris=True)
267
+ blocking = [i for i in issues if i not in TOLERATED]
268
+ if blocking or (derived is None and not layout.startswith("z")):
269
+ quarantine[",".join(blocking) or "no-record"] += 1
270
+ return None
271
+ used_queries.add(query)
272
+ return {"query": query, "spans": spans, "record": derived,
273
+ "tolerated": sorted(set(issues)), "layout": layout, "language": lang,
274
+ "family": family, "script": "cjk" if lang in ("ja", "zh") else "latin-cyrillic"}
275
+
276
+
277
+ JOINERS = (" ", " - ", "_", " | ", "-", "(", ")", " ")
278
+ PATH_ROOTS = ("Archive", "Collection", "Library", "Shelf", "Videos")
279
+ # A gap the boundary view may rewrite: separators only. Brackets are excluded
280
+ # because a span's `[` is structure, not spacing - removing it manufactures text
281
+ # like `Title1080p]`, which no real filename contains and which the contract has no
282
+ # way to reject, since unclaimed punctuation is debris-free by definition.
283
+ SEPARATOR_GAP = re.compile(r"[\s._-]+")
284
+ TIGHT_JOINERS = ("", ".", "|", "-", "_")
285
+ # Round 5's marker-role vocabulary. Deliberately disjoint from every marker word
286
+ # that appears in the held-out lexical split: `Episode`, `Chapter` and
287
+ # `Season number` must stay unseen, or the lexical axis would measure memory of
288
+ # the probe's own words instead of transfer. `test_marker_vocabulary_is_disjoint
289
+ # _from_the_heldout_lexical_split` enforces that, because a comment cannot.
290
+ EPISODE_MARKERS = ("Ep", "Eps", "Episodio", "Эпизод", "Bölüm")
291
+ EPISODE_FORMS = ("{m} {n}", "{m}{n}", "{n} {m}")
292
+
293
+
294
+ def _single_number(text):
295
+ """The span's number, only if the text contains exactly one digit group.
296
+
297
+ The contract's EPISODE rule requires one number, and a two-number fragment
298
+ ("05-07") is a RANGE the augmentation must not rewrite into a fake episode.
299
+ """
300
+ numbers = re.findall(r"\d+", text)
301
+ return numbers[0] if len(numbers) == 1 else None
302
+
303
+
304
+ def augment_markers(row, rng, attempts=4):
305
+ """Attach a word marker to numeric evidence, keeping the derived record fixed.
306
+
307
+ Training layouts express episodes as bare numbers (`05`, `E05`, `第05话`) and
308
+ seasons through `SEASON_MARKS`, so nothing in the corpus ever tells the model
309
+ that a *word* belongs to a number's span. That missing supervision is the
310
+ round-4 explanation for the lexical collapse: faced with `Season 4 Episode 21`
311
+ the model has no reason to prefer EPISODE over EPISODE_TITLE.
312
+
313
+ Like the value and path views, gold is re-derived by the contract and the view
314
+ is refused unless the record is byte-identical: the marker must be extra text
315
+ inside an already-typed span, never a re-labelled value.
316
+ """
317
+ if row["record"] is None:
318
+ return None
319
+ ordered = sorted(row["spans"], key=lambda span: span["start"])
320
+ eligible = []
321
+ for index, span in enumerate(ordered):
322
+ if span["kind"] not in ("EPISODE", "SEASON"):
323
+ continue
324
+ text = row["query"][span["start"]:span["end"]]
325
+ number = _single_number(text)
326
+ if number is None or len(number) > 4:
327
+ continue
328
+ eligible.append((index, span, text, number))
329
+ if not eligible:
330
+ return None
331
+ for _ in range(attempts):
332
+ chosen = [item for item in eligible if rng.random() < 0.7] or [rng.choice(eligible)]
333
+ replacements = {}
334
+ for index, span, text, number in chosen:
335
+ if span["kind"] == "SEASON":
336
+ # Re-render with another mark from the same language's table: the
337
+ # number and its value are unchanged, only which word carries it.
338
+ marks = [mark for mark in SEASON_MARKS[row["language"]]
339
+ if mark.format(n=number, ord=number, p=number) != text]
340
+ if not marks:
341
+ continue
342
+ value = int(number)
343
+ replacements[index] = rng.choice(marks).format(
344
+ n=value, p=min(value, 3), ord=ORDINALS[row["language"]][min(value, 4) - 1])
345
+ continue
346
+ marker = rng.choice(EPISODE_MARKERS)
347
+ replacements[index] = rng.choice(EPISODE_FORMS).format(m=marker, n=number)
348
+ if not replacements:
349
+ continue
350
+ query, spans = _replace_span_text(row, replacements)
351
+ if len(query) > MAX_CHARS:
352
+ continue
353
+ derived, issues = contract_record(query, spans, check_debris=True)
354
+ if derived != row["record"] or [i for i in issues if i not in TOLERATED]:
355
+ continue
356
+ return {**row, "query": query, "spans": spans, "record": derived,
357
+ "tolerated": sorted(set(issues)), "augmented": True,
358
+ "augmented_axes": sorted(set(row.get("augmented_axes", [])) | {"marker"})}
359
+ return None
360
+
361
+
362
+ def augment_boundary(row, rng, attempts=6):
363
+ """Tighten the separator between two adjacent atoms, brackets untouched.
364
+
365
+ Every layout in the corpus separates atoms with visible whitespace, so the
366
+ model can treat "there is always a gap" as a free signal; the held-out layouts
367
+ use `.`, `|`, `_` and bare adjacency instead. Round 3's error analysis put the
368
+ residual failure on boundary cuts rather than vocabulary, so this view makes the
369
+ cut itself the task while keeping the atom types and the record fixed.
370
+
371
+ Only gaps made purely of separator characters are eligible, and the replacement
372
+ is another separator, never the empty-and-bracketless deletion of structure:
373
+ deleting `" ["` would yield `Title1080p]` - text no real filename contains. The
374
+ contract cannot catch that (unclaimed punctuation is debris-free by design), so
375
+ the surface form is constrained here instead, and gold equality is still checked.
376
+ """
377
+ if row["record"] is None:
378
+ return None
379
+ ordered = sorted(row["spans"], key=lambda span: span["start"])
380
+ gaps = []
381
+ for index in range(len(ordered) - 1):
382
+ gap = row["query"][ordered[index]["end"]:ordered[index + 1]["start"]]
383
+ if SEPARATOR_GAP.fullmatch(gap):
384
+ gaps.append((index, gap))
385
+ if not gaps:
386
+ return None
387
+ for _ in range(attempts):
388
+ index, gap = rng.choice(gaps)
389
+ left, right = ordered[index], ordered[index + 1]
390
+ if left["kind"] == right["kind"]:
391
+ continue
392
+ replacement = rng.choice(TIGHT_JOINERS)
393
+ if replacement == gap:
394
+ continue
395
+ start = left["end"]
396
+ query = row["query"][:start] + replacement + row["query"][start + len(gap):]
397
+ shift = len(replacement) - len(gap)
398
+ spans = [dict(span) if position <= index else
399
+ {**span, "start": span["start"] + shift, "end": span["end"] + shift}
400
+ for position, span in enumerate(ordered)]
401
+ if len(query) > MAX_CHARS:
402
+ continue
403
+ derived, issues = contract_record(query, spans, check_debris=True)
404
+ if derived != row["record"] or [i for i in issues if i not in TOLERATED]:
405
+ continue
406
+ return {**row, "query": query, "spans": spans, "record": derived,
407
+ "tolerated": sorted(set(issues)), "augmented": True,
408
+ "augmented_axes": sorted(set(row.get("augmented_axes", [])) | {"boundary"})}
409
+ return None
410
+
411
+
412
+ def augment(row, rng, attempts=4):
413
+ """The same atoms in a different arrangement, gold re-derived by the contract.
414
+
415
+ Round 1 measured the opposite of the obvious hypothesis: 19 hand-written
416
+ layout families fit their own distribution to 83.6 % and scored 0.4 % on
417
+ unseen arrangements, *worse* than 3 layouts. Adding formats teaches the model
418
+ to look up a format. Breaking position inside every training row instead makes
419
+ the lookup impossible, so the only signal left is local: digits, brackets,
420
+ known group names, resolution tokens.
421
+
422
+ Identity fragments keep their relative order. `record` joins title fragments
423
+ in file order, so a blind shuffle would change the gold title and the
424
+ augmentation would no longer be the same row in a different costume.
425
+
426
+ Returns None when a shuffle lands on text the contract cannot represent -
427
+ two adjacent numeric spans, say - rather than shipping a row whose gold is a
428
+ guess.
429
+ """
430
+ original = [(span["kind"], span["text"]) for span in
431
+ sorted(row["spans"], key=lambda s: s["start"])]
432
+ extension = row["query"][row["query"].rindex("."):]
433
+ for _ in range(attempts):
434
+ parts = list(original)
435
+ rng.shuffle(parts)
436
+ slots = [i for i, part in enumerate(parts) if part[0] in ("TITLE", "EPISODE_TITLE")]
437
+ identity = [part for part in original if part[0] in ("TITLE", "EPISODE_TITLE")]
438
+ for slot, part in zip(slots, identity):
439
+ parts[slot] = part
440
+ query, spans = "", []
441
+ for index, (kind, text) in enumerate(parts):
442
+ if index:
443
+ query += rng.choice(JOINERS)
444
+ spans.append({"kind": kind, "text": text, "start": len(query),
445
+ "end": len(query) + len(text)})
446
+ query += text
447
+ query += extension
448
+ if len(query) > MAX_CHARS:
449
+ continue
450
+ derived, issues = contract_record(query, spans, check_debris=True)
451
+ if [i for i in issues if i not in TOLERATED] or derived != row["record"]:
452
+ continue
453
+ return {**row, "query": query, "spans": spans, "record": derived,
454
+ "tolerated": sorted(set(issues)), "augmented": True}
455
+ return None
456
+
457
+
458
+ def _replace_span_text(row, replacements):
459
+ """Rebuild a row after replacing span text, retaining every literal byte.
460
+
461
+ `replacements` is keyed by the span's index in start order. Rebuilding rather
462
+ than editing offsets in place keeps span alignment auditable and preserves the
463
+ parent layout's punctuation exactly.
464
+ """
465
+ old = sorted(row["spans"], key=lambda span: span["start"])
466
+ query, spans, cursor = "", [], 0
467
+ for index, span in enumerate(old):
468
+ query += row["query"][cursor:span["start"]]
469
+ text = replacements.get(index, row["query"][span["start"]:span["end"]])
470
+ start = len(query)
471
+ query += text
472
+ spans.append({"kind": span["kind"], "text": text,
473
+ "start": start, "end": len(query)})
474
+ cursor = span["end"]
475
+ query += row["query"][cursor:]
476
+ return query, spans
477
+
478
+
479
+ def augment_values(row, rng, attempts=4):
480
+ """Broaden numeric evidence without changing any non-numeric field.
481
+
482
+ Positional augmentation removes the layout cue that distinguished a four-digit
483
+ episode from YEAR/RESOLUTION, but round 2 never supplied four-digit EPISODE
484
+ evidence. This view changes digits *inside already typed spans*, re-derives the
485
+ record through the contract, and refuses any collateral field change.
486
+ """
487
+ if row["record"] is None:
488
+ return None
489
+ ordered = sorted(row["spans"], key=lambda span: span["start"])
490
+ eligible = [(index, span) for index, span in enumerate(ordered)
491
+ if span["kind"] in ("EPISODE", "SEASON")
492
+ and re.search(r"\d+", row["query"][span["start"]:span["end"]])]
493
+ if not eligible:
494
+ return None
495
+ for _ in range(attempts):
496
+ replacements = {}
497
+ chosen = [pair for pair in eligible if rng.random() < 0.7]
498
+ if not chosen:
499
+ chosen = [rng.choice(eligible)]
500
+ for index, span in chosen:
501
+ text = row["query"][span["start"]:span["end"]]
502
+ if span["kind"] == "EPISODE":
503
+ # Current held-out 4-digit episodes are multiples of 100. Train on
504
+ # the width, never their exact values.
505
+ number = rng.randrange(1001, 10000)
506
+ while number % 100 == 0:
507
+ number = rng.randrange(1001, 10000)
508
+ else:
509
+ # heldout-value's big seasons are 10-23.
510
+ number = rng.randrange(24, 31)
511
+ replacements[index] = re.sub(r"\d+", str(number), text, count=1)
512
+ query, spans = _replace_span_text(row, replacements)
513
+ if len(query) > MAX_CHARS:
514
+ continue
515
+ derived, issues = contract_record(query, spans, check_debris=True)
516
+ if derived is None or [issue for issue in issues if issue not in TOLERATED]:
517
+ continue
518
+ changed = {key for key in set(row["record"]) | set(derived)
519
+ if row["record"].get(key) != derived.get(key)}
520
+ if not changed or not changed <= {"season", "episodes"}:
521
+ continue
522
+ return {**row, "query": query, "spans": spans, "record": derived,
523
+ "tolerated": sorted(set(issues)),
524
+ "augmented": True,
525
+ "augmented_axes": sorted(set(row.get("augmented_axes", [])) | {"value"})}
526
+ return None
527
+
528
+
529
+ def augment_path(row, rng, attempts=4):
530
+ """Place the row in randomized path components and add duplicate evidence.
531
+
532
+ Roots are generic development vocabulary, deliberately not the held-out `TV`
533
+ or `Series` literals. A duplicate title is DISCARD, as contract §2 requires;
534
+ successful views must derive the parent record exactly.
535
+ """
536
+ if row["record"] is None:
537
+ return None
538
+ extension = row["query"][row["query"].rindex("."):]
539
+ original = [(span["kind"], row["query"][span["start"]:span["end"]])
540
+ for span in sorted(row["spans"], key=lambda span: span["start"])]
541
+ titles = [text for kind, text in original if kind == "TITLE"]
542
+ if not titles:
543
+ return None
544
+ for _ in range(attempts):
545
+ parts = [("DISCARD", rng.choice(PATH_ROOTS)), *original]
546
+ # A duplicate fragment is not a second title (§2). Put it in a varying
547
+ # component so the view teaches identity comparison rather than one layout.
548
+ parts.insert(rng.randrange(2, len(parts) + 1), ("DISCARD", rng.choice(titles)))
549
+ boundaries = len(parts) - 1
550
+ path_boundaries = set(rng.sample(range(boundaries), k=min(3, boundaries)))
551
+ query, spans = "", []
552
+ for index, (kind, text) in enumerate(parts):
553
+ if index:
554
+ query += rng.choice(("/", "\\")) if index - 1 in path_boundaries else rng.choice(JOINERS)
555
+ start = len(query)
556
+ query += text
557
+ spans.append({"kind": kind, "text": text, "start": start, "end": len(query)})
558
+ query += extension
559
+ if len(query) > MAX_CHARS:
560
+ continue
561
+ derived, issues = contract_record(query, spans, check_debris=True)
562
+ if derived != row["record"] or [issue for issue in issues if issue not in TOLERATED]:
563
+ continue
564
+ return {**row, "query": query, "spans": spans, "record": derived,
565
+ "tolerated": sorted(set(issues)),
566
+ "augmented": True,
567
+ "augmented_axes": sorted(set(row.get("augmented_axes", [])) | {"path"})}
568
+ return None
569
+
570
+
571
+ def augment_training(row, mode, position_rng, value_rng, path_rng, marker_rng=None,
572
+ boundary_rng=None):
573
+ """Compose the supported axes while keeping each axis's random stream separate.
574
+
575
+ Every mode starts from the positional shuffle, so the added axes are always
576
+ measured *on top of* the layout cue being broken. The extra RNGs exist for the
577
+ same reason the value and path ones do: sharing a stream with the position
578
+ shuffle would make the round-2 paired controls depend on which later axis the
579
+ arm happened to use.
580
+ """
581
+ view = augment(row, position_rng)
582
+ if view is None:
583
+ return None
584
+ axes = set(view.get("augmented_axes", [])) | {"position"}
585
+ view["augmented_axes"] = sorted(axes)
586
+ if mode in ("shuffle-values", "shuffle-rich"):
587
+ view = augment_values(view, value_rng)
588
+ if view is None:
589
+ return None
590
+ if mode in ("shuffle-paths", "shuffle-rich"):
591
+ view = augment_path(view, path_rng)
592
+ if view is None:
593
+ return None
594
+ if mode == "shuffle-markers":
595
+ if marker_rng is None:
596
+ raise ValueError("shuffle-markers needs its own rng")
597
+ view = augment_markers(view, marker_rng)
598
+ if view is None:
599
+ return None
600
+ if mode == "shuffle-boundary":
601
+ if boundary_rng is None:
602
+ raise ValueError("shuffle-boundary needs its own rng")
603
+ view = augment_boundary(view, boundary_rng)
604
+ if view is None:
605
+ return None
606
+ return view
607
+
608
+
609
+ def build_split(rng, names, count, families, used_queries, quarantine):
610
+ picked = []
611
+ for index in range(count):
612
+ lang, title, alias = families[index]
613
+ choices = [n for n in names if n not in NEEDS_ALIAS or alias]
614
+ if not choices:
615
+ quarantine["no-layout-for-family"] += 1
616
+ continue
617
+ layout = rng.choice(choices)
618
+ atoms = make_atoms(rng, lang, title, alias)
619
+ query, spans = render(atoms, LAYOUTS[layout][1], rng.choice(EXTENSIONS))
620
+ row = finalize(query, spans, layout, lang, title, used_queries, quarantine)
621
+ if row:
622
+ picked.append(row)
623
+ return picked
624
+
625
+
626
+ # Paired probes: one controlled change per row, everything else identical to the
627
+ # matching `probe-base` row. The pilot showed a bare held-out number cannot be
628
+ # attributed, so each probe answers exactly one question about which factor broke
629
+ # the model. `probe-*` rows are diagnostics, never claim metrics.
630
+ PROBE_BASE = "n1"
631
+ PROBE_LETTERS = ("ru", "ja", "zh") # the script probe needs a second script to exist
632
+
633
+
634
+ def build_probes(rng, families, used_queries, quarantine):
635
+ base, variants = [], {f"probe-{name}": [] for name in
636
+ ("layout", "script", "value", "episode-title")}
637
+ heldout = [n for n, (tag, parts) in LAYOUTS.items()
638
+ if tag.startswith("heldout-layout") and ("a", "a") not in parts]
639
+ base_parts = LAYOUTS[PROBE_BASE][1]
640
+ with_title = base_parts[:5] + [("a", "x"), "-"] + base_parts[5:]
641
+ for lang, title, alias in families:
642
+ if lang not in PROBE_LETTERS or not alias:
643
+ continue
644
+ atoms = make_atoms(rng, lang, title, alias)
645
+ query, spans = render(atoms, base_parts, ".mkv")
646
+ parent = finalize(query, spans, PROBE_BASE, lang, title, used_queries, quarantine)
647
+ if not parent:
648
+ continue
649
+ base.append(parent)
650
+ number = len(base)
651
+ cases = {"layout": (LAYOUTS[rng.choice(heldout)][1], None),
652
+ "script": (base_parts, {"t": ("TITLE", alias)}),
653
+ "value": (base_parts, {"e": ("EPISODE", f"{number * 100:04d}")}),
654
+ "episode-title": (with_title, None)}
655
+ for name, (parts, changed) in cases.items():
656
+ probe_atoms = dict(atoms)
657
+ probe_atoms.update(changed or {})
658
+ query, spans = render(probe_atoms, parts, ".mkv")
659
+ row = finalize(query, spans, f"probe-{name}", lang, title, used_queries, quarantine)
660
+ if row:
661
+ row["probe_parent"] = parent["query"]
662
+ variants[f"probe-{name}"].append(row)
663
+ return {"probe-base": base, **variants}
664
+
665
+
666
+ def generate():
667
+ if ROOT.exists():
668
+ raise SystemExit(f"refusing to overwrite the locked corpus at {ROOT}")
669
+ ROOT.mkdir(parents=True)
670
+ rng = random.Random(SEED)
671
+ families = []
672
+ seen_titles = set()
673
+ for lang in LANGS:
674
+ words = WORDS[lang]
675
+ for a, b, c in [(x, y, z) for x in words for y in words for z in words
676
+ if x != y and y != z]:
677
+ title = f"{a} {b} {c}"
678
+ if title in seen_titles:
679
+ continue # an identical string in two languages would leak families
680
+ seen_titles.add(title)
681
+ table = dict(zip(words, ALIAS.get(lang, [])))
682
+ families.append((lang, title, " ".join(table.get(w, w) for w in title.split(" "))
683
+ if lang in ALIAS else None))
684
+ rng.shuffle(families)
685
+ spec = [("train-narrow", ["narrow", "narrow-neg"], 1600),
686
+ ("train-extra", ["broad", "broad-neg"], 3200),
687
+ ("validation", ["narrow", "broad", "narrow-neg", "broad-neg"], 240),
688
+ ("easy", ["narrow", "broad", "narrow-neg", "broad-neg"], 240),
689
+ ("heldout-layout", ["heldout-layout-a", "heldout-layout-b"], 240),
690
+ ("heldout-lexical", ["heldout-lexical"], 160),
691
+ ("heldout-value", ["heldout-value"], 160),
692
+ ("heldout-negative", ["heldout-neg"], 120)]
693
+ total = sum(count for _, _, count in spec) + 600 # 600 more families for probes
694
+ if len(families) < total:
695
+ raise SystemExit(f"only {len(families)} disjoint title families for {total} rows")
696
+ manifest = {"seed": SEED, "max_chars": MAX_CHARS, "schema": "contract v2.1",
697
+ "labels_from": "authored layout atoms, derived by fp.normalize.record",
698
+ "claim": "format generalization on controlled data, not real-world accuracy",
699
+ "arms": {k: {"layout_tags": v, "train_splits": ARM_SPLITS[k]}
700
+ for k, v in ARM_TAGS.items()},
701
+ "layouts": {name: {"tag": tag, "atoms": sorted({p[1] for p in parts
702
+ if not isinstance(p, str)})}
703
+ for name, (tag, parts) in LAYOUTS.items()},
704
+ "splits": {}, "quarantine": {}}
705
+ cursor, used, quarantine = 0, set(), Counter()
706
+
707
+ def emit(split, rows):
708
+ path = ROOT / f"{split}.jsonl"
709
+ path.write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows))
710
+ manifest["splits"][split] = {
711
+ "n": len(rows), "sha256": digest(path),
712
+ "layouts": sorted({r["layout"] for r in rows}),
713
+ "languages": dict(Counter(r["language"] for r in rows)),
714
+ "fields": dict(Counter(k for r in rows for k in (r["record"] or {}))),
715
+ "abstentions": sum(1 for r in rows if r["record"] is None),
716
+ "max_chars": max(len(r["query"]) for r in rows)}
717
+ for name in manifest["splits"][split]["layouts"]:
718
+ if split.startswith("train") and LAYOUTS[name][0].startswith("heldout"):
719
+ raise AssertionError(f"held-out layout {name} leaked into {split}")
720
+
721
+ for split, tags, count in spec:
722
+ names = [n for n, (tag, _) in LAYOUTS.items() if tag in tags]
723
+ subset = families[cursor:cursor + count]
724
+ cursor += count
725
+ rows = build_split(rng, names, count, subset, used, quarantine)
726
+ assert rows, f"{split} produced nothing"
727
+ emit(split, rows)
728
+ # Probes get their own families, so a probe row never shares a title with a
729
+ # scored split; the pairing is inside the probe set, not across splits.
730
+ probe_subset = families[cursor:cursor + 600]
731
+ for split, rows in build_probes(rng, probe_subset, used, quarantine).items():
732
+ emit(split, rows)
733
+ manifest["quarantine"] = dict(quarantine)
734
+ (ROOT / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
735
+ print(json.dumps({k: v["n"] for k, v in manifest["splits"].items()} |
736
+ {"quarantine": dict(quarantine)}, ensure_ascii=False, indent=2))
737
+
738
+
739
+ def rows(split):
740
+ manifest = json.loads((ROOT / "manifest.json").read_text())
741
+ path = ROOT / f"{split}.jsonl"
742
+ data = [json.loads(line) for line in path.read_text().splitlines()]
743
+ assert digest(path) == manifest["splits"][split]["sha256"], f"{split}: hash mismatch"
744
+ assert len(data) == manifest["splits"][split]["n"], f"{split}: row count changed"
745
+ return data
746
+
747
+
748
+ def verify():
749
+ manifest = json.loads((ROOT / "manifest.json").read_text())
750
+ problems, queries, families = [], set(), {}
751
+ for split in manifest["splits"]:
752
+ group = "probes" if split.startswith("probe-") else split
753
+ for row in rows(split):
754
+ queries.add(row["query"])
755
+ owner = families.setdefault(row["family"], group)
756
+ if owner != group:
757
+ problems.append(f"{row['family']}: family in {group} and {owner}")
758
+ derived, issues = contract_record(row["query"], row["spans"], check_debris=True)
759
+ if derived != row["record"] or sorted(set(issues)) != row["tolerated"]:
760
+ problems.append(f"{row['query'][:40]}: gold not reproducible {issues}")
761
+ if len(row["query"]) > manifest["max_chars"]:
762
+ problems.append(f"{row['query'][:40]}: exceeds locked length")
763
+ print(json.dumps({"rows": len(queries), "families": len(families),
764
+ "problem_count": len(problems), "problems": problems[:20]},
765
+ ensure_ascii=False, indent=2))
766
+ return 1 if problems else 0
767
+
768
+
769
+ def main(argv=None):
770
+ parser = argparse.ArgumentParser(description=__doc__)
771
+ parser.add_argument("--verify", action="store_true")
772
+ args = parser.parse_args(argv)
773
+ return verify() if args.verify else (generate(), 0)[1]
774
+
775
+
776
+ if __name__ == "__main__":
777
+ raise SystemExit(main())
app/fp/normalize.py ADDED
@@ -0,0 +1,544 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Contract v2 normalizer: evidence spans -> emitted record, deterministically.
2
+
3
+ Implements docs/LABELING-CONTRACT.md sections 3-7. This is the only permitted
4
+ path from labels to an emitted record: annotators mark spans, this code derives
5
+ the record. Hand-editing an output string is a contract violation, so the
6
+ derivation has to be inspectable and testable on its own.
7
+
8
+ Span dicts: `{'kind', 'start', 'end'}` plus optional `text` (must match the
9
+ query bytes), `options` and `value`. Kinds: TITLE, EPISODE_TITLE, GROUP, YEAR, SEASON,
10
+ EPISODE, RANGE, LIST, RESOLUTION, DIMENSIONS, DISCARD. `options` carries
11
+ label-side decisions the contract refuses to guess from bytes: `is_title`,
12
+ `fold_case`, `items`. `value` carries a normalized season number (None means
13
+ an explicit-but-unbounded ordinal, which is omitted, never 0).
14
+
15
+ `covered` may list extra [start, end) ranges (for example a path component
16
+ carrying no field) so that the debris check is a fact about the label, not a
17
+ guess about punctuation. Debris checking is opt-in because real corpora are
18
+ partially labeled; a full-label row should turn it on.
19
+ """
20
+
21
+ import re
22
+ import unicodedata
23
+
24
+ # A group name is copied verbatim from one bracket, so a collab credit such as
25
+ # `ACB字幕组&天空字幕组` stays one string; two separate group spans conflict.
26
+ MULTI_GROUP = re.compile(r"\]\s*\[")
27
+ EXTENSION = re.compile(r"\.[A-Za-z0-9]{1,8}$")
28
+
29
+ # These punctuation marks are used as structural delimiters by the controlled
30
+ # format suites. They are not title characters at a predicted span edge; the
31
+ # gold derivation still uses the original spans and never calls this trimmer.
32
+ # The Unicode members were added after the consumed fresh-6 audit exposed
33
+ # full-width/vertical delimiters bleeding into TITLE, SEASON, and EPISODE spans,
34
+ # then extended for the fresh-7 delimiter inventory *before* fresh-7 was
35
+ # generated, so no suite can be accused of picking delimiters after seeing the
36
+ # trimmer. The list is duplicated verbatim in goport/normalize.go; both sides
37
+ # are pinned by tests. ASCII "+" is deliberately absent: it occurs in real
38
+ # titles ("C++") and would corrupt evidence to trim it.
39
+ STRUCTURAL_EDGE_CHARS = frozenset(
40
+ "#<>{}@|/∥|⁘❖【】『』⟦⟧‖‣⁓⧸⁄∕⋯»〔〕❪❫⦅⦆〘〙〖〗«》"
41
+ )
42
+
43
+ # The roster above is only the manual half of the edge test. Any non-ASCII rune in the
44
+ # dash, bracket or punctuation categories is an edge rune too, even when the roster
45
+ # predates it: that is what stops the set from measuring which delimiters happened to be
46
+ # enumerated before a suite shipped. ASCII runes are never widened this way -- ASCII
47
+ # punctuation legitimately terminates role text ("Title...", "(Recut)", "Title '05'"), so
48
+ # ASCII stays roster-only.
49
+ _EDGE_CATEGORIES = frozenset({"Pd", "Po", "Ps", "Pe", "Pi", "Pf", "Pc"})
50
+
51
+
52
+ def is_structural_edge(ch: str) -> bool:
53
+ """Whether `ch` may be trimmed from a *predicted* span edge. Gold spans never see this."""
54
+ if ord(ch) < 0x80:
55
+ return ch in STRUCTURAL_EDGE_CHARS
56
+ return ch in STRUCTURAL_EDGE_CHARS or unicodedata.category(ch) in _EDGE_CATEGORIES
57
+
58
+ PATH_HASH_GROUP = re.compile(
59
+ r"(?i)(?:[- ](?:subs?|raws?|team|works?|encode|unit|signal|depot|delta|plow|"
60
+ r"cellar|marrow|nest|harbor|lantern|rip|release|group)|"
61
+ r"(?:subs|raws|team|works|unit|signal|depot|delta|plow|cellar|marrow|nest|"
62
+ r"harbor|lantern))$"
63
+ )
64
+
65
+ # Contract §10: sidecars and documents are not media, even with a perfect
66
+ # title. Containers (.rar/.zip/.7z/.torrent) are NOT listed: §10 parses them.
67
+ NON_MEDIA_EXTENSIONS = frozenset({
68
+ "srt", "ass", "ssa", "vtt", "sub", "idx", "sup", "smi", # subtitles
69
+ "jpg", "jpeg", "png", "gif", "webp", "bmp", "tbn", # images
70
+ "nfo", "txt", "pdf", "epub", "docx", "doc", "md", "html", "htm", # documents
71
+ "xml", "json", "log",
72
+ "md5", "sfv", "sha1", "sha256", "par2", # checksums
73
+ "gz", "tar", "bz2", "xz", # non-media archives
74
+ })
75
+
76
+
77
+ def non_media_extension(query):
78
+ """The final dot-suffix, lower-cased, when it names a non-media sidecar;
79
+ otherwise None. Position-anchored like `EXTENSION`: only the true suffix
80
+ counts, a `.srt` in the middle of a path is not a decision here."""
81
+ match = EXTENSION.search(query)
82
+ if not match:
83
+ return None
84
+ ext = match.group(0)[1:].lower()
85
+ return ext if ext in NON_MEDIA_EXTENSIONS else None
86
+
87
+
88
+ def leading_hash_stem(query):
89
+ """Whether a query has the titleless CRC-prefixed release-stem shape.
90
+
91
+ This is intentionally narrower than ``query.startswith('[')``: the stem
92
+ must have a hex token, two dash-separated fields, and a closed resolution
93
+ class at the end. It is a decision-layer rule for rows whose only apparent
94
+ title is a group-shaped token between a checksum and codec junk; ordinary
95
+ bracket-prefixed titles and two-field releases remain untouched.
96
+ """
97
+ if not re.match(r"^\[[0-9a-fA-F]{7,12}\]\s+", query):
98
+ return False
99
+ rest = query[query.find("]") + 1:].lstrip()
100
+ fields = rest.split(" - ")
101
+ if len(fields) != 3 or not all(fields[:2]):
102
+ return False
103
+ tail = fields[2]
104
+ return bool(re.fullmatch(r"(?:480p|576p|720p|1080p|1080i|2160p)\.[A-Za-z0-9]{1,8}",
105
+ tail, flags=re.IGNORECASE))
106
+
107
+
108
+ UNICODE_SPACE = re.compile("[   -    ]")
109
+ DIGITS = re.compile(r"\d+(?:\.\d+)*")
110
+ YEAR_LIKE = re.compile(r"\b\d{4}\b")
111
+ OPEN_BRACKETS = "([{「『〈《【“「"
112
+ CLOSE_BRACKETS = ")]}」』〉》】”」"
113
+ BRACKETS = OPEN_BRACKETS + CLOSE_BRACKETS
114
+ # Brackets are deliberately NOT edge-stripped: an unmatched bracket is either
115
+ # lexical title content or a label error, and silently deleting one side of a
116
+ # pair corrupts evidence. Only balanced pairs are removed (see normalize_text).
117
+ STRIP_CHARS = " \t\r\n-–—~~·•::/\\."
118
+
119
+
120
+ def _balanced(text):
121
+ return all(text.count(open_) >= text.count(close)
122
+ for open_, close in zip(OPEN_BRACKETS, CLOSE_BRACKETS))
123
+
124
+
125
+ def _strip_bracket_pairs(text):
126
+ """Contract 3: brackets are removed only as balanced pairs enclosing the
127
+ whole fragment. `ポコニャン(叮噹貓)` keeps its parentheses (they are inside
128
+ the title), `「title」` and `「title」` lose them (they delimit it)."""
129
+ while (len(text) >= 2 and text[0] in OPEN_BRACKETS and text[-1] in CLOSE_BRACKETS
130
+ and _balanced(text[1:-1])):
131
+ text = _fold(text[1:-1]).strip(STRIP_CHARS)
132
+ return text
133
+
134
+ SCHEMA_VALUES = {"480p", "576p", "720p", "1080p", "1080i", "2160p"}
135
+ # Contract 7: a bare height is only HD evidence. SD heights are shared with
136
+ # computer displays (640x480 VGA, 800x480) and non-square-pixel DVD captures,
137
+ # so they never establish a broadcast class.
138
+ HEIGHT_CLASS = {720: "720p", 1080: "1080p", 2160: "2160p"}
139
+ MIN_ASPECT, MAX_ASPECT = 1.7, 1.95 # widescreen video frames only
140
+ MAX_EPISODES = 64
141
+ YEAR_MIN, YEAR_MAX = 1870, 2099
142
+
143
+ # Contract 5 value resolution. Deliberately a *closed* vocabulary: if a season
144
+ # marker is not in here, the row reports `season-unparsable` instead of guessing.
145
+ # A model that emits the right span never has to know numbers, and this table is
146
+ # the auditable place where text becomes a value.
147
+ DIGITS = re.compile(r"\d+")
148
+ FINAL_WORDS = ("final", "última", "ultimas", "last", "fin", "おしまい", "最終", "最后", "最终",
149
+ "最後", "끝", "последн")
150
+ ORDINAL_WORDS = {
151
+ "first": 1, "second": 2, "third": 3, "fourth": 4, "fifth": 5, "sixth": 6,
152
+ "seventh": 7, "eighth": 8, "ninth": 9, "tenth": 10, "primeira": 1, "segunda": 2,
153
+ "terceira": 3, "quarta": 4, "quinta": 5, "primera": 1, "tercera": 3,
154
+ "premiere": 1, "deuxieme": 2, "deuxième": 2, "troisieme": 3, "troisième": 3,
155
+ "erste": 1, "zweite": 2, "dritte": 3, "primo": 1, "secundo": 2, "seconda": 2,
156
+ "prima": 1, "terzo": 3, "uma": 1, "dois": 2, "tres": 3, "três": 3, "cuarta": 4,
157
+ "quinta": 5, "sexta": 6, "fifth": 5,
158
+ "первый": 1, "второй": 2, "третий": 3, "четвёртый": 4, "пятый": 5, "шестой": 6,
159
+ "седьмой": 7, "восьмой": 8, "девятый": 9, "десятый": 10,
160
+ }
161
+ CJK_NUMERALS = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7,
162
+ "八": 8, "九": 9, "十": 10, "壹": 1, "貳": 2, "叁": 3}
163
+
164
+
165
+ def season_value(text):
166
+ """int, None (=explicit season with no number), or 'unparsable'.
167
+
168
+ Digits win over words: `2nd Season` and `第2期` are the same value. A
169
+ multi-number marker (`Season 2 of 12`, `S01E02` mistagged) is unparsable
170
+ rather than a pick-one, because either choice is a guess about the work.
171
+ """
172
+ folded = normalize_text(text, fold_case=True)
173
+ if any(word in folded for word in FINAL_WORDS):
174
+ return None
175
+ numbers = DIGITS.findall(folded)
176
+ if len(numbers) == 1:
177
+ value = int(numbers[0])
178
+ return value if 0 <= value <= 99 else "unparsable"
179
+ if len(numbers) > 1:
180
+ return "unparsable"
181
+ for word in folded.split(" "):
182
+ if word in ORDINAL_WORDS:
183
+ return ORDINAL_WORDS[word]
184
+ runs = [c for c in folded if c in CJK_NUMERALS]
185
+ if len(runs) == 1:
186
+ return CJK_NUMERALS[runs[0]]
187
+ return "unparsable"
188
+
189
+
190
+ def _fold(text):
191
+ """Contract 3: underscores and exotic whitespace become spaces, runs
192
+ collapse, nothing is transliterated. Dots are handled separately, because
193
+ whether one is a delimiter depends on its neighbours (see
194
+ `fold_dot_delimiters`)."""
195
+ text = UNICODE_SPACE.sub(" ", text)
196
+ text = text.replace("_", " ")
197
+ text = re.sub(r"[-–—]{2,}", "-", text)
198
+ return re.sub(r" {2,}", " ", text).strip()
199
+
200
+
201
+ ALPHA = re.compile(r"[^\W\d_]+\Z", re.UNICODE)
202
+ NUMERIC = re.compile(r"\d+\Z")
203
+
204
+
205
+ def _segment_kind(unit):
206
+ if not unit:
207
+ return "empty"
208
+ if ALPHA.match(unit):
209
+ return "word"
210
+ if NUMERIC.match(unit):
211
+ return "number"
212
+ return "mixed"
213
+
214
+
215
+ def fold_dot_delimiters(text):
216
+ """Contract 3.2, title fragments only, decided dot by dot inside each
217
+ whitespace token, where a segment is a *word* (letters of any script), a
218
+ *number* (digits only), *mixed* (letters and digits together: `1080p`,
219
+ `2a`, `S01`, `x264`) or *empty* (a doubled or trailing dot).
220
+
221
+ A dot splits when either neighbour is a word (`CITY.HUNTER` ->
222
+ `CITY HUNTER`, `Akane.1988` -> `Akane 1988`, `Movie.2.Innocence` ->
223
+ `Movie 2 Innocence`), when the right side is mixed (`Ghost.1080p` ->
224
+ `Ghost 1080p`: a technical tag is never title text, and a split that only
225
+ touches title-adjacent junk cannot lose evidence), or when a side is empty
226
+ (`Dr.`, `Ghost.` lose the delimiter dot). The one shape that is never cut
227
+ is `number . number` before any word has appeared in the token:
228
+ `1917.2019` and `2.43.121` survive as single tokens, because a run that
229
+ opens with digits reads as an encoded value and splitting it would be a
230
+ guess about which part is the title. Single letters are words, so
231
+ `A.K.A.` folds to `A K A`.
232
+
233
+ Consequence: a title that genuinely contains a digit-dot-digit sequence
234
+ must be labeled as two fragments. That is the honest cost of a rule an
235
+ annotator can apply without knowing the work."""
236
+ pieces = []
237
+ for token in text.split(" "):
238
+ segments = token.split(".")
239
+ if len(segments) == 1:
240
+ if token:
241
+ pieces.append(token)
242
+ continue
243
+ kinds = [_segment_kind(s) for s in segments]
244
+ cut, word_seen = [0], False
245
+ for index in range(1, len(segments)):
246
+ previous, current = kinds[index - 1], kinds[index]
247
+ touches_word = previous == "word" or current == "word"
248
+ if current in ("mixed", "empty") or touches_word:
249
+ cut.append(index) # a word or junk boundary: split here
250
+ elif previous == "number" and current == "number" and word_seen:
251
+ cut.append(index) # `Akane.1988.2020` splits; `1917.2019` does not
252
+ word_seen = word_seen or touches_word
253
+ for boundary, edge in zip(cut, cut[1:] + [len(segments)]):
254
+ unit = ".".join(segments[boundary:edge])
255
+ if unit:
256
+ pieces.append(unit)
257
+ text = " ".join(pieces)
258
+ return re.sub(r" {2,}", " ", text).strip()
259
+
260
+
261
+ def trim_structural_edges(query, spans):
262
+ """Trim structural delimiter runes from predicted span edges.
263
+
264
+ This is deliberately prediction-only. Gold labels are never rewritten:
265
+ a label span that owns `#`, `<`, `>`, `{`, `}`, `@`, or `|` remains
266
+ lossless. The trimmer handles a decoder that includes a neighboring
267
+ delimiter in a role span, and drops a span made only of delimiters.
268
+ """
269
+ out = []
270
+ changed = 0
271
+ for span in spans:
272
+ start, end = span["start"], span["end"]
273
+ first, last = start, end
274
+ while first < last and is_structural_edge(query[first]):
275
+ first += 1
276
+ while last > first and is_structural_edge(query[last - 1]):
277
+ last -= 1
278
+ if first != start or last != end:
279
+ changed += 1
280
+ if first < last:
281
+ out.append({**span, "start": first, "end": last})
282
+ return out, changed
283
+
284
+
285
+ def titleless_path_group(query, spans):
286
+ """Narrow prediction gate for ``(hash)/junk/Group/res`` stems.
287
+
288
+ The parenthesized hash, slash path, and group-like title suffix must all be
289
+ present, with no episode, season, year, or episode-title evidence. A
290
+ positive row with any additional title evidence is left untouched.
291
+ """
292
+ if not re.match(r"^\([0-9a-fA-F]{7,12}\)", query) or "/" not in query:
293
+ return False
294
+ titles = [span for span in spans if span["kind"] == "TITLE"]
295
+ evidence = [span for span in spans if span["kind"] in
296
+ ("EPISODE", "EPISODE_TITLE", "SEASON", "YEAR")]
297
+ if len(titles) != 1 or evidence:
298
+ return False
299
+ return bool(PATH_HASH_GROUP.search(query[titles[0]["start"]:titles[0]["end"]].strip()))
300
+
301
+
302
+ def normalize_text(text, is_title=False, fold_case=False, keep_edges=False):
303
+ """`keep_edges=True` is for an already-assembled title: an edge bracket or
304
+ hyphen there belongs to an adjacent fragment, so edge stripping is skipped
305
+ and only byte-internal rules (separators, whitespace, delimiter dots)
306
+ apply. Fragments must not use it - that is where delimiters are removed."""
307
+ text = _fold(text)
308
+ if not keep_edges:
309
+ text = text.strip(STRIP_CHARS)
310
+ text = _strip_bracket_pairs(text)
311
+ text = text.strip(STRIP_CHARS)
312
+ if fold_case:
313
+ text = text.lower()
314
+ if is_title:
315
+ text = fold_dot_delimiters(text)
316
+ return _fold(text)
317
+
318
+
319
+ def _range_endpoints(text):
320
+ """Return (None, (lo, hi), issues). Repeated season prefixes
321
+ (`S01E01-S01E03`) and embedded technical values (`[01 ~ 24][1080p]`,
322
+ `1280x720`) are removed first, so the endpoints are the only numbers left.
323
+ Endpoints are 1-3 digits: a 4-digit number is a year or a checksum, not an
324
+ episode index."""
325
+ stripped = re.sub(r"(?i)s\d+", " ", text)
326
+ stripped = re.sub(r"(?i)\d{3,5}\s*[x×]\s*\d{3,5}|\d{3,4}[pi]\b", " ", stripped)
327
+ nums = [int(n) for n in re.findall(r"\d{1,3}", stripped)]
328
+ if not nums:
329
+ return None, None, ["range-no-digits"]
330
+ if len(nums) > 2:
331
+ return None, None, ["range-too-complex"]
332
+ if len(nums) == 1:
333
+ return None, None, ["range-single-value"]
334
+ if not re.search(r"[-~–—至到]", text):
335
+ return None, None, ["range-no-connector"]
336
+ return None, (nums[0], nums[-1]), []
337
+
338
+
339
+ def expand_range(text, cap=MAX_EPISODES):
340
+ _, endpoints, issues = _range_endpoints(text)
341
+ if issues:
342
+ return [], issues
343
+ a, b = endpoints
344
+ if a == b:
345
+ issues.append("range-single-value")
346
+ lo, hi = min(a, b), max(a, b)
347
+ if hi - lo + 1 > cap:
348
+ return [], ["list-too-long"]
349
+ if hi - lo + 1 > 200:
350
+ issues.append("range-suspiciously-long")
351
+ return list(range(lo, hi + 1)), issues
352
+
353
+
354
+ def expand_list(span, text=None):
355
+ """Item values come from the label side when there is one: re-reading them
356
+ from bytes would reintroduce exactly the ambiguity the contract says to
357
+ quarantine. A *predicted* span has no label side, so for those the digits
358
+ inside the claimed boundary are read - the model's claim is the boundary.
359
+ """
360
+ values = span.get("options", {}).get("items")
361
+ if values is None:
362
+ values = re.findall(r"\d+", text if text is not None else span["text"])
363
+ values = sorted({int(v) for v in values})
364
+ if not values:
365
+ return [], ["list-empty"]
366
+ if len(values) > MAX_EPISODES:
367
+ return [], ["list-too-long"]
368
+ return values, []
369
+
370
+
371
+ def debris(query, claims):
372
+ """Unclaimed, non-punctuation text: what a full-label row cannot explain.
373
+
374
+ Only the container suffix at the very end of the query is excused, and it
375
+ is excused by *position*, not by appearance: a `.720p` left unclaimed in
376
+ the middle of a name is a missing decision and must be reported even
377
+ though it looks like an extension. Stripping dot-suffixes from the
378
+ leftovers instead silently forgives exactly the technical tags that strict
379
+ rows exist to catch."""
380
+ mask = bytearray(len(query))
381
+ suffix = EXTENSION.search(query) # anchored at `$`, so this is the final suffix
382
+ for start, end in list(claims) + ([(suffix.start(), suffix.end())] if suffix else []):
383
+ for i in range(max(0, min(start, len(query))), min(max(0, end), len(query))):
384
+ mask[i] = 1
385
+ text = "".join(ch for i, ch in enumerate(query) if not mask[i])
386
+ # Contract 3.3: a run of underscores or hyphens that only delimits fields is
387
+ # debris the pipeline removes, not text that needs a recorded decision. `_`
388
+ # counted as a word character here, which made every underscored layout look
389
+ # unexplainable.
390
+ text = text.replace("_", " ")
391
+ return [part for part in re.split(r"[^\w]+", text, flags=re.UNICODE) if part]
392
+
393
+
394
+ def record(query, spans, *, covered=None, check_debris=False):
395
+ """Derive the emitted record from labeled spans. Returns (record|None,
396
+ issues). Issues never silently change a value; conflicting or
397
+ unrepresentable evidence is reported so the row can be quarantined."""
398
+ issues = []
399
+ claims = [(s["start"], s["end"]) for s in spans] + list(covered or [])
400
+ title, episode_title, episodes = [], [], []
401
+ group = year = season = resolution = dimensions = None
402
+ for span in sorted(spans, key=lambda s: (s["start"], s["end"])):
403
+ kind = span["kind"]
404
+ text = span.get("text")
405
+ if text is None:
406
+ text = query[span["start"]:span["end"]]
407
+ elif query[span["start"]:span["end"]] != text:
408
+ issues.append("span-text-mismatch")
409
+ opts = span.get("options", {})
410
+ if kind in ("TITLE", "EPISODE_TITLE"):
411
+ # Which fragment is the work title and which is the episode title is
412
+ # the annotator's decision, recorded as a span kind. Position cannot
413
+ # decide it: a second TITLE fragment is usually the same work title
414
+ # in another script, while a second EPISODE_TITLE fragment is the
415
+ # next segment of one episode's name.
416
+ bucket = title if kind == "TITLE" else episode_title
417
+ value = normalize_text(text, is_title=True,
418
+ fold_case=opts.get("fold_case", False))
419
+ if value:
420
+ bucket.append(value)
421
+ else:
422
+ issues.append("empty-title-fragment")
423
+ elif kind == "GROUP":
424
+ value = normalize_text(text)
425
+ if group is not None:
426
+ issues.append("group-multiple-brackets")
427
+ elif MULTI_GROUP.search(value):
428
+ issues.append("group-multiple-names")
429
+ group = group or value
430
+ elif kind == "YEAR":
431
+ digits = YEAR_LIKE.findall(text)
432
+ if len(digits) != 1:
433
+ issues.append("year-ambiguous")
434
+ continue
435
+ value = int(digits[0])
436
+ if not YEAR_MIN <= value <= YEAR_MAX:
437
+ issues.append("year-out-of-range")
438
+ elif year is not None and year != value:
439
+ issues.append("year-conflict")
440
+ else:
441
+ year = value
442
+ elif kind == "SEASON":
443
+ if "value" in span:
444
+ value = span["value"] # annotator-supplied (contract 5, worksheet)
445
+ else:
446
+ value = season_value(text) # predicted span: text decides, not a head
447
+ if value == "unparsable":
448
+ issues.append("season-unparsable")
449
+ continue
450
+ if value is None:
451
+ issues.append("season-unspecified")
452
+ elif season is not None and season != value:
453
+ issues.append("season-conflict")
454
+ elif not 0 <= value <= 99:
455
+ issues.append("season-out-of-range")
456
+ else:
457
+ season = value
458
+ elif kind == "EPISODE":
459
+ nums = re.findall(r"\d+", text)
460
+ if len(nums) != 1:
461
+ issues.append("episode-ambiguous")
462
+ else:
463
+ episodes.append(int(nums[0]))
464
+ elif kind == "RANGE":
465
+ values, extra = expand_range(text)
466
+ issues += extra
467
+ episodes += values
468
+ elif kind == "LIST":
469
+ values, extra = expand_list(span, text)
470
+ issues += extra
471
+ episodes += values
472
+ elif kind == "RESOLUTION":
473
+ value = normalize_text(text, fold_case=True)
474
+ if value not in SCHEMA_VALUES:
475
+ issues.append("resolution-unknown-class")
476
+ elif resolution and resolution != value:
477
+ issues.append("resolution-conflict")
478
+ else:
479
+ resolution = value
480
+ elif kind == "DIMENSIONS":
481
+ match = re.search(r"(\d{3,5})\s*[x×]\s*(\d{3,5})", text, re.I)
482
+ if not match:
483
+ issues.append("dimensions-unreadable")
484
+ continue
485
+ width, height = int(match.group(1)), int(match.group(2))
486
+ value = HEIGHT_CLASS.get(height) if MIN_ASPECT <= width / height <= MAX_ASPECT else None
487
+ if value is None:
488
+ issues.append("dimensions-no-class")
489
+ elif resolution and resolution != value:
490
+ issues.append("resolution-conflict")
491
+ else:
492
+ resolution, dimensions = value, text
493
+ elif kind != "DISCARD":
494
+ issues.append("unhandled-kind:" + kind)
495
+
496
+ if check_debris:
497
+ left = debris(query, claims)
498
+ if left:
499
+ issues.append("unexplained-debris:" + "|".join(left[:6]))
500
+
501
+ out = {}
502
+ if title:
503
+ # join=True: an edge bracket here belongs to an adjacent fragment, so
504
+ # only byte-internal rules apply to the assembled title.
505
+ out["title"] = normalize_text(" ".join(title), is_title=True, keep_edges=True)
506
+ if episode_title:
507
+ out["episode_title"] = normalize_text(" ".join(episode_title), is_title=True,
508
+ keep_edges=True)
509
+ if year is not None:
510
+ out["year"] = year
511
+ if season is not None:
512
+ out["season"] = season
513
+ if episodes:
514
+ values = sorted(set(episodes))
515
+ if len(values) > MAX_EPISODES:
516
+ # Contract 9: never silently truncate; the row is quarantined.
517
+ issues.append("list-too-long")
518
+ out["episodes"] = values
519
+ if group:
520
+ out["release_group"] = group
521
+ if resolution:
522
+ out["resolution"] = resolution
523
+ # `required: title` means a title-less record is the same output as no call.
524
+ return (out if out.get("title") else None), issues
525
+
526
+
527
+ def gated_record(query, spans, **kwargs):
528
+ """`record` followed by the decision layer applied at prediction time.
529
+
530
+ A non-media suffix or the narrow CRC-prefixed titleless release-stem shape
531
+ forces abstention and reports why in the issues list.
532
+ """
533
+ ext = non_media_extension(query)
534
+ trimmed, trimmed_count = trim_structural_edges(query, spans)
535
+ out, issues = record(query, trimmed, **kwargs)
536
+ if trimmed_count:
537
+ issues = issues + [f"trimmed-structural-edges:{trimmed_count}"]
538
+ if out is not None and ext is not None:
539
+ return None, issues + [f"gated-non-media-extension:{ext}"]
540
+ if out is not None and leading_hash_stem(query):
541
+ return None, issues + ["gated-leading-hash-stem"]
542
+ if out is not None and titleless_path_group(query, trimmed):
543
+ return None, issues + ["gated-titleless-path-group"]
544
+ return out, issues
app/fp/parser2.py ADDED
@@ -0,0 +1,891 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train and score the production head design on the contract v2.1 corpus.
2
+
3
+ The architecture question this answers is narrow and specific: can a small
4
+ encoder emit *typed spans* over layouts it never saw, with every value resolved
5
+ by deterministic contract code? There is no value head here on purpose. The
6
+ pilot needed one, and its hard-split season accuracy was the only field that
7
+ survived pretraining; if this run reproduces field-level reliability without it,
8
+ the head was compensating for narrow data rather than doing necessary work.
9
+
10
+ Predictions are scored through `fp.normalize.record`, the same function that
11
+ derived the gold. A prediction is therefore a parse under the ratified contract,
12
+ not a string diff: abstention, ranges, lists and case folding are all evaluated
13
+ as the contract defines them, which is what makes these numbers comparable with
14
+ the real-data numbers we cannot produce yet.
15
+
16
+ Usage:
17
+ python -m fp.parser2 --arm B-broad --mode characters --seed 0 \
18
+ --out runs/parser-v2/broad-characters-0
19
+ """
20
+
21
+ import argparse
22
+ import importlib.metadata
23
+ import json
24
+ import math
25
+ import os
26
+ import random
27
+ import statistics
28
+ import sys
29
+ import time
30
+ from collections import Counter, defaultdict
31
+ from pathlib import Path
32
+
33
+ # Required by deterministic CUDA matrix multiplication. This has to be present
34
+ # before the first CUDA context is initialized; setting it here keeps remote runs
35
+ # reproducible without relying on one shell's environment.
36
+ os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
37
+
38
+ import torch
39
+ from torch import nn
40
+ from transformers import BertConfig, BertModel
41
+
42
+ from fp import corpus2, normalize
43
+ from fp.corpus2 import ARM_SPLITS, ROOT as CORPUS, augment, augment_training, digest, rows
44
+ from fp.corpus2 import KINDS as corpus_KINDS
45
+ from fp.normalize import record as contract_record
46
+
47
+ SNAP = Path(os.environ.get(
48
+ "ANIFILEBERT_SNAPSHOT",
49
+ "/home/lain/.cache/huggingface/hub/models--ModerRAS--AniFileBERT/snapshots/"
50
+ "d8ddb0b54dad4d65a60ab7eba5acb06a2a1fab02"))
51
+ KINDS = list(corpus_KINDS)
52
+ LABELS = ["O"] + [prefix + kind for kind in KINDS for prefix in ("B-", "I-")]
53
+ # A schedule is a named, frozen list, not the meaning of a flag. Round 4 shipped
54
+ # `balanced` as four views; growing that same name to six views in round 5 would
55
+ # have made `scripts/parser-v2-round4.sh` silently produce a different experiment
56
+ # under a published name, so the older list stays pinned under `balanced4`.
57
+ SCHEDULES = {"balanced4": ("none", "shuffle", "shuffle-values", "shuffle-paths"),
58
+ "balanced": ("none", "shuffle", "shuffle-values", "shuffle-paths",
59
+ "shuffle-markers", "shuffle-boundary"),
60
+ # Round 5b's dose control. Uniform rotation makes every view's share
61
+ # 1/|views|, so adding two views silently cut the value view from 3/12 to
62
+ # 2/12 -- which explains the probe-value drop without any interference
63
+ # hypothesis. This list restores the value share to 0.25 (C1's) while
64
+ # keeping the marker share at C4's 2/12, trading only boundary, the view
65
+ # with the weakest measured effect. A 12-slot list visited once per epoch
66
+ # makes each row's counts exact, not merely balanced within one.
67
+ "balanced-dose": ("none", "none", "shuffle", "shuffle",
68
+ "shuffle-values", "shuffle-values", "shuffle-values",
69
+ "shuffle-paths", "shuffle-paths",
70
+ "shuffle-markers", "shuffle-markers",
71
+ "shuffle-boundary")}
72
+ BALANCED_VIEWS = SCHEDULES["balanced"]
73
+ SELECTION_VIEWS = ("plain", "shuffle", "markers", "boundary")
74
+
75
+
76
+ def balanced_view(row_index, epoch, seed, views=BALANCED_VIEWS):
77
+ """Exact rotation over a named schedule.
78
+
79
+ Unlike random independent gates, this guarantees each row receives each view the
80
+ same number of times over a full schedule: three times per view in round 4's
81
+ twelve epochs and four views, twice per view in round 5's six. `seed` rotates the
82
+ starting cell without changing the balance.
83
+ """
84
+ return views[(row_index + epoch + seed) % len(views)]
85
+
86
+
87
+ def selection_views(validation_rows, seed, forbidden_queries):
88
+ """Fixed derived views of the validation split, for checkpoint selection.
89
+
90
+ Round 4 selected on seen-layout validation while the acceptance target is about
91
+ transformed views, so the choice now averages the same transformations it is
92
+ meant to be good at. Only validation rows are read. A derived view that lands on
93
+ any scored query is replaced by its parent: an augmented row that happened to
94
+ reproduce held-out text would leak the evaluation into selection through the same
95
+ back door that round 3 had to close for training rows.
96
+ """
97
+ streams = {"shuffle": (augment, random.Random(seed + 20260919)),
98
+ "markers": (corpus2.augment_markers, random.Random(seed + 20260920)),
99
+ "boundary": (corpus2.augment_boundary, random.Random(seed + 20260921))}
100
+ views = {"plain": list(validation_rows)}
101
+ counts = {name: {"accepted": 0, "fell_back": 0, "leakage_guarded": 0}
102
+ for name in streams}
103
+ for name, (transform, rng) in streams.items():
104
+ derived = []
105
+ for row in validation_rows:
106
+ candidate = transform(row, rng)
107
+ if candidate is None:
108
+ counts[name]["fell_back"] += 1
109
+ derived.append(row)
110
+ continue
111
+ if candidate["query"] in forbidden_queries:
112
+ counts[name]["leakage_guarded"] += 1
113
+ derived.append(row)
114
+ continue
115
+ counts[name]["accepted"] += 1
116
+ derived.append(candidate)
117
+ views[name] = derived
118
+ return views, counts
119
+ SCORE_SPLITS = ("validation", "easy", "heldout-layout", "heldout-lexical", "heldout-value",
120
+ "heldout-negative", "probe-base", "probe-layout", "probe-script",
121
+ "probe-value", "probe-episode-title")
122
+
123
+
124
+ def save_json(path, value):
125
+ Path(path).write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n")
126
+
127
+
128
+ def resolve_device(requested):
129
+ if requested == "auto":
130
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
131
+ if requested == "cuda" and not torch.cuda.is_available():
132
+ raise SystemExit("--device cuda requested, but torch.cuda.is_available() is false")
133
+ return torch.device(requested)
134
+
135
+
136
+ def on_device(batch, device):
137
+ return tuple(tensor.to(device, non_blocking=device.type == "cuda") for tensor in batch)
138
+
139
+
140
+ def cpu_state_dict(model):
141
+ """Portable checkpoint: never make a local rescore depend on CUDA existing."""
142
+ return {name: value.detach().cpu() for name, value in model.state_dict().items()}
143
+
144
+
145
+ class Extractor(nn.Module):
146
+ def __init__(self, mode, n_tags):
147
+ super().__init__()
148
+ self.mode, self.tokenizer = mode, None
149
+ if mode != "bytes":
150
+ sys.path.insert(0, str(SNAP))
151
+ from anifilebert.model import load_model
152
+ from anifilebert.tokenizer import load_tokenizer
153
+ self.tokenizer = load_tokenizer(str(SNAP))
154
+ if mode == "pretrained":
155
+ self.encoder = load_model(str(SNAP)).bert
156
+ else:
157
+ config = BertConfig.from_pretrained(str(SNAP), local_files_only=True)
158
+ self.encoder = BertModel(config, add_pooling_layer=False)
159
+ else:
160
+ config = BertConfig(vocab_size=257, hidden_size=256, num_hidden_layers=4,
161
+ num_attention_heads=8, intermediate_size=1024,
162
+ max_position_embeddings=512, pad_token_id=256)
163
+ self.encoder = BertModel(config, add_pooling_layer=False)
164
+ self.tags = nn.Linear(256, n_tags)
165
+
166
+ def encode(self, row):
167
+ """Tokens plus the [start, end) token window of every character.
168
+
169
+ Byte mode widens the window for multi-byte characters, so a boundary can
170
+ only ever fall between complete UTF-8 characters.
171
+ """
172
+ query = row["query"]
173
+ if self.mode == "bytes":
174
+ ids, starts, ends, pos = list(query.encode()), [], [], 0
175
+ for char in query:
176
+ starts.append(pos)
177
+ pos += len(char.encode())
178
+ ends.append(pos)
179
+ else:
180
+ tokens = self.tokenizer.convert_tokens_to_ids(self.tokenizer.tokenize(query))
181
+ ids = [self.tokenizer.cls_token_id] + tokens + [self.tokenizer.sep_token_id]
182
+ starts, ends = list(range(1, len(query) + 1)), list(range(2, len(query) + 2))
183
+ assert len(tokens) == len(query), (
184
+ "char-window pooling assumes one token per character; the tokenizer "
185
+ "changed. Refusing to train on misaligned labels.")
186
+ assert len(ids) <= self.encoder.config.max_position_embeddings, "No truncation"
187
+ labels = [0] * len(query)
188
+ for span in row["spans"]:
189
+ start, end = span["start"], span["end"]
190
+ labels[start] = LABELS.index("B-" + span["kind"])
191
+ for i in range(start + 1, end):
192
+ labels[i] = LABELS.index("I-" + span["kind"])
193
+ return ids, starts, ends, labels, row
194
+
195
+ def forward(self, batch):
196
+ ids, attention, starts, ends = batch
197
+ hidden = self.encoder(input_ids=ids, attention_mask=attention).last_hidden_state
198
+ cumulative = torch.cat((torch.zeros_like(hidden[:, :1]), hidden.cumsum(1)), 1)
199
+ a = starts.unsqueeze(-1).expand(-1, -1, hidden.shape[-1])
200
+ b = ends.unsqueeze(-1).expand_as(a)
201
+ width = (ends - starts).clamp_min(1).unsqueeze(-1)
202
+ chars = (cumulative.gather(1, b) - cumulative.gather(1, a)) / width
203
+ # Flatten before the tag layer: DynamicQuantizedLinear, which is what the
204
+ # INT8 export installs, only accepts 2-D input, so a rank-3 call would make
205
+ # the shipped artifact crash while the fp32 prototype scored fine.
206
+ tagged = self.tags(chars.reshape(-1, chars.shape[-1]))
207
+ return tagged.reshape(*chars.shape[:2], -1), chars
208
+
209
+
210
+ def collate(items, pad):
211
+ length = max(len(item[0]) for item in items)
212
+ chars = max(len(item[1]) for item in items)
213
+ ids = torch.full((len(items), length), pad, dtype=torch.long)
214
+ attention = torch.zeros_like(ids)
215
+ starts = torch.zeros((len(items), chars), dtype=torch.long)
216
+ ends, labels = starts.clone(), torch.full((len(items), chars), -100, dtype=torch.long)
217
+ for i, (tokens, a, b, y, _) in enumerate(items):
218
+ ids[i, :len(tokens)] = torch.tensor(tokens)
219
+ attention[i, :len(tokens)] = 1
220
+ starts[i, :len(a)], ends[i, :len(b)] = torch.tensor(a), torch.tensor(b)
221
+ labels[i, :len(y)] = torch.tensor(y)
222
+ return (ids, attention, starts, ends), labels
223
+
224
+
225
+ def legality_matrix():
226
+ """Zero where a BIO jump is legal, -inf where it is not.
227
+
228
+ This is the strict control for the empirical table below: a constraint says
229
+ which sequences are admissible and lets the emissions decide among them. The
230
+ empirical matrix additionally supplies a learned ordering prior, so its effect
231
+ must be measured separately rather than confused with BIO legality.
232
+ """
233
+ allow = torch.zeros(len(LABELS), len(LABELS))
234
+ for source, from_tag in enumerate(LABELS):
235
+ for target, to_tag in enumerate(LABELS):
236
+ if to_tag.startswith("I-") and from_tag not in (to_tag, "B-" + to_tag[2:]):
237
+ allow[source, target] = -torch.inf
238
+ return allow
239
+
240
+
241
+ def transitions(items):
242
+ """Empirical log-transition matrix over the tag sequence.
243
+
244
+ This answers a different question than `legality_matrix`: whether the
245
+ *distribution* of arrangements the model was trained on supplies a useful tag
246
+ order. It is not the default because it can trade transfer for fit; `--transition`
247
+ keeps that factor explicit.
248
+
249
+ Greedy per-position argmax is allowed to emit an `I-X` that opens a span, and
250
+ the legalizer silently throws those away. A transition table makes the model
251
+ answer for that: it costs one matrix, needs no retraining, and can be applied
252
+ to an existing checkpoint, which is why this is the cheapest hypothesis here.
253
+ """
254
+ counts = torch.zeros(len(LABELS), len(LABELS))
255
+ for item in items:
256
+ previous = LABELS.index("O")
257
+ for label in item[3]:
258
+ counts[previous, label] += 1
259
+ previous = label
260
+ counts[previous, LABELS.index("O")] += 1
261
+ probabilities = counts / counts.sum(1, keepdim=True).clamp_min(1)
262
+ return torch.log(probabilities + 1e-12)
263
+
264
+
265
+ def viterbi(logits, transition):
266
+ """Exact best tag sequence under a transition matrix. Legal in the sense the
267
+ matrix defines it: an impossible jump is -inf, not a silent drop.
268
+
269
+ The reduction is over the *source* axis. `scores` is indexed [from, to], so the
270
+ best predecessor of each target is `max(0)`; an earlier version of this function
271
+ took `max(1)`, which collapsed targets instead of sources and returned an all-`O`
272
+ path on every split - silently scoring 0 spans on rows the same checkpoint got
273
+ 311/311 right. The test `test_viterbi_returns_one_state_per_frame_and_repairs_an_orphan`
274
+ exists because a bug that makes a decoder *abstain* looks like a cautious model
275
+ in an aggregate table.
276
+ """
277
+ best = torch.full((len(LABELS),), -torch.inf)
278
+ best[LABELS.index("O")] = 0.0
279
+ back = []
280
+ for frame in logits:
281
+ scores = best.unsqueeze(1) + transition # [from, to]
282
+ back.append(scores.argmax(0)) # best predecessor for each target
283
+ best = frame + scores.max(0).values # score of the best path ending at each target
284
+ path = [int(best.argmax())]
285
+ for step in reversed(back):
286
+ path.append(int(step[path[-1]]))
287
+ path.reverse()
288
+ # One state per frame, not n + 1: the recursion's seed state is not a tag. An
289
+ # earlier version returned it, so every frame was scored with its neighbour's
290
+ # label - which in a span task shifts every boundary by one character.
291
+ return path[1:]
292
+
293
+
294
+ def viterbi_batch(logits, lengths, transition):
295
+ """Vectorized equivalent of `viterbi`, with a distinct end for every row."""
296
+ batch, frames, labels = logits.shape
297
+ lengths = torch.as_tensor(lengths, device=logits.device)
298
+ best = torch.full((batch, labels), -torch.inf, device=logits.device,
299
+ dtype=logits.dtype)
300
+ best[:, LABELS.index("O")] = 0.0
301
+ back = []
302
+ for index in range(frames):
303
+ scores = best.unsqueeze(2) + transition.unsqueeze(0)
304
+ predecessor = scores.argmax(1)
305
+ candidate = logits[:, index] + scores.max(1).values
306
+ active = (index < lengths).unsqueeze(1)
307
+ best = torch.where(active, candidate, best)
308
+ back.append(predecessor)
309
+ current = best.argmax(1)
310
+ paths = torch.zeros((batch, frames), dtype=torch.long, device=logits.device)
311
+ for index in reversed(range(frames)):
312
+ active = index < lengths
313
+ paths[:, index] = torch.where(active, current, paths[:, index])
314
+ predecessor = back[index].gather(1, current.unsqueeze(1)).squeeze(1)
315
+ current = torch.where(active, predecessor, current)
316
+ return [paths[row, :int(length)].tolist() for row, length in enumerate(lengths)]
317
+
318
+
319
+ def decode(tag_ids, limit):
320
+ """BIO to spans. An `I-` that does not continue its own kind ends the span, and
321
+ an orphan `I-` becomes nothing: the decoder never invents a boundary."""
322
+ spans, kind, begin = [], None, 0
323
+ for i, tag in enumerate([LABELS[t] for t in tag_ids[:limit]] + ["O"]):
324
+ if kind is not None and tag != "I-" + kind:
325
+ spans.append((kind, begin, i))
326
+ kind = None
327
+ if tag.startswith("B-"):
328
+ kind, begin = tag[2:], i
329
+ return spans
330
+
331
+
332
+ def predict_spans(query, tag_ids):
333
+ """Decoder output as contract spans, with no text and no value: the record is
334
+ whatever `fp.normalize` can derive from the boundaries alone."""
335
+ return [{"kind": kind, "start": start, "end": end}
336
+ for kind, start, end in decode(tag_ids, len(query))]
337
+
338
+
339
+ def tag_paths(logits, lengths, decode_mode="greedy", transition=None):
340
+ """Decode only real character frames, never another row's batch padding.
341
+
342
+ Viterbi backtracking starts at its final frame, so decoding the padded suffix
343
+ can change the state selected for the final real character. Greedy happened to
344
+ be insensitive because `predict_spans` truncates, but both decoders should be
345
+ explicitly batch-composition invariant.
346
+ """
347
+ if decode_mode == "greedy":
348
+ return [frame[:length].argmax(-1).tolist()
349
+ for frame, length in zip(logits, lengths)]
350
+ return viterbi_batch(logits, lengths, transition)
351
+
352
+
353
+ def label_weights(items):
354
+ """Inverse frequency, sqrt-damped, mean 1. DISCARD and TITLE dominate the
355
+ character budget, and without this the rare kinds are unlearnable.
356
+
357
+ Keys are label *ids*: returning them indexed by name silently produced all
358
+ zeros, which made the loss NaN and the exported artifact crash.
359
+ """
360
+ counts = Counter(label for item in items for label in item[3] if label >= 0)
361
+ total = sum(counts.values())
362
+ raw = {label_id: 1 / math.sqrt(count / total * len(LABELS))
363
+ for label_id, count in counts.items()}
364
+ mean = sum(raw[label] * counts[label] for label in raw) / total
365
+ return [raw.get(index, 0.0) / mean for index in range(len(LABELS))]
366
+
367
+
368
+ @torch.inference_mode()
369
+ def evaluate(model, encoded, batch_size=64, timed=False, decode_mode="greedy", transition=None):
370
+ model.eval()
371
+ device = next(model.parameters()).device
372
+ counts, bylang, bylayout, predictions, latencies = Counter(), {}, {}, [], []
373
+ per_kind = defaultdict(Counter)
374
+ start = time.perf_counter()
375
+ for offset in range(0, len(encoded), batch_size):
376
+ items = encoded[offset:offset + batch_size]
377
+ batch, _ = collate(items, model.encoder.config.pad_token_id)
378
+ batch = on_device(batch, device)
379
+ logits, _ = model(batch)
380
+ # Decoding is tiny and intentionally stays on CPU, where Viterbi's Python
381
+ # loop and the deterministic contract resolver already operate.
382
+ logits = logits.detach().cpu()
383
+ tags = tag_paths(logits, [len(item[1]) for item in items], decode_mode, transition)
384
+ for i, item in enumerate(items):
385
+ row = item[-1]
386
+ spans = predict_spans(row["query"], tags[i])
387
+ actual, issues = normalize.gated_record(row["query"], spans)
388
+ expected = row["record"]
389
+ exact = actual == expected
390
+ counts["n"] += 1
391
+ counts["exact"] += exact
392
+ counts["abstained"] += actual is None
393
+ counts["wrong_abstain"] += actual is None and expected is not None
394
+ counts["false_emit"] += actual is not None and expected is None
395
+ counts["issue_rows"] += bool(issues)
396
+ for span in row["spans"]:
397
+ per_kind[span["kind"]]["gold"] += 1
398
+ for span in spans:
399
+ per_kind[span["kind"]]["pred"] += 1
400
+ gold = {(s["kind"], s["start"], s["end"]) for s in row["spans"]}
401
+ pred = {(s["kind"], s["start"], s["end"]) for s in spans}
402
+ counts["span_tp"] += len(gold & pred)
403
+ counts["span_pred"], counts["span_gold"] = counts["span_pred"] + len(pred), counts["span_gold"] + len(gold)
404
+ for kind, _, _ in gold & pred:
405
+ per_kind[kind]["tp"] += 1
406
+ for key, value in (actual or {}).items():
407
+ counts["emitted_fields"] += 1
408
+ counts["wrong_emitted_fields"] += (expected or {}).get(key) != value
409
+ for key, value in (expected or {}).items():
410
+ counts["gold_fields"] += 1
411
+ # Compare prediction to gold. Writing `(expected or {})` on both
412
+ # sides made every gold field look correct and reported 100%.
413
+ counts["correct_fields"] += (actual or {}).get(key) == value
414
+ counts["correct_abstain"] += actual is None and expected is None
415
+ bylang.setdefault(row["language"], Counter())["n"] += 1
416
+ bylang[row["language"]]["exact"] += exact
417
+ slot = bylayout.setdefault(row["layout"], Counter())
418
+ slot["n"] += 1
419
+ slot["exact"] += exact
420
+ predictions.append({"query": row["query"], "layout": row["layout"],
421
+ "language": row["language"], "expected": expected,
422
+ "actual": actual, "spans": spans,
423
+ "issues": issues, "exact": exact})
424
+ if timed:
425
+ latencies.append(1000 * single_row_ms(model, item))
426
+ elapsed = time.perf_counter() - start
427
+ out = dict(counts)
428
+ out["span_f1"] = f1(counts["span_tp"], counts["span_pred"], counts["span_gold"])
429
+ out["kinds"] = {kind: {"gold": c["gold"], "pred": c["pred"], "tp": c["tp"],
430
+ "f1": f1(c["tp"], c["pred"], c["gold"])}
431
+ for kind, c in sorted(per_kind.items())}
432
+ out["by_language"] = {k: dict(v) for k, v in sorted(bylang.items())}
433
+ out["by_layout"] = {k: dict(v) for k, v in sorted(bylayout.items())}
434
+ out["ms_per_row_batched"] = 1000 * elapsed / max(1, len(encoded))
435
+ if latencies:
436
+ out["latency_ms_p50"] = round(statistics.median(latencies), 2)
437
+ out["latency_ms_p95"] = round(sorted(latencies)[int(0.95 * len(latencies))], 2)
438
+ return out, predictions
439
+
440
+
441
+ def single_row_ms(model, item):
442
+ began = time.perf_counter()
443
+ batch, _ = collate([item], model.encoder.config.pad_token_id)
444
+ device = next(model.parameters()).device
445
+ batch = on_device(batch, device)
446
+ logits, _ = model(batch)
447
+ torch.cuda.synchronize(device) if device.type == "cuda" else None
448
+ logits.argmax(-1)
449
+ return time.perf_counter() - began
450
+
451
+
452
+ def f1(tp, pred, gold):
453
+ precision, recall = (tp / pred if pred else 0.0), (tp / gold if gold else 0.0)
454
+ return round(2 * precision * recall / (precision + recall), 4) if precision + recall else 0.0
455
+
456
+
457
+ def train(args):
458
+ out = Path(args.out)
459
+ out.mkdir(parents=True, exist_ok=False)
460
+ torch.set_num_threads(args.threads)
461
+ random.seed(args.seed)
462
+ torch.manual_seed(args.seed)
463
+ torch.cuda.manual_seed_all(args.seed)
464
+ torch.use_deterministic_algorithms(True)
465
+ device = resolve_device(args.device)
466
+ train_splits = ARM_SPLITS[args.arm]
467
+ train_rows = [row for split in train_splits for row in rows(split)]
468
+ data = {split: rows(split) for split in SCORE_SPLITS}
469
+ scored_queries = set()
470
+ for value in data.values():
471
+ scored_queries |= {row["query"] for row in value}
472
+ leaked = scored_queries.intersection(row["query"] for row in train_rows)
473
+ assert not leaked, f"train rows appear in a scored split: {sorted(leaked)[:3]}"
474
+ if args.smoke:
475
+ train_rows, data = train_rows[:160], {k: v[:40] for k, v in data.items()}
476
+ model = Extractor(args.mode, len(LABELS))
477
+ encoded = {key: [model.encode(row) for row in value] for key, value in
478
+ list(data.items()) + [("train", train_rows)]}
479
+ model.to(device)
480
+ weights = torch.tensor(label_weights(encoded["train"]), dtype=torch.float, device=device)
481
+ transition = (legality_matrix() if args.transition == "legal"
482
+ else transitions(encoded["train"]))
483
+ versions = {p: importlib.metadata.version(p) for p in ("torch", "transformers", "numpy")}
484
+ unknown = {}
485
+ if model.tokenizer:
486
+ unk = model.tokenizer.unk_token_id
487
+ # The audit number that matters is how much of the training text the
488
+ # vocabulary cannot represent at all, recorded per run.
489
+ tokens = [token for item in encoded["train"] for token in item[0]]
490
+ unknown = {"train": round(sum(t == unk for t in tokens) / len(tokens), 4)}
491
+ # Recorded verbatim so a published run can never be reinterpreted after the
492
+ # view list changes: `--augment balanced` means whatever this file says it meant.
493
+ views = SCHEDULES.get(args.augment)
494
+ selected_views, selection_counts = selection_views(data["validation"], args.seed,
495
+ scored_queries)
496
+ selection_encoded = {name: [model.encode(row) for row in value]
497
+ for name, value in selected_views.items()}
498
+ save_json(out / "config.json", {
499
+ "args": vars(args), "versions": versions, "corpus_sha256": digest(CORPUS / "manifest.json"),
500
+ "source_sha256s": {"parser2": digest(__file__),
501
+ "corpus2": digest(Path(corpus2.__file__)),
502
+ "normalize": digest(Path(normalize.__file__))},
503
+ "corpus_splits": train_splits, "train_rows": len(train_rows),
504
+ "parameters": sum(p.numel() for p in model.parameters()),
505
+ "source_sha256": digest(__file__), "encoder": model.encoder.config.to_dict(),
506
+ "snapshot": str(SNAP) if args.mode != "bytes" else None,
507
+ "pretraining_overlap": "unknown for upstream weights" if args.mode == "pretrained" else None,
508
+ "value_head": False, "unknown_token_fraction": unknown,
509
+ "device": {"requested": args.device, "resolved": str(device),
510
+ "cuda": torch.version.cuda,
511
+ "name": torch.cuda.get_device_name(device) if device.type == "cuda" else None},
512
+ "label_weights_min": round(float(weights.min()), 3),
513
+ "label_weights_max": round(float(weights.max()), 3),
514
+ "balanced_views": list(views) if views else None,
515
+ "selection": ("mean exact-record over fixed plain/shuffle/markers/boundary "
516
+ "views of the validation split; earliest checkpoint breaks ties")
517
+ if args.select == "multi-view" else
518
+ "validation exact-record; earliest checkpoint breaks ties",
519
+ # Recorded before training so a reviewer can see that selection never read a
520
+ # held-out row, and can rebuild the same views from the seed.
521
+ "selection_view_counts": selection_counts,
522
+ "selection_view_queries": {name: [row["query"] for row in value]
523
+ for name, value in selected_views.items()}})
524
+ optimizer = torch.optim.AdamW([
525
+ {"params": model.encoder.parameters(), "lr": 5e-5 if args.mode == "pretrained" else 3e-4},
526
+ {"params": model.tags.parameters(), "lr": 3e-4}], weight_decay=0.01)
527
+ best, history, started = -1.0, [], time.perf_counter()
528
+ rng = random.Random(args.seed)
529
+ balanced_position_rng = random.Random(args.seed + 20260918)
530
+ value_rng = random.Random(args.seed + 20260916)
531
+ path_rng = random.Random(args.seed + 20260917)
532
+ marker_rng = random.Random(args.seed + 20260922)
533
+ boundary_rng = random.Random(args.seed + 20260923)
534
+ for epoch in range(args.epochs):
535
+ model.train()
536
+ items, augmentation = [], Counter()
537
+ for row_index, row in enumerate(train_rows):
538
+ view = row
539
+ view_mode = (balanced_view(row_index, epoch, args.seed, views)
540
+ if args.augment in SCHEDULES else args.augment)
541
+ selected = (view_mode != "none" and
542
+ (args.augment in SCHEDULES or rng.random() < args.augment_rate))
543
+ augmentation["scheduled_" + view_mode] += 1
544
+ if selected:
545
+ augmentation["selected"] += 1
546
+ # A different arrangement of the same atoms every epoch: the label
547
+ # multiset is unchanged unless this arm explicitly adds value
548
+ # support. Axis-specific RNGs do not perturb which positional view
549
+ # the paired round-2 control would have seen.
550
+ position_rng = balanced_position_rng if args.augment in SCHEDULES else rng
551
+ candidate = augment_training(row, view_mode, position_rng, value_rng, path_rng,
552
+ marker_rng, boundary_rng)
553
+ # The augmented view is built at train time, so it never passed the
554
+ # corpus dedupe. A view that lands on a scored query would be test
555
+ # leakage through the back door, and a shuffled negative row is the
556
+ # realistic way to hit one.
557
+ if candidate and candidate["query"] not in scored_queries:
558
+ view = candidate
559
+ augmentation["accepted"] += 1
560
+ for axis in candidate.get("augmented_axes", []):
561
+ augmentation[axis] += 1
562
+ elif candidate:
563
+ augmentation["scored_query_collision"] += 1
564
+ else:
565
+ augmentation["rejected"] += 1
566
+ items.append(model.encode(view))
567
+ rng.shuffle(items)
568
+ losses = []
569
+ for offset in range(0, len(items), args.batch):
570
+ minibatch = items[offset:offset + args.batch]
571
+ batch, labels = collate(minibatch, model.encoder.config.pad_token_id)
572
+ batch, labels = on_device(batch, device), labels.to(device, non_blocking=True)
573
+ logits, _ = model(batch)
574
+ loss = nn.functional.cross_entropy(logits.reshape(-1, len(LABELS)),
575
+ labels.reshape(-1), weight=weights)
576
+ if not torch.isfinite(loss):
577
+ raise SystemExit(f"non-finite loss at epoch {epoch + 1} step {offset}: "
578
+ f"a zero/nan weight or lr is producing a model that only "
579
+ f"looks trained")
580
+ optimizer.zero_grad()
581
+ loss.backward()
582
+ nn.utils.clip_grad_norm_(model.parameters(), 1.0)
583
+ optimizer.step()
584
+ losses.append(float(loss.detach()))
585
+ val, _ = evaluate(model, encoded["validation"], decode_mode=args.decode,
586
+ transition=transition)
587
+ # The selection objective is the mean over the fixed derived views; the
588
+ # plain validation number stays recorded so rounds 3 and 4 remain readable.
589
+ view_scores = {}
590
+ for name in SELECTION_VIEWS:
591
+ if name == "plain":
592
+ view_scores[name] = val["exact"]
593
+ continue
594
+ scored_view, _ = evaluate(model, selection_encoded[name],
595
+ decode_mode=args.decode, transition=transition)
596
+ view_scores[name] = scored_view["exact"]
597
+ objective = (sum(view_scores.values()) /
598
+ (len(SELECTION_VIEWS) * max(1, val["n"])))
599
+ entry = {"epoch": epoch + 1, "loss": sum(losses) / len(losses),
600
+ "validation_exact": val["exact"], "validation_span_f1": val["span_f1"],
601
+ "validation_kinds": {k: v["f1"] for k, v in val["kinds"].items()},
602
+ "selection_objective": round(objective, 4),
603
+ "selection_views": view_scores,
604
+ "augmentation": dict(sorted(augmentation.items())),
605
+ "elapsed_s": round(time.perf_counter() - started, 1)}
606
+ history.append(entry)
607
+ save_json(out / "history.json", history)
608
+ print(f"{args.arm} {args.mode} {args.seed} ep{epoch + 1} "
609
+ f"val {val['exact']}/{val['n']} sel {objective:.3f} f1 {val['span_f1']} "
610
+ f"{entry['elapsed_s']}s", flush=True)
611
+ chosen = objective if args.select == "multi-view" else val["exact"] / max(1, val["n"])
612
+ if chosen > best:
613
+ best = chosen
614
+ torch.save(cpu_state_dict(model), out / "best.pt")
615
+ model.load_state_dict(torch.load(out / "best.pt", map_location="cpu", weights_only=True))
616
+ results = {}
617
+ for split in SCORE_SPLITS:
618
+ scored, preds = evaluate(model, encoded[split], timed=split == "easy",
619
+ decode_mode=args.decode, transition=transition)
620
+ results[split] = scored
621
+ (out / f"{split}.jsonl").write_text("".join(
622
+ json.dumps(p, ensure_ascii=False) + "\n" for p in preds))
623
+ # Purely positional probe, derived at evaluation time from `probe-base`: the
624
+ # same atoms in a new arrangement, so unlike `probe-layout` it introduces no
625
+ # unseen marker vocabulary. `heldout-layout` confounds the two, and round 1
626
+ # showed that a zero there is ambiguous between "format" and "lexicon".
627
+ # Gold is the parent record, which `augment` proves equal before returning.
628
+ probe_rng = random.Random(args.seed + 20260915)
629
+ base = data["probe-base"]
630
+ views = [(augment(row, probe_rng) or row) for row in base]
631
+ scored, preds = evaluate(model, [model.encode(row) for row in views])
632
+ scored["derived_rearranged"] = sum(1 for row, view in zip(base, views) if view is not row)
633
+ scored["derived_from"] = "probe-base re-arranged by corpus2.augment at eval time"
634
+ results["probe-shuffle"] = scored
635
+ (out / "probe-shuffle.jsonl").write_text("".join(
636
+ json.dumps(p, ensure_ascii=False) + "\n" for p in preds))
637
+ results["training_seconds"] = round(time.perf_counter() - started, 1)
638
+ results["checkpoint_sha256"] = digest(out / "best.pt")
639
+ # `best` is whichever objective was registered: a count for seen-layout
640
+ # validation, a rate for the multi-view mean. Reporting both explicitly keeps a
641
+ # round-4 and a round-5 history readable without pretending they are the same
642
+ # number.
643
+ results["selected_checkpoint_objective"] = {"mode": args.select,
644
+ "value": round(best, 4)}
645
+ results["selection_view_counts"] = selection_counts
646
+ export(model, args, out, encoded, results)
647
+ save_json(out / "results.json", results)
648
+ print(json.dumps({split: {"exact": f"{results[split]['exact']}/{results[split]['n']}",
649
+ "span_f1": results[split]["span_f1"]}
650
+ for split in ("validation", "easy", "heldout-layout", "heldout-lexical",
651
+ "heldout-value", "heldout-negative", "probe-base",
652
+ "probe-layout", "probe-shuffle")}, indent=2), flush=True)
653
+
654
+
655
+ def export(model, args, out, encoded, results):
656
+ """Evaluate the artifact that would actually ship, not the fp32 prototype."""
657
+ # Dynamic INT8 is a CPU deployment format. The checkpoint is deliberately
658
+ # portable and all artifact comparisons happen after the same fp32 model is
659
+ # returned to CPU, regardless of where training ran.
660
+ model.cpu().eval()
661
+ fp32 = out / "model.fp32.pt"
662
+ torch.save(model.state_dict(), fp32)
663
+ quantized = torch.quantization.quantize_dynamic(model, {nn.Linear}, dtype=torch.qint8)
664
+ qpath = out / "model.int8.pt"
665
+ torch.save(quantized.state_dict(), qpath)
666
+ sizes = {"fp32_mib": round(fp32.stat().st_size / 2 ** 20, 2),
667
+ "int8_mib": round(qpath.stat().st_size / 2 ** 20, 2)}
668
+ rows_sample = encoded["easy"][:64]
669
+ scored, _ = evaluate(quantized, rows_sample, timed=False)
670
+ # Like-for-like: the quantized number only means anything next to the fp32
671
+ # number on the same rows, which is the comparison the pilot had to add.
672
+ baseline, _ = evaluate(model, rows_sample)
673
+ # Quantization is scored on held-out formats too, not only on in-distribution
674
+ # rows: a drop that only appears on unseen layouts would be invisible here
675
+ # otherwise, and the shipped artifact is the one that has to answer for it.
676
+ unseen, _ = evaluate(quantized, encoded["heldout-layout"])
677
+ results["export"] = {**sizes, "int8_subset_n": scored["n"],
678
+ "int8_subset_exact": scored["exact"],
679
+ "fp32_subset_exact": baseline["exact"],
680
+ "int8_span_f1_subset": scored["span_f1"],
681
+ "fp32_span_f1_subset": baseline["span_f1"],
682
+ "int8_heldout_layout_exact": f"{unseen['exact']}/{unseen['n']}",
683
+ "fp32_heldout_layout_exact": f"{results['heldout-layout']['exact']}/"
684
+ f"{results['heldout-layout']['n']}",
685
+ "int8_latency_p50": None, "int8_latency_p95": None}
686
+ with torch.inference_mode():
687
+ latencies = [1000 * single_row_ms(quantized, item) for item in rows_sample]
688
+ results["export"]["int8_latency_p50"] = round(statistics.median(latencies), 2)
689
+ results["export"]["int8_latency_p95"] = round(sorted(latencies)[int(0.95 * len(latencies))], 2)
690
+
691
+
692
+ def score_artifact(args):
693
+ """Score the *shipped* INT8 file on the full battery, under both decoders.
694
+
695
+ Training writes `model.int8.pt`, but until now only a 64-row subset and
696
+ heldout-layout were ever read from that file: the artifact comparisons in the
697
+ documentation were re-quantizations of the checkpoint on the scoring machine,
698
+ not the bytes that would ship. This loads the exported file and states explicitly
699
+ whether it equals a fresh local re-quantization, so a reader knows which object
700
+ produced each number.
701
+ """
702
+ out = Path(args.score_artifact)
703
+ config = json.loads((out / "config.json").read_text())
704
+ target = out / f"artifact-int8-{args.transition}.json"
705
+ if target.exists():
706
+ raise SystemExit(f"refusing to overwrite {target}")
707
+ shipped_path = out / "model.int8.pt"
708
+ if not shipped_path.exists():
709
+ raise SystemExit(f"{out} has no exported artifact to score")
710
+ torch.set_num_threads(args.threads)
711
+ mode = config["args"]["mode"]
712
+
713
+ def quantized_model():
714
+ # Dynamic INT8 has no state_dict-compatible loader for an unquantized module:
715
+ # quantize first, then load the shipped weights into the same structure.
716
+ candidate = torch.quantization.quantize_dynamic(Extractor(mode, len(LABELS)),
717
+ {nn.Linear}, dtype=torch.qint8)
718
+ candidate.load_state_dict(torch.load(shipped_path, map_location="cpu",
719
+ weights_only=False))
720
+ return candidate.eval()
721
+
722
+ def decode_tags(network, items, decode_mode="greedy", transition=None):
723
+ """The tag sequences a model emits for `items`, for behaviour comparison."""
724
+ network.eval()
725
+ tags = []
726
+ with torch.no_grad():
727
+ for offset in range(0, len(items), 64):
728
+ chunk = items[offset:offset + 64]
729
+ batch, _ = collate(chunk, network.encoder.config.pad_token_id)
730
+ logits, _ = network(batch)
731
+ tags += tag_paths(logits.detach().cpu(), [len(item[1]) for item in chunk],
732
+ decode_mode, transition)
733
+ return tags
734
+
735
+ def checkpoint():
736
+ fresh = Extractor(mode, len(LABELS))
737
+ fresh.load_state_dict(torch.load(out / "best.pt", map_location="cpu",
738
+ weights_only=True))
739
+ return fresh.eval()
740
+
741
+ shipped = quantized_model()
742
+ reference = checkpoint()
743
+ # A quantized `state_dict` holds packed params (tuples, dtypes), so comparing its
744
+ # internals compares a serialization format. What a reader needs to know is
745
+ # whether the shipped bytes *behave* like a fresh re-quantization of this
746
+ # checkpoint, so the equality claim is made about predictions.
747
+ sample = [reference.encode(row) for row in rows("easy")[:32]]
748
+ shipped_tags = decode_tags(shipped, sample)
749
+ reference_tags = decode_tags(reference, sample)
750
+ requant_tags = decode_tags(torch.quantization.quantize_dynamic(checkpoint(),
751
+ {nn.Linear},
752
+ dtype=torch.qint8),
753
+ sample)
754
+ train_items = [reference.encode(row)
755
+ for split in ARM_SPLITS[config["args"]["arm"]] for row in rows(split)]
756
+ transition = (legality_matrix() if args.transition == "legal"
757
+ else transitions(train_items))
758
+ encoded = {split: [reference.encode(row) for row in rows(split)]
759
+ for split in SCORE_SPLITS}
760
+ # Same seed formula and row order as the trainer, so `probe-shuffle` here is the
761
+ # same measurement the fp32 rescores report.
762
+ probe_rng = random.Random(config["args"]["seed"] + 20260915)
763
+ base = rows("probe-base")
764
+ views = [(augment(row, probe_rng) or row) for row in base]
765
+ encoded["probe-shuffle"] = [reference.encode(row) for row in views]
766
+ result = {"checkpoint_sha256": digest(out / "best.pt"),
767
+ "artifact_sha256": digest(shipped_path),
768
+ "artifact_mib": round(shipped_path.stat().st_size / 2 ** 20, 2),
769
+ "shipped_matches_requantization": shipped_tags == requant_tags,
770
+ "shipped_matches_fp32_on_sample": shipped_tags == reference_tags,
771
+ "sample_rows": len(sample), "transition": args.transition, "decodes": {}}
772
+ for decode_mode in ("greedy", "viterbi"):
773
+ result["decodes"][decode_mode] = {
774
+ split: evaluate(shipped, items, decode_mode=decode_mode,
775
+ transition=transition)[0]
776
+ for split, items in encoded.items()}
777
+ save_json(target, result)
778
+ print(json.dumps({"artifact_sha256": result["artifact_sha256"][:12],
779
+ "shipped_matches_requantization":
780
+ result["shipped_matches_requantization"],
781
+ "shipped_matches_fp32_on_sample":
782
+ result["shipped_matches_fp32_on_sample"],
783
+ "greedy": {split: f"{scores['exact']}/{scores['n']}" for split, scores
784
+ in result["decodes"]["greedy"].items()},
785
+ "viterbi": {split: f"{scores['exact']}/{scores['n']}" for split, scores
786
+ in result["decodes"]["viterbi"].items()
787
+ if split in ("validation", "heldout-layout",
788
+ "heldout-lexical", "probe-value")}}, indent=2))
789
+
790
+
791
+ def rescore(args):
792
+ """Re-score an existing checkpoint with a different decoder: no retraining, so
793
+ the comparison isolates decoding from everything else. Also checks that the
794
+ greedy path reproduces the run's recorded numbers before trusting the delta."""
795
+ out = Path(args.rescore)
796
+ config = json.loads((out / "config.json").read_text())
797
+ target = out / f"rescore-{args.transition}.json"
798
+ if target.exists():
799
+ raise SystemExit(f"refusing to overwrite {target}")
800
+ torch.set_num_threads(args.threads)
801
+ device = resolve_device(args.device)
802
+ model = Extractor(config["args"]["mode"], len(LABELS))
803
+ model.load_state_dict(torch.load(out / "best.pt", map_location="cpu", weights_only=True))
804
+ model.to(device)
805
+ train_items = [model.encode(row)
806
+ for split in ARM_SPLITS[config["args"]["arm"]] for row in rows(split)]
807
+ transition = (legality_matrix() if args.transition == "legal"
808
+ else transitions(train_items))
809
+ encoded = {split: [model.encode(row) for row in rows(split)] for split in SCORE_SPLITS}
810
+ # The positional probe added after round 1, computed here so a checkpoint
811
+ # trained before it exists can still be compared on it. Same seed formula and
812
+ # same row order as the trainer, so the numbers are the same measurement.
813
+ probe_rng = random.Random(config["args"]["seed"] + 20260915)
814
+ base = rows("probe-base")
815
+ views = [(augment(row, probe_rng) or row) for row in base]
816
+ shuffle_items = [model.encode(row) for row in views]
817
+ result = {"checkpoint_sha256": digest(out / "best.pt"),
818
+ "args": config["args"],
819
+ "transition": args.transition,
820
+ "probe_shuffle_rearranged": sum(1 for row, view in zip(base, views)
821
+ if view is not row),
822
+ "decodes": {}}
823
+ for mode in ("greedy", "viterbi"):
824
+ result["decodes"][mode] = {
825
+ split: evaluate(model, encoded[split],
826
+ decode_mode=mode, transition=transition)[0]
827
+ for split in SCORE_SPLITS}
828
+ result["decodes"][mode]["probe-shuffle"] = evaluate(
829
+ model, shuffle_items, decode_mode=mode, transition=transition)[0]
830
+ recorded = json.loads((out / "results.json").read_text())
831
+ result["greedy_matches_recorded"] = all(
832
+ result["decodes"]["greedy"][split]["exact"] == recorded[split]["exact"]
833
+ for split in SCORE_SPLITS)
834
+ result["delta_viterbi_minus_greedy_exact"] = {
835
+ split: result["decodes"]["viterbi"][split]["exact"]
836
+ - result["decodes"]["greedy"][split]["exact"] for split in SCORE_SPLITS}
837
+ save_json(target, result)
838
+ print(json.dumps({"greedy_matches_recorded": result["greedy_matches_recorded"],
839
+ "probe_shuffle_rearranged": result["probe_shuffle_rearranged"],
840
+ "probe_shuffle": {mode: f"{scores['probe-shuffle']['exact']}/"
841
+ f"{scores['probe-shuffle']['n']}"
842
+ for mode, scores in result["decodes"].items()},
843
+ "delta": result["delta_viterbi_minus_greedy_exact"]}, indent=2))
844
+
845
+
846
+ def main():
847
+ parser = argparse.ArgumentParser(description=__doc__)
848
+ parser.add_argument("--arm", choices=["A-narrow", "B-broad"], default="B-broad")
849
+ parser.add_argument("--mode", choices=["bytes", "characters", "pretrained"], default="characters")
850
+ parser.add_argument("--seed", type=int, default=0)
851
+ parser.add_argument("--epochs", type=int, default=6)
852
+ parser.add_argument("--batch", type=int, default=32)
853
+ parser.add_argument("--threads", type=int, default=3)
854
+ parser.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto")
855
+ parser.add_argument("--decode", choices=["greedy", "viterbi"], default="greedy")
856
+ parser.add_argument("--transition", choices=["legal", "empirical"], default="legal",
857
+ help="which matrix Viterbi uses: BIO admissibility only "
858
+ "(default) or empirical tag-transition frequencies")
859
+ parser.add_argument("--augment", choices=["none", "shuffle", "shuffle-values",
860
+ "shuffle-paths", "shuffle-rich", "balanced4",
861
+ "balanced", "balanced-dose", "shuffle-markers",
862
+ "shuffle-boundary"],
863
+ default="none",
864
+ help="shuffle atom arrangement per epoch; breaks positional cues. "
865
+ "`balanced` is the current six-view schedule; `balanced4` is "
866
+ "round 4's frozen four-view list")
867
+ parser.add_argument("--select", choices=["validation", "multi-view"], default="validation",
868
+ help="checkpoint objective: seen-layout validation exact-record "
869
+ "(rounds 1-4) or the mean over fixed derived validation views")
870
+ parser.add_argument("--augment-rate", type=float, default=0.5,
871
+ help="share of training rows re-arranged each epoch")
872
+ parser.add_argument("--rescore", help="re-score a run directory with both decoders")
873
+ parser.add_argument("--score-artifact",
874
+ help="score a run's exported model.int8.pt on every split under "
875
+ "both decoders, and report whether it matches a fresh local "
876
+ "re-quantization of the checkpoint")
877
+ parser.add_argument("--smoke", action="store_true",
878
+ help="tiny subsets to check plumbing; never a measurement")
879
+ parser.add_argument("--out")
880
+ args = parser.parse_args()
881
+ if args.rescore:
882
+ return rescore(args)
883
+ if args.score_artifact:
884
+ return score_artifact(args)
885
+ if not args.out:
886
+ parser.error("--out is required unless --rescore or --score-artifact is given")
887
+ train(args)
888
+
889
+
890
+ if __name__ == "__main__":
891
+ main()
app/fp/parser3_model.py ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lossless character inputs and complete-span heads for the v3 controls.
2
+
3
+ The encoder still sees one position per Unicode scalar, so UTF-8 fallback does
4
+ not consume a different positional budget or split an output character. Existing
5
+ vocabulary IDs and embeddings are preserved. Unknown scalars have a reversible
6
+ integer ID and four ordered UTF-8 byte slots, never the shared UNK ID alone.
7
+ """
8
+
9
+ import numpy as np
10
+ import torch
11
+ from torch import nn
12
+
13
+ from fp import charclass
14
+ from fp.parser2 import Extractor, KINDS, LABELS, predict_spans, tag_paths, legality_matrix
15
+
16
+ TYPES = ["O", *KINDS]
17
+
18
+
19
+ def utf8_slots(codepoints):
20
+ """UTF-8 bytes + 1, padded with zero to four slots (tensorized, ordered)."""
21
+ cp = codepoints.clamp_min(0)
22
+ two, three, four = cp >= 0x80, cp >= 0x800, cp >= 0x10000
23
+ first = torch.where(four, 0xF0 | (cp >> 18), torch.where(
24
+ three, 0xE0 | (cp >> 12), torch.where(two, 0xC0 | (cp >> 6), cp)))
25
+ second = torch.where(four, 0x80 | ((cp >> 12) & 63), torch.where(
26
+ three, 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63)))
27
+ third = torch.where(four, 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63))
28
+ fourth = 0x80 | (cp & 63)
29
+ return torch.stack((first + 1, torch.where(two, second + 1, 0),
30
+ torch.where(three, third + 1, 0),
31
+ torch.where(four, fourth + 1, 0)), dim=-1)
32
+
33
+
34
+ class Parser(Extractor):
35
+ def __init__(self, representation="unicode", head="bio", *, encoder=None, tokenizer=None,
36
+ char_classes=False):
37
+ if representation not in ("legacy", "unicode", "subword") or head not in ("bio", "span"):
38
+ raise ValueError("unknown representation or head")
39
+ if encoder is None:
40
+ super().__init__("pretrained", len(LABELS))
41
+ else:
42
+ # Tiny injected encoders make alignment/export tests independent of downloads.
43
+ nn.Module.__init__(self)
44
+ self.encoder, self.tokenizer, self.mode = encoder, tokenizer, "pretrained"
45
+ self.tags = nn.Linear(encoder.config.hidden_size, len(LABELS))
46
+ self.representation, self.head = representation, head
47
+ self.base_vocab_size = self.encoder.config.vocab_size
48
+ hidden = self.encoder.config.hidden_size
49
+ if representation == "unicode":
50
+ if hidden % 4:
51
+ raise ValueError("hidden size must be divisible by four")
52
+ self.byte_slots = nn.ModuleList([nn.Embedding(257, hidden // 4, padding_idx=0)
53
+ for _ in range(4)])
54
+ for embedding in self.byte_slots:
55
+ nn.init.normal_(embedding.weight, std=0.02)
56
+ with torch.no_grad():
57
+ embedding.weight[0].zero_()
58
+ self.byte_projection = nn.Linear(hidden, hidden, bias=False)
59
+ nn.init.eye_(self.byte_projection.weight)
60
+ if head == "span":
61
+ self.tags = nn.Linear(hidden, len(TYPES))
62
+ self.starts = nn.Linear(hidden, len(TYPES))
63
+ self.ends = nn.Linear(hidden, len(TYPES))
64
+ self.char_classes = char_classes
65
+ # (structural, other, fallback) identity-dropout rates; set by the trainer.
66
+ self.identity_dropout = (0.0, 0.0, 0.0)
67
+ if char_classes:
68
+ if representation != "unicode":
69
+ raise ValueError("character classes need the unicode representation")
70
+ # Zero-initialised: a model built on an existing encoder starts out
71
+ # computing exactly what it computed without class features.
72
+ self.category_embedding = nn.Embedding(charclass.N_CATEGORIES, hidden)
73
+ self.script_embedding = nn.Embedding(charclass.N_SCRIPTS, hidden)
74
+ nn.init.zeros_(self.category_embedding.weight)
75
+ nn.init.zeros_(self.script_embedding.weight)
76
+ # Stands in for a dropped character identity during training only.
77
+ self.anonymous = nn.Parameter(torch.randn(hidden) * 0.02)
78
+ category, script = charclass.tables()
79
+ id_cp = torch.full((self.base_vocab_size,), -1, dtype=torch.long)
80
+ for token, index in self.tokenizer.get_vocab().items():
81
+ if len(token) == 1 and index < self.base_vocab_size:
82
+ id_cp[index] = ord(token)
83
+ # Lookup tables are rebuilt from code, never saved: the exported Go
84
+ # artifact carries the same assignment as ranges in its JSON sidecar.
85
+ self.register_buffer("id_codepoint", id_cp, persistent=False)
86
+ self.register_buffer("category_table", torch.from_numpy(category.astype("int64")),
87
+ persistent=False)
88
+ self.register_buffer("script_table", torch.from_numpy(script.astype("int64")),
89
+ persistent=False)
90
+ structural = torch.zeros(charclass.N_CATEGORIES, dtype=torch.bool)
91
+ structural[list(charclass.STRUCTURAL)] = True
92
+ self.register_buffer("structural_category", structural, persistent=False)
93
+
94
+ def fits(self, row):
95
+ """Encoder-window check: characters for character encoders, tokens for subword.
96
+
97
+ The frozen character encoder sees one position per code point plus the
98
+ two specials; a subword encoder sees the tokenizer's pieces. No row is
99
+ ever truncated, so the caller filters with this before encoding.
100
+ """
101
+ if self.representation == "subword":
102
+ tokens = self.tokenizer(row["query"], add_special_tokens=True)["input_ids"]
103
+ return len(tokens) <= self.encoder.config.max_position_embeddings
104
+ return len(row["query"]) + 2 <= self.encoder.config.max_position_embeddings
105
+
106
+ def encode(self, row):
107
+ if self.representation == "subword":
108
+ return self._encode_subword(row)
109
+ query = row["query"]
110
+ # encode() rejects surrogate code points as well as invalid UTF-8 text.
111
+ query.encode("utf-8", errors="strict")
112
+ tokens = self.tokenizer.tokenize(query)
113
+ if tokens != list(query):
114
+ raise ValueError("v3 requires a character tokenizer with exact original offsets")
115
+ ids = self.tokenizer.convert_tokens_to_ids(tokens)
116
+ if self.representation == "unicode":
117
+ ids = [self.base_vocab_size + ord(char) if token == self.tokenizer.unk_token_id
118
+ else token for char, token in zip(query, ids)]
119
+ ids = [self.tokenizer.cls_token_id, *ids, self.tokenizer.sep_token_id]
120
+ if len(ids) > self.encoder.config.max_position_embeddings:
121
+ raise ValueError(f"{len(ids)} positions exceed encoder limit; no truncation")
122
+ labels = self._labels(query, row["spans"], row.get("partial", False))
123
+ return ids, list(range(1, len(query) + 1)), list(range(2, len(query) + 2)), labels, row
124
+
125
+ def _labels(self, query, spans, partial=False):
126
+ """BIO labels for one row, refusing invalid or misquoted spans.
127
+
128
+ Partial rows mark uncovered characters -100 so the loss ignores them:
129
+ the row asserts only what its spans cover, not that nothing else exists.
130
+ """
131
+ labels = [-100 if partial else 0] * len(query)
132
+ previous = 0
133
+ for span in spans:
134
+ a, b, kind = span["start"], span["end"], span["kind"]
135
+ if not 0 <= previous <= a < b <= len(query):
136
+ raise ValueError("invalid or overlapping span offsets")
137
+ previous = b
138
+ if span.get("text", query[a:b]) != query[a:b]:
139
+ raise ValueError("span text does not match original offsets")
140
+ labels[a] = LABELS.index("B-" + kind)
141
+ labels[a + 1:b] = [LABELS.index("I-" + kind)] * (b - a - 1)
142
+ return labels
143
+
144
+ def _encode_subword(self, row):
145
+ """Subword tokens with one character window per code point.
146
+
147
+ Each character shares the window of the token(s) covering it, so the
148
+ cumulative-mean pooling in forward() still yields exactly one feature per
149
+ character and the span head keeps character resolution. A character the
150
+ tokenizer never covers is an error, never a silent guess.
151
+ """
152
+ query = row["query"]
153
+ query.encode("utf-8", errors="strict")
154
+ encoding = self.tokenizer(query, add_special_tokens=True,
155
+ return_offsets_mapping=True)
156
+ ids, offsets = encoding["input_ids"], encoding["offset_mapping"]
157
+ if len(ids) > self.encoder.config.max_position_embeddings:
158
+ raise ValueError(f"{len(ids)} tokens exceed encoder limit; no truncation")
159
+ starts, ends = [], []
160
+ for index in range(len(query)):
161
+ first = last = None
162
+ for position, (start, stop) in enumerate(offsets):
163
+ if start == stop or not start <= index < stop:
164
+ continue
165
+ if first is None:
166
+ first = position
167
+ last = position
168
+ if first is not None:
169
+ starts.append((first, last + 1))
170
+ else:
171
+ starts.append(None)
172
+ if query and all(window is None for window in starts):
173
+ raise ValueError(f"tokenizer covers nothing in {query!r}")
174
+ # Word-piece tokenizers normalize some characters away (sentencepiece
175
+ # collapses repeated whitespace). Those characters inherit the nearest
176
+ # covered character's window so the character grid stays intact; the
177
+ # count is recorded on the model and reported in the run config.
178
+ uncovered = 0
179
+ for index in range(len(query)):
180
+ if starts[index] is not None:
181
+ continue
182
+ uncovered += 1
183
+ previous = next((starts[position] for position in range(index - 1, -1, -1)
184
+ if starts[position] is not None), None)
185
+ following = next((starts[position] for position in range(index + 1, len(query))
186
+ if starts[position] is not None), None)
187
+ starts[index] = previous or following
188
+ self.uncovered_chars = getattr(self, "uncovered_chars", 0) + uncovered
189
+ ends = [window[1] for window in starts]
190
+ starts = [window[0] for window in starts]
191
+ return (ids, starts, ends,
192
+ self._labels(query, row["spans"], row.get("partial", False)), row)
193
+
194
+ def character_embeddings(self, ids):
195
+ fallback = ids >= self.base_vocab_size
196
+ known = ids.masked_fill(fallback, self.tokenizer.unk_token_id)
197
+ embedded = self.encoder.embeddings.word_embeddings(known)
198
+ if self.representation == "legacy":
199
+ return embedded
200
+ slots = utf8_slots(ids - self.base_vocab_size)
201
+ composed = torch.cat([embedding(slots[..., i])
202
+ for i, embedding in enumerate(self.byte_slots)], dim=-1)
203
+ composed = self.byte_projection(composed.reshape(-1, composed.shape[-1])).reshape_as(embedded)
204
+ embedded = torch.where(fallback.unsqueeze(-1), composed, embedded)
205
+ if not self.char_classes:
206
+ return embedded
207
+ codepoint = torch.where(fallback, ids - self.base_vocab_size,
208
+ self.id_codepoint[ids.clamp(0, self.base_vocab_size - 1)])
209
+ is_char = codepoint >= 0
210
+ safe = codepoint.clamp(0, charclass.LIMIT - 1)
211
+ category = torch.where(is_char, self.category_table[safe], 0)
212
+ script = torch.where(is_char, self.script_table[safe], 0)
213
+ structural, other, unseen = self.identity_dropout
214
+ if self.training and (structural or other or unseen):
215
+ rate = torch.where(self.structural_category[category], structural, other)
216
+ rate = torch.where(fallback, torch.clamp(rate, min=unseen), rate)
217
+ dropped = is_char & (torch.rand(rate.shape, device=rate.device) < rate)
218
+ embedded = torch.where(dropped.unsqueeze(-1), self.anonymous.to(embedded.dtype),
219
+ embedded)
220
+ return embedded + self.category_embedding(category) + self.script_embedding(script)
221
+
222
+ def forward(self, batch):
223
+ ids, attention, starts, ends = batch
224
+ if self.representation in ("legacy", "subword"):
225
+ hidden = self.encoder(input_ids=ids, attention_mask=attention).last_hidden_state
226
+ else:
227
+ hidden = self.encoder(inputs_embeds=self.character_embeddings(ids),
228
+ attention_mask=attention).last_hidden_state
229
+ cumulative = torch.cat((torch.zeros_like(hidden[:, :1]), hidden.cumsum(1)), 1)
230
+ a = starts.unsqueeze(-1).expand(-1, -1, hidden.shape[-1])
231
+ b = ends.unsqueeze(-1).expand_as(a)
232
+ chars = (cumulative.gather(1, b) - cumulative.gather(1, a)) / (
233
+ ends - starts).clamp_min(1).unsqueeze(-1)
234
+ logits = self.tags(chars.reshape(-1, chars.shape[-1]))
235
+ return logits.reshape(*chars.shape[:2], -1), chars
236
+
237
+ def boundary_logits(self, chars):
238
+ flat = chars.reshape(-1, chars.shape[-1])
239
+ shape = (*chars.shape[:2], len(TYPES))
240
+ return self.starts(flat).reshape(shape), self.ends(flat).reshape(shape)
241
+
242
+
243
+ def span_targets(labels):
244
+ """Type/start/end class targets, respecting adjacent same-type gold spans."""
245
+ valid = labels >= 0
246
+ types = torch.where(valid, (labels + 1) // 2, -100)
247
+ starts = torch.where(valid, torch.where(labels % 2 == 1, types, 0), -100)
248
+ following = torch.cat((labels[:, 1:], torch.zeros_like(labels[:, :1])), dim=1)
249
+ continues = (following == 2 * types) & (types > 0)
250
+ ends = torch.where(valid, torch.where((types > 0) & ~continues, types, 0), -100)
251
+ return types, starts, ends
252
+
253
+
254
+ def class_weights(targets, size):
255
+ """Smoothed inverse-square-root weights; unseen classes never have zero loss."""
256
+ valid = targets[targets >= 0]
257
+ counts = torch.bincount(valid, minlength=size).float().add(1)
258
+ weights = counts.rsqrt()
259
+ return weights / (weights * counts).sum() * counts.sum()
260
+
261
+
262
+ def loss(model, logits, chars, labels, weights, *, semicrf=0.0, lengths=None,
263
+ teacher=None, kd_weight=0.0, kd_temperature=1.0):
264
+ """Factorised span/BIO cross-entropy, plus the optional structured and KD terms.
265
+
266
+ `weights` entries may be None (uniform classes). `semicrf` adds the globally
267
+ normalised interval objective for fully labelled rows; `teacher` holds the
268
+ teacher's (type, start, end) logits for distillation on labelled characters.
269
+ """
270
+ if model.head == "bio":
271
+ return nn.functional.cross_entropy(logits.reshape(-1, len(LABELS)),
272
+ labels.reshape(-1), weight=weights[0])
273
+ start, end = model.boundary_logits(chars)
274
+ targets = span_targets(labels)
275
+ terms = [nn.functional.cross_entropy(pred.reshape(-1, len(TYPES)), target.reshape(-1),
276
+ weight=weight)
277
+ for pred, target, weight in zip((logits, start, end), targets, weights)]
278
+ total = sum(terms) / len(terms)
279
+ if semicrf:
280
+ total = total + semicrf * semicrf_loss(logits, start, end, labels, lengths)
281
+ if teacher is not None and kd_weight:
282
+ total = total + kd_weight * distillation_loss((logits, start, end), teacher, labels,
283
+ kd_temperature)
284
+ return total
285
+
286
+
287
+ def interval_potentials(type_logits, start_logits, end_logits):
288
+ """Per-kind log-odds against the null class: the scores `interval_decode` sums."""
289
+ return [x[..., 1:] - x[..., :1] for x in (type_logits, start_logits, end_logits)]
290
+
291
+
292
+ def semicrf_log_partition(body, start, end, lengths):
293
+ """log sum over every segmentation of exp(score), batched.
294
+
295
+ A segmentation is background characters (score 0) and non-overlapping typed
296
+ spans scoring interior body + start + end, exactly the space `interval_decode`
297
+ maximises over. O(L^2 K) per row.
298
+ """
299
+ batch, length, kinds = body.shape
300
+ prefix = torch.cat((body.new_zeros(batch, 1, kinds), body.cumsum(1)), dim=1)
301
+ alpha = [body.new_zeros(batch)]
302
+ for stop in range(1, length + 1):
303
+ previous = torch.stack(alpha, dim=1) # [B, stop]
304
+ spans = (previous.unsqueeze(-1) + prefix[:, stop:stop + 1] - prefix[:, :stop]
305
+ + start[:, :stop] + end[:, stop - 1:stop]) # [B, stop, K]
306
+ alpha.append(torch.logaddexp(alpha[-1], spans.reshape(batch, -1).logsumexp(-1)))
307
+ alpha = torch.stack(alpha, dim=1)
308
+ return alpha.gather(1, lengths.unsqueeze(1)).squeeze(1)
309
+
310
+
311
+ def semicrf_loss(type_logits, start_logits, end_logits, labels, lengths):
312
+ """Per-character negative log-likelihood of the gold segmentation.
313
+
314
+ Rows carrying unlabelled characters (partial rows) have no single gold
315
+ segmentation and are left to the factorised terms.
316
+ """
317
+ positions = torch.arange(labels.shape[1], device=labels.device)
318
+ inside = positions.unsqueeze(0) < lengths.unsqueeze(1)
319
+ full = ~((labels < 0) & inside).any(1) & (lengths > 0)
320
+ if not full.any():
321
+ return type_logits.sum() * 0
322
+ body, start, end = interval_potentials(type_logits[full], start_logits[full],
323
+ end_logits[full])
324
+ labels, lengths = labels[full], lengths[full]
325
+ types, starts, ends = span_targets(labels)
326
+
327
+ def gathered(scores, target):
328
+ picked = scores.gather(2, (target.clamp_min(1) - 1).unsqueeze(-1)).squeeze(-1)
329
+ return (picked * (target > 0)).sum(1)
330
+
331
+ gold = gathered(body, types) + gathered(start, starts) + gathered(end, ends)
332
+ partition = semicrf_log_partition(body, start, end, lengths)
333
+ return ((partition - gold) / lengths.clamp_min(1)).mean()
334
+
335
+
336
+ def distillation_loss(student, teacher, labels, temperature):
337
+ """Temperature-scaled KL to the teacher, on labelled characters only.
338
+
339
+ Characters a row leaves unlabelled are excluded, so no teacher prediction ever
340
+ stands in for a missing gold label.
341
+ """
342
+ mask = (labels >= 0).reshape(-1)
343
+ if not mask.any():
344
+ return student[0].sum() * 0
345
+ total = 0
346
+ for s, t in zip(student, teacher):
347
+ s = s.reshape(-1, s.shape[-1])[mask] / temperature
348
+ t = t.reshape(-1, t.shape[-1])[mask] / temperature
349
+ total = total + nn.functional.kl_div(s.log_softmax(-1), t.log_softmax(-1),
350
+ log_target=True, reduction="batchmean")
351
+ return total * temperature ** 2 / len(student)
352
+
353
+
354
+ def record_confidence(type_logits, start_logits, end_logits, length):
355
+ """exp(score(best segmentation) - log Z): the semi-CRF probability of the decode.
356
+
357
+ A score for ranking and thresholds, not a calibrated probability unless the
358
+ model was trained with the structured term and checked for calibration.
359
+ """
360
+ body, start, end = interval_potentials(type_logits[:length].unsqueeze(0),
361
+ start_logits[:length].unsqueeze(0),
362
+ end_logits[:length].unsqueeze(0))
363
+ spans = interval_decode(type_logits[:length], start_logits[:length], end_logits[:length])
364
+ best = 0.0
365
+ for span in spans:
366
+ k = KINDS.index(span["kind"])
367
+ best += float(body[0, span["start"]:span["end"], k].sum()
368
+ + start[0, span["start"], k] + end[0, span["end"] - 1, k])
369
+ partition = semicrf_log_partition(body, start, end,
370
+ torch.tensor([length], device=body.device))
371
+ return float(torch.exp(torch.tensor(best) - partition.cpu()[0]))
372
+
373
+
374
+ def interval_decode(type_logits, start_logits, end_logits):
375
+ """Best non-overlapping complete spans, with an explicit all-background path.
376
+
377
+ A candidate's score is interior type-vs-background log odds plus type-vs-null
378
+ odds at its start and end. No vocabulary, delimiter, or position rule enters
379
+ decoding. Adjacent spans are legal, including two of the same type.
380
+ """
381
+ arrays = [x.detach().cpu().numpy() if isinstance(x, torch.Tensor) else np.asarray(x)
382
+ for x in (type_logits, start_logits, end_logits)]
383
+ if any(a.ndim != 2 or a.shape != arrays[0].shape for a in arrays):
384
+ raise ValueError("span logits must be aligned matrices")
385
+ if not all(np.isfinite(a).all() for a in arrays):
386
+ raise ValueError("non-finite span logits")
387
+ body, start, end = [a[:, 1:] - a[:, :1] for a in arrays]
388
+ length, kinds = body.shape
389
+ prefix = np.concatenate((np.zeros((1, kinds)), body.cumsum(0)), axis=0)
390
+ best, back = np.zeros(length + 1), [None] * (length + 1)
391
+ for stop in range(1, length + 1):
392
+ scores = (best[:stop, None] + prefix[stop] - prefix[:stop]
393
+ + start[:stop] + end[stop - 1])
394
+ index = int(scores.argmax())
395
+ begin, kind = divmod(index, kinds)
396
+ if scores[begin, kind] > best[stop - 1]:
397
+ best[stop], back[stop] = scores[begin, kind], (begin, kind)
398
+ else:
399
+ best[stop] = best[stop - 1]
400
+ spans, stop = [], length
401
+ while stop:
402
+ if back[stop] is None:
403
+ stop -= 1
404
+ else:
405
+ begin, kind = back[stop]
406
+ spans.append({"kind": KINDS[kind], "start": begin, "end": stop})
407
+ stop = begin
408
+ return list(reversed(spans))
409
+
410
+
411
+ def decode_batch(model, logits, chars, items, decoder):
412
+ lengths = [len(item[-1]["query"]) for item in items]
413
+ if model.head == "bio":
414
+ paths = tag_paths(logits.detach().cpu(), lengths, decoder, legality_matrix())
415
+ return [predict_spans(item[-1]["query"], path) for item, path in zip(items, paths)]
416
+ if decoder != "span":
417
+ raise ValueError("span head requires span decoding")
418
+ starts, ends = model.boundary_logits(chars)
419
+ return [interval_decode(logits[i, :n], starts[i, :n], ends[i, :n])
420
+ for i, n in enumerate(lengths)]
app/serve.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Local parser service for Lain.
3
+
4
+ Serves one frozen filename parser over HTTP so a native or containerised Lain
5
+ can identify release names through a model. Read-only: never writes to disk and
6
+ holds exactly one artifact.
7
+
8
+ GET /health -> {"status": "ok", ...}
9
+ POST /parse {"filename": …} -> {"record": {...} | null, "spans": [...], ...}
10
+
11
+ The record is derived by the same gated contract resolver used at evaluation
12
+ time (``fp.normalize.gated_record``), so a null record is the model abstaining
13
+ and the caller should fall back to its own providers.
14
+
15
+ Usage:
16
+ serve.py --artifact <prefix>/artifact/v3mix-bio-s1 --snapshot <prefix>/anifilebert
17
+ serve.py --check "Some.Show.S01E02.1080p-GROUP.mkv" # one-shot, prints JSON
18
+ """
19
+
20
+ import argparse
21
+ import hashlib
22
+ import json
23
+ import logging
24
+ import os
25
+ import pathlib
26
+ import socket
27
+ import sys
28
+ import threading
29
+ import time
30
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
31
+
32
+ import torch
33
+ from torch import nn
34
+
35
+ log = logging.getLogger("lain-parser")
36
+ APP = pathlib.Path(__file__).resolve().parent
37
+ MAX_BODY = 64 * 1024
38
+
39
+
40
+ class Parser:
41
+ """One artifact, loaded once, guarded by a lock for the HTTP threads."""
42
+
43
+ def __init__(self, artifact, snapshot):
44
+ self.artifact = pathlib.Path(artifact).resolve()
45
+ self.snapshot = pathlib.Path(snapshot).resolve()
46
+ # The fp modules read the snapshot path at import time.
47
+ os.environ["ANIFILEBERT_SNAPSHOT"] = str(self.snapshot)
48
+ if str(APP) not in sys.path:
49
+ sys.path.insert(0, str(APP))
50
+ from fp import normalize
51
+ from fp.parser2 import collate
52
+ from fp.parser3_model import Parser as Model, decode_batch
53
+
54
+ self.normalize, self.collate, self.decode_batch = normalize, collate, decode_batch
55
+ config = json.loads((self.artifact / "config.json").read_text())
56
+ model = Model(config["representation"], config["head"],
57
+ char_classes=config.get("char_classes", False)).eval()
58
+ model = torch.ao.quantization.quantize_dynamic(model, {nn.Linear}, dtype=torch.qint8)
59
+ state = torch.load(self.artifact / "model.int8.pt", map_location="cpu",
60
+ weights_only=False)
61
+ model.load_state_dict(state)
62
+ model.eval()
63
+ self.model, self.config = model, config
64
+ self.decoder = "viterbi" if config["head"] == "bio" else "span"
65
+ self.limit = model.encoder.config.max_position_embeddings - 2
66
+ self.lock = threading.Lock()
67
+ self.warmup()
68
+
69
+ def digest(self):
70
+ return hashlib.sha256((self.artifact / "model.int8.pt").read_bytes()).hexdigest()
71
+
72
+ @torch.inference_mode()
73
+ def warmup(self):
74
+ self.parse("Example.Show.S01E01.1080p-GROUP")
75
+
76
+ @torch.inference_mode()
77
+ def parse(self, query):
78
+ """Return (record, spans, issues); record is None on abstention."""
79
+ if len(query) > self.limit:
80
+ return None, [], [], "over-encoder-limit"
81
+ row = {"query": query, "record": None, "spans": [], "language": "real",
82
+ "layout": "real", "view": "real", "family": "real"}
83
+ with self.lock:
84
+ item = self.model.encode(row)
85
+ batch, _ = self.collate([item], self.model.encoder.config.pad_token_id)
86
+ logits, chars = self.model(batch)
87
+ spans = self.decode_batch(self.model, logits, chars, [item], self.decoder)[0]
88
+ record, issues = self.normalize.gated_record(query, spans)
89
+ return record, spans, issues, None
90
+
91
+
92
+ class Handler(BaseHTTPRequestHandler):
93
+ server_version = "lain-parser/1.0"
94
+
95
+ def log_message(self, fmt, *args):
96
+ log.debug("%s %s", self.address_string(), fmt % args)
97
+
98
+ def _send(self, code, payload):
99
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
100
+ self.send_response(code)
101
+ self.send_header("Content-Type", "application/json; charset=utf-8")
102
+ self.send_header("Content-Length", str(len(body)))
103
+ self.end_headers()
104
+ self.wfile.write(body)
105
+
106
+ def do_GET(self):
107
+ if self.path != "/health":
108
+ self._send(404, {"error": "not-found"})
109
+ return
110
+ parser = self.server.parser
111
+ self._send(200, {"status": "ok", "artifact": parser.artifact.name,
112
+ "head": parser.config["head"],
113
+ "max_chars": parser.limit,
114
+ "uptime_s": round(time.time() - self.server.started, 1)})
115
+
116
+ def do_POST(self):
117
+ if self.path != "/parse":
118
+ self._send(404, {"error": "not-found"})
119
+ return
120
+ try:
121
+ length = int(self.headers.get("Content-Length") or 0)
122
+ except ValueError:
123
+ length = 0
124
+ if length <= 0 or length > MAX_BODY:
125
+ self._send(400, {"error": "empty-body"})
126
+ return
127
+ try:
128
+ payload = json.loads(self.rfile.read(length))
129
+ except (ValueError, UnicodeDecodeError):
130
+ self._send(400, {"error": "invalid-json"})
131
+ return
132
+ filename = payload.get("filename") if isinstance(payload, dict) else None
133
+ if not isinstance(filename, str) or not filename.strip():
134
+ self._send(400, {"error": "invalid-filename"})
135
+ return
136
+ began = time.perf_counter()
137
+ try:
138
+ record, spans, issues, limited = self.server.parser.parse(filename)
139
+ except ValueError as exc:
140
+ self._send(400, {"error": "invalid-filename", "detail": str(exc)})
141
+ return
142
+ except Exception: # never take the service down over one bad request
143
+ log.exception("parse failed for %r", filename[:120])
144
+ self._send(500, {"error": "internal"})
145
+ return
146
+ elapsed = round(1000 * (time.perf_counter() - began), 2)
147
+ log.info("parse %.2fms abstain=%s chars=%d", elapsed, record is None, len(filename))
148
+ self._send(200, {"artifact": self.server.parser.artifact.name,
149
+ "record": record, "spans": spans, "issues": issues,
150
+ "abstain_reason": limited, "latency_ms": elapsed})
151
+
152
+
153
+ def build_parsers(artifact, snapshot):
154
+ parser = Parser(artifact, snapshot)
155
+ log.info("artifact %s head=%s max_chars=%d sha256=%s", parser.artifact.name,
156
+ parser.config["head"], parser.limit, parser.digest()[:16])
157
+ return parser
158
+
159
+
160
+ class UnixHTTPServer(ThreadingHTTPServer):
161
+ """HTTP over a Unix domain socket, for containers that cannot reach the host.
162
+
163
+ A container with Lain's data directory bind-mounted sees the socket at
164
+ /data/parser.sock and needs no firewall rule or published port.
165
+ """
166
+
167
+ address_family = socket.AF_UNIX
168
+ daemon_threads = True
169
+
170
+ def server_bind(self):
171
+ path = self.server_address
172
+ if os.path.exists(path):
173
+ os.unlink(path)
174
+ self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
175
+ self.socket.bind(path)
176
+ os.chmod(path, 0o666) # the container's user is not our user
177
+ self.server_name = "lain-parser"
178
+
179
+ def server_activate(self):
180
+ self.socket.listen(self.request_queue_size)
181
+
182
+ def get_request(self):
183
+ request, _ = self.socket.accept()
184
+ return request, ("unix", 0)
185
+
186
+ def server_close(self):
187
+ super().server_close()
188
+ try:
189
+ os.unlink(self.server_address)
190
+ except OSError:
191
+ pass
192
+
193
+
194
+ def serve(hosts, port, parser, unix_path):
195
+ started = time.time()
196
+ servers = []
197
+ for host in hosts:
198
+ try:
199
+ server = ThreadingHTTPServer((host, port), Handler)
200
+ except OSError as exc:
201
+ log.warning("cannot bind %s:%d: %s", host, port, exc)
202
+ continue
203
+ server.parser = parser
204
+ server.started = started
205
+ servers.append(server)
206
+ if unix_path:
207
+ try:
208
+ server = UnixHTTPServer(unix_path, Handler)
209
+ except OSError as exc:
210
+ log.warning("cannot bind unix socket %s: %s", unix_path, exc)
211
+ else:
212
+ server.parser = parser
213
+ server.started = started
214
+ servers.append(server)
215
+ if not servers:
216
+ raise SystemExit("no bind address worked; is the port already in use?")
217
+ for server in servers:
218
+ threading.Thread(target=server.serve_forever, daemon=True).start()
219
+ if isinstance(server, UnixHTTPServer):
220
+ log.info("listening on unix://%s", server.server_address)
221
+ else:
222
+ log.info("listening on http://%s:%d", *server.server_address[:2])
223
+ try:
224
+ while True:
225
+ time.sleep(3600)
226
+ except KeyboardInterrupt:
227
+ log.info("shutting down")
228
+
229
+
230
+ def main(argv=None):
231
+ args = parse_args(argv)
232
+ logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
233
+ format="%(asctime)s %(levelname)s %(message)s")
234
+ parser = build_parsers(args.artifact, args.snapshot)
235
+ if args.check is not None:
236
+ record, spans, issues, limited = parser.parse(args.check)
237
+ print(json.dumps({"filename": args.check, "record": record, "spans": spans,
238
+ "issues": issues, "abstain_reason": limited},
239
+ ensure_ascii=False, indent=2))
240
+ return 0
241
+ serve([h.strip() for h in args.host.split(",") if h.strip()], args.port, parser,
242
+ args.unix_socket)
243
+ return 0
244
+
245
+
246
+ def parse_args(argv):
247
+ default_prefix = pathlib.Path(os.environ.get("LAIN_PARSER_PREFIX",
248
+ pathlib.Path.home() / ".local/share/lain-parser"))
249
+ ap = argparse.ArgumentParser(description=__doc__)
250
+ ap.add_argument("--artifact", default=str(default_prefix / "artifact/v3mix-bio-s1"))
251
+ ap.add_argument("--snapshot", default=str(default_prefix / "anifilebert"))
252
+ ap.add_argument("--host", default=os.environ.get("LAIN_PARSER_HOST", "127.0.0.1,172.17.0.1"),
253
+ help="comma-separated bind addresses")
254
+ ap.add_argument("--port", type=int, default=int(os.environ.get("LAIN_PARSER_PORT", "9377")))
255
+ ap.add_argument("--unix-socket", default=os.environ.get("LAIN_PARSER_SOCKET", ""),
256
+ help="also listen on this Unix socket path")
257
+ ap.add_argument("--check", metavar="FILENAME",
258
+ help="parse one filename, print JSON and exit")
259
+ ap.add_argument("--verbose", action="store_true")
260
+ return ap.parse_args(argv)
261
+
262
+
263
+ if __name__ == "__main__":
264
+ sys.exit(main())