"""Contract v2 normalizer: evidence spans -> emitted record, deterministically. Implements docs/LABELING-CONTRACT.md sections 3-7. This is the only permitted path from labels to an emitted record: annotators mark spans, this code derives the record. Hand-editing an output string is a contract violation, so the derivation has to be inspectable and testable on its own. Span dicts: `{'kind', 'start', 'end'}` plus optional `text` (must match the query bytes), `options` and `value`. Kinds: TITLE, EPISODE_TITLE, GROUP, YEAR, SEASON, EPISODE, RANGE, LIST, RESOLUTION, DIMENSIONS, DISCARD. `options` carries label-side decisions the contract refuses to guess from bytes: `is_title`, `fold_case`, `items`. `value` carries a normalized season number (None means an explicit-but-unbounded ordinal, which is omitted, never 0). `covered` may list extra [start, end) ranges (for example a path component carrying no field) so that the debris check is a fact about the label, not a guess about punctuation. Debris checking is opt-in because real corpora are partially labeled; a full-label row should turn it on. """ import re import unicodedata # A group name is copied verbatim from one bracket, so a collab credit such as # `ACB字幕组&天空字幕组` stays one string; two separate group spans conflict. MULTI_GROUP = re.compile(r"\]\s*\[") EXTENSION = re.compile(r"\.[A-Za-z0-9]{1,8}$") # These punctuation marks are used as structural delimiters by the controlled # format suites. They are not title characters at a predicted span edge; the # gold derivation still uses the original spans and never calls this trimmer. # The Unicode members were added after the consumed fresh-6 audit exposed # full-width/vertical delimiters bleeding into TITLE, SEASON, and EPISODE spans, # then extended for the fresh-7 delimiter inventory *before* fresh-7 was # generated, so no suite can be accused of picking delimiters after seeing the # trimmer. The list is duplicated verbatim in goport/normalize.go; both sides # are pinned by tests. ASCII "+" is deliberately absent: it occurs in real # titles ("C++") and would corrupt evidence to trim it. STRUCTURAL_EDGE_CHARS = frozenset( "#<>{}@|/∥|⁘❖【】『』⟦⟧‖‣⁓⧸⁄∕⋯»〔〕❪❫⦅⦆〘〙〖〗«》" ) # The roster above is only the manual half of the edge test. Any non-ASCII rune in the # dash, bracket or punctuation categories is an edge rune too, even when the roster # predates it: that is what stops the set from measuring which delimiters happened to be # enumerated before a suite shipped. ASCII runes are never widened this way -- ASCII # punctuation legitimately terminates role text ("Title...", "(Recut)", "Title '05'"), so # ASCII stays roster-only. _EDGE_CATEGORIES = frozenset({"Pd", "Po", "Ps", "Pe", "Pi", "Pf", "Pc"}) def is_structural_edge(ch: str) -> bool: """Whether `ch` may be trimmed from a *predicted* span edge. Gold spans never see this.""" if ord(ch) < 0x80: return ch in STRUCTURAL_EDGE_CHARS return ch in STRUCTURAL_EDGE_CHARS or unicodedata.category(ch) in _EDGE_CATEGORIES PATH_HASH_GROUP = re.compile( r"(?i)(?:[- ](?:subs?|raws?|team|works?|encode|unit|signal|depot|delta|plow|" r"cellar|marrow|nest|harbor|lantern|rip|release|group)|" r"(?:subs|raws|team|works|unit|signal|depot|delta|plow|cellar|marrow|nest|" r"harbor|lantern))$" ) # Contract §10: sidecars and documents are not media, even with a perfect # title. Containers (.rar/.zip/.7z/.torrent) are NOT listed: §10 parses them. NON_MEDIA_EXTENSIONS = frozenset({ "srt", "ass", "ssa", "vtt", "sub", "idx", "sup", "smi", # subtitles "jpg", "jpeg", "png", "gif", "webp", "bmp", "tbn", # images "nfo", "txt", "pdf", "epub", "docx", "doc", "md", "html", "htm", # documents "xml", "json", "log", "md5", "sfv", "sha1", "sha256", "par2", # checksums "gz", "tar", "bz2", "xz", # non-media archives }) def non_media_extension(query): """The final dot-suffix, lower-cased, when it names a non-media sidecar; otherwise None. Position-anchored like `EXTENSION`: only the true suffix counts, a `.srt` in the middle of a path is not a decision here.""" match = EXTENSION.search(query) if not match: return None ext = match.group(0)[1:].lower() return ext if ext in NON_MEDIA_EXTENSIONS else None def leading_hash_stem(query): """Whether a query has the titleless CRC-prefixed release-stem shape. This is intentionally narrower than ``query.startswith('[')``: the stem must have a hex token, two dash-separated fields, and a closed resolution class at the end. It is a decision-layer rule for rows whose only apparent title is a group-shaped token between a checksum and codec junk; ordinary bracket-prefixed titles and two-field releases remain untouched. """ if not re.match(r"^\[[0-9a-fA-F]{7,12}\]\s+", query): return False rest = query[query.find("]") + 1:].lstrip() fields = rest.split(" - ") if len(fields) != 3 or not all(fields[:2]): return False tail = fields[2] return bool(re.fullmatch(r"(?:480p|576p|720p|1080p|1080i|2160p)\.[A-Za-z0-9]{1,8}", tail, flags=re.IGNORECASE)) UNICODE_SPACE = re.compile("[   -    ]") DIGITS = re.compile(r"\d+(?:\.\d+)*") YEAR_LIKE = re.compile(r"\b\d{4}\b") OPEN_BRACKETS = "([{「『〈《【“「" CLOSE_BRACKETS = ")]}」』〉》】”」" BRACKETS = OPEN_BRACKETS + CLOSE_BRACKETS # Brackets are deliberately NOT edge-stripped: an unmatched bracket is either # lexical title content or a label error, and silently deleting one side of a # pair corrupts evidence. Only balanced pairs are removed (see normalize_text). STRIP_CHARS = " \t\r\n-–—~~·•::/\\." def _balanced(text): return all(text.count(open_) >= text.count(close) for open_, close in zip(OPEN_BRACKETS, CLOSE_BRACKETS)) def _strip_bracket_pairs(text): """Contract 3: brackets are removed only as balanced pairs enclosing the whole fragment. `ポコニャン(叮噹貓)` keeps its parentheses (they are inside the title), `「title」` and `「title」` lose them (they delimit it).""" while (len(text) >= 2 and text[0] in OPEN_BRACKETS and text[-1] in CLOSE_BRACKETS and _balanced(text[1:-1])): text = _fold(text[1:-1]).strip(STRIP_CHARS) return text SCHEMA_VALUES = {"480p", "576p", "720p", "1080p", "1080i", "2160p"} # Contract 7: a bare height is only HD evidence. SD heights are shared with # computer displays (640x480 VGA, 800x480) and non-square-pixel DVD captures, # so they never establish a broadcast class. HEIGHT_CLASS = {720: "720p", 1080: "1080p", 2160: "2160p"} MIN_ASPECT, MAX_ASPECT = 1.7, 1.95 # widescreen video frames only MAX_EPISODES = 64 YEAR_MIN, YEAR_MAX = 1870, 2099 # Contract 5 value resolution. Deliberately a *closed* vocabulary: if a season # marker is not in here, the row reports `season-unparsable` instead of guessing. # A model that emits the right span never has to know numbers, and this table is # the auditable place where text becomes a value. DIGITS = re.compile(r"\d+") FINAL_WORDS = ("final", "última", "ultimas", "last", "fin", "おしまい", "最終", "最后", "最终", "最後", "끝", "последн") ORDINAL_WORDS = { "first": 1, "second": 2, "third": 3, "fourth": 4, "fifth": 5, "sixth": 6, "seventh": 7, "eighth": 8, "ninth": 9, "tenth": 10, "primeira": 1, "segunda": 2, "terceira": 3, "quarta": 4, "quinta": 5, "primera": 1, "tercera": 3, "premiere": 1, "deuxieme": 2, "deuxième": 2, "troisieme": 3, "troisième": 3, "erste": 1, "zweite": 2, "dritte": 3, "primo": 1, "secundo": 2, "seconda": 2, "prima": 1, "terzo": 3, "uma": 1, "dois": 2, "tres": 3, "três": 3, "cuarta": 4, "quinta": 5, "sexta": 6, "fifth": 5, "первый": 1, "второй": 2, "третий": 3, "четвёртый": 4, "пятый": 5, "шестой": 6, "седьмой": 7, "восьмой": 8, "девятый": 9, "десятый": 10, } CJK_NUMERALS = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, "十": 10, "壹": 1, "貳": 2, "叁": 3} def season_value(text): """int, None (=explicit season with no number), or 'unparsable'. Digits win over words: `2nd Season` and `第2期` are the same value. A multi-number marker (`Season 2 of 12`, `S01E02` mistagged) is unparsable rather than a pick-one, because either choice is a guess about the work. """ folded = normalize_text(text, fold_case=True) if any(word in folded for word in FINAL_WORDS): return None numbers = DIGITS.findall(folded) if len(numbers) == 1: value = int(numbers[0]) return value if 0 <= value <= 99 else "unparsable" if len(numbers) > 1: return "unparsable" for word in folded.split(" "): if word in ORDINAL_WORDS: return ORDINAL_WORDS[word] runs = [c for c in folded if c in CJK_NUMERALS] if len(runs) == 1: return CJK_NUMERALS[runs[0]] return "unparsable" def _fold(text): """Contract 3: underscores and exotic whitespace become spaces, runs collapse, nothing is transliterated. Dots are handled separately, because whether one is a delimiter depends on its neighbours (see `fold_dot_delimiters`).""" text = UNICODE_SPACE.sub(" ", text) text = text.replace("_", " ") text = re.sub(r"[-–—]{2,}", "-", text) return re.sub(r" {2,}", " ", text).strip() ALPHA = re.compile(r"[^\W\d_]+\Z", re.UNICODE) NUMERIC = re.compile(r"\d+\Z") def _segment_kind(unit): if not unit: return "empty" if ALPHA.match(unit): return "word" if NUMERIC.match(unit): return "number" return "mixed" def fold_dot_delimiters(text): """Contract 3.2, title fragments only, decided dot by dot inside each whitespace token, where a segment is a *word* (letters of any script), a *number* (digits only), *mixed* (letters and digits together: `1080p`, `2a`, `S01`, `x264`) or *empty* (a doubled or trailing dot). A dot splits when either neighbour is a word (`CITY.HUNTER` -> `CITY HUNTER`, `Akane.1988` -> `Akane 1988`, `Movie.2.Innocence` -> `Movie 2 Innocence`), when the right side is mixed (`Ghost.1080p` -> `Ghost 1080p`: a technical tag is never title text, and a split that only touches title-adjacent junk cannot lose evidence), or when a side is empty (`Dr.`, `Ghost.` lose the delimiter dot). The one shape that is never cut is `number . number` before any word has appeared in the token: `1917.2019` and `2.43.121` survive as single tokens, because a run that opens with digits reads as an encoded value and splitting it would be a guess about which part is the title. Single letters are words, so `A.K.A.` folds to `A K A`. Consequence: a title that genuinely contains a digit-dot-digit sequence must be labeled as two fragments. That is the honest cost of a rule an annotator can apply without knowing the work.""" pieces = [] for token in text.split(" "): segments = token.split(".") if len(segments) == 1: if token: pieces.append(token) continue kinds = [_segment_kind(s) for s in segments] cut, word_seen = [0], False for index in range(1, len(segments)): previous, current = kinds[index - 1], kinds[index] touches_word = previous == "word" or current == "word" if current in ("mixed", "empty") or touches_word: cut.append(index) # a word or junk boundary: split here elif previous == "number" and current == "number" and word_seen: cut.append(index) # `Akane.1988.2020` splits; `1917.2019` does not word_seen = word_seen or touches_word for boundary, edge in zip(cut, cut[1:] + [len(segments)]): unit = ".".join(segments[boundary:edge]) if unit: pieces.append(unit) text = " ".join(pieces) return re.sub(r" {2,}", " ", text).strip() def trim_structural_edges(query, spans): """Trim structural delimiter runes from predicted span edges. This is deliberately prediction-only. Gold labels are never rewritten: a label span that owns `#`, `<`, `>`, `{`, `}`, `@`, or `|` remains lossless. The trimmer handles a decoder that includes a neighboring delimiter in a role span, and drops a span made only of delimiters. """ out = [] changed = 0 for span in spans: start, end = span["start"], span["end"] first, last = start, end while first < last and is_structural_edge(query[first]): first += 1 while last > first and is_structural_edge(query[last - 1]): last -= 1 if first != start or last != end: changed += 1 if first < last: out.append({**span, "start": first, "end": last}) return out, changed def titleless_path_group(query, spans): """Narrow prediction gate for ``(hash)/junk/Group/res`` stems. The parenthesized hash, slash path, and group-like title suffix must all be present, with no episode, season, year, or episode-title evidence. A positive row with any additional title evidence is left untouched. """ if not re.match(r"^\([0-9a-fA-F]{7,12}\)", query) or "/" not in query: return False titles = [span for span in spans if span["kind"] == "TITLE"] evidence = [span for span in spans if span["kind"] in ("EPISODE", "EPISODE_TITLE", "SEASON", "YEAR")] if len(titles) != 1 or evidence: return False return bool(PATH_HASH_GROUP.search(query[titles[0]["start"]:titles[0]["end"]].strip())) def normalize_text(text, is_title=False, fold_case=False, keep_edges=False): """`keep_edges=True` is for an already-assembled title: an edge bracket or hyphen there belongs to an adjacent fragment, so edge stripping is skipped and only byte-internal rules (separators, whitespace, delimiter dots) apply. Fragments must not use it - that is where delimiters are removed.""" text = _fold(text) if not keep_edges: text = text.strip(STRIP_CHARS) text = _strip_bracket_pairs(text) text = text.strip(STRIP_CHARS) if fold_case: text = text.lower() if is_title: text = fold_dot_delimiters(text) return _fold(text) def _range_endpoints(text): """Return (None, (lo, hi), issues). Repeated season prefixes (`S01E01-S01E03`) and embedded technical values (`[01 ~ 24][1080p]`, `1280x720`) are removed first, so the endpoints are the only numbers left. Endpoints are 1-3 digits: a 4-digit number is a year or a checksum, not an episode index.""" stripped = re.sub(r"(?i)s\d+", " ", text) stripped = re.sub(r"(?i)\d{3,5}\s*[x×]\s*\d{3,5}|\d{3,4}[pi]\b", " ", stripped) nums = [int(n) for n in re.findall(r"\d{1,3}", stripped)] if not nums: return None, None, ["range-no-digits"] if len(nums) > 2: return None, None, ["range-too-complex"] if len(nums) == 1: return None, None, ["range-single-value"] if not re.search(r"[-~–—至到]", text): return None, None, ["range-no-connector"] return None, (nums[0], nums[-1]), [] def expand_range(text, cap=MAX_EPISODES): _, endpoints, issues = _range_endpoints(text) if issues: return [], issues a, b = endpoints if a == b: issues.append("range-single-value") lo, hi = min(a, b), max(a, b) if hi - lo + 1 > cap: return [], ["list-too-long"] if hi - lo + 1 > 200: issues.append("range-suspiciously-long") return list(range(lo, hi + 1)), issues def expand_list(span, text=None): """Item values come from the label side when there is one: re-reading them from bytes would reintroduce exactly the ambiguity the contract says to quarantine. A *predicted* span has no label side, so for those the digits inside the claimed boundary are read - the model's claim is the boundary. """ values = span.get("options", {}).get("items") if values is None: values = re.findall(r"\d+", text if text is not None else span["text"]) values = sorted({int(v) for v in values}) if not values: return [], ["list-empty"] if len(values) > MAX_EPISODES: return [], ["list-too-long"] return values, [] def debris(query, claims): """Unclaimed, non-punctuation text: what a full-label row cannot explain. Only the container suffix at the very end of the query is excused, and it is excused by *position*, not by appearance: a `.720p` left unclaimed in the middle of a name is a missing decision and must be reported even though it looks like an extension. Stripping dot-suffixes from the leftovers instead silently forgives exactly the technical tags that strict rows exist to catch.""" mask = bytearray(len(query)) suffix = EXTENSION.search(query) # anchored at `$`, so this is the final suffix for start, end in list(claims) + ([(suffix.start(), suffix.end())] if suffix else []): for i in range(max(0, min(start, len(query))), min(max(0, end), len(query))): mask[i] = 1 text = "".join(ch for i, ch in enumerate(query) if not mask[i]) # Contract 3.3: a run of underscores or hyphens that only delimits fields is # debris the pipeline removes, not text that needs a recorded decision. `_` # counted as a word character here, which made every underscored layout look # unexplainable. text = text.replace("_", " ") return [part for part in re.split(r"[^\w]+", text, flags=re.UNICODE) if part] def record(query, spans, *, covered=None, check_debris=False): """Derive the emitted record from labeled spans. Returns (record|None, issues). Issues never silently change a value; conflicting or unrepresentable evidence is reported so the row can be quarantined.""" issues = [] claims = [(s["start"], s["end"]) for s in spans] + list(covered or []) title, episode_title, episodes = [], [], [] group = year = season = resolution = dimensions = None for span in sorted(spans, key=lambda s: (s["start"], s["end"])): kind = span["kind"] text = span.get("text") if text is None: text = query[span["start"]:span["end"]] elif query[span["start"]:span["end"]] != text: issues.append("span-text-mismatch") opts = span.get("options", {}) if kind in ("TITLE", "EPISODE_TITLE"): # Which fragment is the work title and which is the episode title is # the annotator's decision, recorded as a span kind. Position cannot # decide it: a second TITLE fragment is usually the same work title # in another script, while a second EPISODE_TITLE fragment is the # next segment of one episode's name. bucket = title if kind == "TITLE" else episode_title value = normalize_text(text, is_title=True, fold_case=opts.get("fold_case", False)) if value: bucket.append(value) else: issues.append("empty-title-fragment") elif kind == "GROUP": value = normalize_text(text) if group is not None: issues.append("group-multiple-brackets") elif MULTI_GROUP.search(value): issues.append("group-multiple-names") group = group or value elif kind == "YEAR": digits = YEAR_LIKE.findall(text) if len(digits) != 1: issues.append("year-ambiguous") continue value = int(digits[0]) if not YEAR_MIN <= value <= YEAR_MAX: issues.append("year-out-of-range") elif year is not None and year != value: issues.append("year-conflict") else: year = value elif kind == "SEASON": if "value" in span: value = span["value"] # annotator-supplied (contract 5, worksheet) else: value = season_value(text) # predicted span: text decides, not a head if value == "unparsable": issues.append("season-unparsable") continue if value is None: issues.append("season-unspecified") elif season is not None and season != value: issues.append("season-conflict") elif not 0 <= value <= 99: issues.append("season-out-of-range") else: season = value elif kind == "EPISODE": nums = re.findall(r"\d+", text) if len(nums) != 1: issues.append("episode-ambiguous") else: episodes.append(int(nums[0])) elif kind == "RANGE": values, extra = expand_range(text) issues += extra episodes += values elif kind == "LIST": values, extra = expand_list(span, text) issues += extra episodes += values elif kind == "RESOLUTION": value = normalize_text(text, fold_case=True) if value not in SCHEMA_VALUES: issues.append("resolution-unknown-class") elif resolution and resolution != value: issues.append("resolution-conflict") else: resolution = value elif kind == "DIMENSIONS": match = re.search(r"(\d{3,5})\s*[x×]\s*(\d{3,5})", text, re.I) if not match: issues.append("dimensions-unreadable") continue width, height = int(match.group(1)), int(match.group(2)) value = HEIGHT_CLASS.get(height) if MIN_ASPECT <= width / height <= MAX_ASPECT else None if value is None: issues.append("dimensions-no-class") elif resolution and resolution != value: issues.append("resolution-conflict") else: resolution, dimensions = value, text elif kind != "DISCARD": issues.append("unhandled-kind:" + kind) if check_debris: left = debris(query, claims) if left: issues.append("unexplained-debris:" + "|".join(left[:6])) out = {} if title: # join=True: an edge bracket here belongs to an adjacent fragment, so # only byte-internal rules apply to the assembled title. out["title"] = normalize_text(" ".join(title), is_title=True, keep_edges=True) if episode_title: out["episode_title"] = normalize_text(" ".join(episode_title), is_title=True, keep_edges=True) if year is not None: out["year"] = year if season is not None: out["season"] = season if episodes: values = sorted(set(episodes)) if len(values) > MAX_EPISODES: # Contract 9: never silently truncate; the row is quarantined. issues.append("list-too-long") out["episodes"] = values if group: out["release_group"] = group if resolution: out["resolution"] = resolution # `required: title` means a title-less record is the same output as no call. return (out if out.get("title") else None), issues def gated_record(query, spans, **kwargs): """`record` followed by the decision layer applied at prediction time. A non-media suffix or the narrow CRC-prefixed titleless release-stem shape forces abstention and reports why in the issues list. """ ext = non_media_extension(query) trimmed, trimmed_count = trim_structural_edges(query, spans) out, issues = record(query, trimmed, **kwargs) if trimmed_count: issues = issues + [f"trimmed-structural-edges:{trimmed_count}"] if out is not None and ext is not None: return None, issues + [f"gated-non-media-extension:{ext}"] if out is not None and leading_hash_stem(query): return None, issues + ["gated-leading-hash-stem"] if out is not None and titleless_path_group(query, trimmed): return None, issues + ["gated-titleless-path-group"] return out, issues