Upload folder using huggingface_hub
Browse files- app/PROVENANCE.json +40 -0
- app/fp/__init__.py +6 -0
- app/fp/charclass.py +92 -0
- app/fp/corpus2.py +777 -0
- app/fp/normalize.py +544 -0
- app/fp/parser2.py +891 -0
- app/fp/parser3_model.py +420 -0
- app/serve.py +264 -0
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())
|