ModerRAS commited on
Commit
7509455
·
1 Parent(s): de3e6cb

Add DMHY annotation workflow helpers

Browse files
tools/annotate_dmhy_dag_agent.py ADDED
@@ -0,0 +1,1034 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run a small DAG-aware LLM annotation pass for DMHY filename suffixes.
2
+
3
+ This runner is intentionally separate from the prefix-tree annotation scripts.
4
+ It selects shared DAG suffix units, asks an OpenAI-compatible Responses API for
5
+ strict JSON annotations, falls back to deterministic heuristics on failure, and
6
+ emits both terminal patch rows and dmhy_weak-compatible sample records.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import sys
15
+ import time
16
+ import urllib.error
17
+ import urllib.request
18
+ from concurrent.futures import ThreadPoolExecutor, as_completed
19
+ from dataclasses import dataclass
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any, Iterable
23
+
24
+ from anifilebert.tokenizer import AnimeTokenizer
25
+ from tools.annotate_dmhy_prefix_graph import (
26
+ heuristic_patch,
27
+ normalize_generated_tokens,
28
+ string_list,
29
+ unique_keep_order,
30
+ write_jsonl,
31
+ )
32
+ from tools.dmhy_dataset import weak_label_filename
33
+
34
+
35
+ DEFAULT_DAG = Path("datasets/AnimeName/dmhy_prefix_dag.json")
36
+ DEFAULT_OUTPUT = Path("datasets/AnimeName/dmhy_weak.dag_sample.jsonl")
37
+ DEFAULT_PATCH_OUTPUT = Path("datasets/AnimeName/dmhy_prefix_dag.annotations.dag_sample.jsonl")
38
+ DEFAULT_UNITS_OUTPUT = Path("datasets/AnimeName/dmhy_prefix_dag.annotation_units.dag_sample.jsonl")
39
+ DEFAULT_BASE_URL = "http://10.137.32.209/v1"
40
+ DEFAULT_MODEL = "gpt-5.4-mini"
41
+ LLM_SOURCE = "responses-dag-v1"
42
+ HEURISTIC_SOURCE = "heuristic-dag-v1"
43
+ COVERAGE_SHARED_ONLY = "shared-only"
44
+ COVERAGE_ALL_TERMINALS = "all-terminals"
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Args:
49
+ dag: Path
50
+ output: Path
51
+ patch_output: Path
52
+ units_output: Path | None
53
+ limit: int
54
+ max_requests: int
55
+ units_per_request: int
56
+ max_context_units: int
57
+ workers: int
58
+ coverage_mode: str
59
+ min_incoming_count: int
60
+ min_reachable_terminals: int
61
+ max_reachable_terminals: int | None
62
+ example_count: int
63
+ max_records: int
64
+ records_per_terminal: int
65
+ terminal_sink_threshold: int
66
+ terminal_cluster_size: int
67
+ llm: bool
68
+ base_url: str
69
+ api_key: str | None
70
+ model: str
71
+ http_timeout: int
72
+ retries: int
73
+ preserve_i_labels: bool
74
+ resume: bool
75
+
76
+
77
+ def parse_args() -> Args:
78
+ parser = argparse.ArgumentParser(
79
+ description="Annotate a small sample of shared DMHY prefix-DAG suffix units"
80
+ )
81
+ parser.add_argument("--dag", type=Path, default=DEFAULT_DAG)
82
+ parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
83
+ parser.add_argument("--patch-output", type=Path, default=DEFAULT_PATCH_OUTPUT)
84
+ parser.add_argument(
85
+ "--units-output",
86
+ default=str(DEFAULT_UNITS_OUTPUT),
87
+ help="Optional selected annotation units JSONL; use empty string to disable",
88
+ )
89
+ parser.add_argument("--limit", type=int, default=20, help="Maximum DAG units to annotate")
90
+ parser.add_argument("--max-requests", type=int, default=20, help="Maximum LLM requests")
91
+ parser.add_argument("--units-per-request", type=int, default=10, help="DAG units per Responses API request")
92
+ parser.add_argument(
93
+ "--max-context-units",
94
+ type=int,
95
+ default=20,
96
+ help="Maximum unit contexts included in one Responses API request",
97
+ )
98
+ parser.add_argument("--workers", type=int, default=8, help="Concurrent LLM workers")
99
+ parser.add_argument(
100
+ "--coverage-mode",
101
+ choices=[COVERAGE_SHARED_ONLY, COVERAGE_ALL_TERMINALS],
102
+ default=COVERAGE_SHARED_ONLY,
103
+ help="Select only shared DAG units, or cover every terminal with terminal_cluster fallback units",
104
+ )
105
+ parser.add_argument("--min-incoming-count", type=int, default=2)
106
+ parser.add_argument("--min-reachable-terminals", type=int, default=2)
107
+ parser.add_argument(
108
+ "--max-reachable-terminals",
109
+ type=int,
110
+ default=500,
111
+ help="Skip very large shared units for sample runs; use 0 to disable",
112
+ )
113
+ parser.add_argument("--example-count", type=int, default=8)
114
+ parser.add_argument("--max-records", type=int, default=200, help="Maximum dataset records to emit")
115
+ parser.add_argument("--records-per-terminal", type=int, default=1)
116
+ parser.add_argument(
117
+ "--terminal-sink-threshold",
118
+ type=int,
119
+ default=1000,
120
+ help="Do not collect terminals from no-outgoing sink nodes above this size",
121
+ )
122
+ parser.add_argument(
123
+ "--terminal-cluster-size",
124
+ type=int,
125
+ default=1,
126
+ help="Number of uncovered terminals per all-terminals fallback cluster",
127
+ )
128
+ parser.add_argument("--llm", action="store_true", help="Use the Responses API; otherwise heuristics only")
129
+ parser.add_argument(
130
+ "--base-url",
131
+ default=os.environ.get("ANIFILEBERT_LLM_BASE_URL", DEFAULT_BASE_URL),
132
+ help="OpenAI-compatible API base URL",
133
+ )
134
+ parser.add_argument(
135
+ "--api-key",
136
+ default=os.environ.get("ANIFILEBERT_LLM_API_KEY"),
137
+ help="API key; defaults to ANIFILEBERT_LLM_API_KEY",
138
+ )
139
+ parser.add_argument("--model", default=DEFAULT_MODEL)
140
+ parser.add_argument("--http-timeout", type=int, default=120)
141
+ parser.add_argument("--retries", type=int, default=3)
142
+ parser.add_argument("--preserve-i-labels", action="store_true")
143
+ parser.add_argument("--resume", action="store_true", help="Skip ok/fallback unit_ids already in patch output")
144
+ ns = parser.parse_args()
145
+ units_arg = str(ns.units_output).strip()
146
+ return Args(
147
+ dag=ns.dag,
148
+ output=ns.output,
149
+ patch_output=ns.patch_output,
150
+ units_output=Path(units_arg) if units_arg else None,
151
+ limit=max(0, ns.limit),
152
+ max_requests=max(0, ns.max_requests),
153
+ units_per_request=max(1, ns.units_per_request),
154
+ max_context_units=max(1, ns.max_context_units),
155
+ workers=max(1, ns.workers),
156
+ coverage_mode=ns.coverage_mode,
157
+ min_incoming_count=max(1, ns.min_incoming_count),
158
+ min_reachable_terminals=max(1, ns.min_reachable_terminals),
159
+ max_reachable_terminals=None if ns.max_reachable_terminals <= 0 else ns.max_reachable_terminals,
160
+ example_count=max(1, ns.example_count),
161
+ max_records=max(0, ns.max_records),
162
+ records_per_terminal=max(1, ns.records_per_terminal),
163
+ terminal_sink_threshold=max(1, ns.terminal_sink_threshold),
164
+ terminal_cluster_size=max(1, ns.terminal_cluster_size),
165
+ llm=ns.llm,
166
+ base_url=ns.base_url,
167
+ api_key=ns.api_key,
168
+ model=ns.model,
169
+ http_timeout=max(1, ns.http_timeout),
170
+ retries=max(1, ns.retries),
171
+ preserve_i_labels=ns.preserve_i_labels,
172
+ resume=ns.resume,
173
+ )
174
+
175
+
176
+ def load_dag(path: Path) -> dict[str, Any]:
177
+ if not path.exists():
178
+ raise SystemExit(f"DAG not found: {path}")
179
+ try:
180
+ dag = json.loads(path.read_text(encoding="utf-8"))
181
+ except json.JSONDecodeError as exc:
182
+ raise SystemExit(f"invalid DAG JSON in {path}: {exc}") from exc
183
+ if not isinstance(dag, dict) or not isinstance(dag.get("nodes"), list):
184
+ raise SystemExit(f"invalid DAG schema in {path}: missing nodes list")
185
+ if not isinstance(dag.get("terminals"), list):
186
+ raise SystemExit(f"invalid DAG schema in {path}: missing terminals list")
187
+ return dag
188
+
189
+
190
+ def int_field(row: dict[str, Any], key: str, default: int = 0) -> int:
191
+ try:
192
+ return int(row.get(key, default) or default)
193
+ except (TypeError, ValueError):
194
+ return default
195
+
196
+
197
+ def node_id(node: dict[str, Any], fallback: int) -> int:
198
+ try:
199
+ return int(node.get("id", fallback))
200
+ except (TypeError, ValueError):
201
+ raise SystemExit(f"invalid node id: {node.get('id')!r}") from None
202
+
203
+
204
+ def terminal_id(terminal: dict[str, Any], fallback: int) -> str:
205
+ value = terminal.get("terminal_id", terminal.get("id", fallback))
206
+ return str(value)
207
+
208
+
209
+ def has_outgoing_edges(node: dict[str, Any]) -> bool:
210
+ return any(isinstance(edge, dict) for edge in node.get("children") or [])
211
+
212
+
213
+ def edge_labels(node: dict[str, Any], limit: int) -> list[str]:
214
+ labels: list[str] = []
215
+ for edge in node.get("children") or []:
216
+ if isinstance(edge, dict) and edge.get("label") is not None:
217
+ labels.append(str(edge["label"]))
218
+ return unique_keep_order(labels)[:limit]
219
+
220
+
221
+ def build_indexes(
222
+ dag: dict[str, Any],
223
+ ) -> tuple[dict[int, dict[str, Any]], dict[int, list[dict[str, Any]]]]:
224
+ nodes: dict[int, dict[str, Any]] = {}
225
+ for index, node in enumerate(dag["nodes"]):
226
+ if isinstance(node, dict):
227
+ nodes[node_id(node, index)] = node
228
+
229
+ terminals_by_node: dict[int, list[dict[str, Any]]] = {}
230
+ for index, terminal in enumerate(dag["terminals"]):
231
+ if not isinstance(terminal, dict):
232
+ continue
233
+ try:
234
+ terminal_node_id = int(terminal.get("node_id"))
235
+ except (TypeError, ValueError):
236
+ continue
237
+ row = dict(terminal)
238
+ row["_terminal_id"] = terminal_id(row, index)
239
+ row["_terminal_index"] = index
240
+ terminals_by_node.setdefault(terminal_node_id, []).append(row)
241
+ return nodes, terminals_by_node
242
+
243
+
244
+ class ReachableTerminals:
245
+ def __init__(
246
+ self,
247
+ nodes: dict[int, dict[str, Any]],
248
+ terminals_by_node: dict[int, list[dict[str, Any]]],
249
+ terminal_sink_threshold: int,
250
+ ) -> None:
251
+ self.nodes = nodes
252
+ self.terminals_by_node = terminals_by_node
253
+ self.terminal_sink_threshold = terminal_sink_threshold
254
+ self.memo: dict[int, list[dict[str, Any]]] = {}
255
+ self.visiting: set[int] = set()
256
+
257
+ def get(self, start: int) -> list[dict[str, Any]]:
258
+ if start in self.memo:
259
+ return self.memo[start]
260
+ if start in self.visiting:
261
+ raise SystemExit(f"cycle detected while traversing DAG at node {start}")
262
+ self.visiting.add(start)
263
+ node = self.nodes.get(start, {})
264
+ local_terminals = self.terminals_by_node.get(start, [])
265
+ is_large_terminal_sink = not has_outgoing_edges(node) and len(local_terminals) >= self.terminal_sink_threshold
266
+ found = [] if is_large_terminal_sink else list(local_terminals)
267
+ for edge in node.get("children") or []:
268
+ if not isinstance(edge, dict):
269
+ continue
270
+ try:
271
+ found.extend(self.get(int(edge.get("target"))))
272
+ except (TypeError, ValueError):
273
+ continue
274
+ self.visiting.remove(start)
275
+ deduped = dedupe_terminals(found)
276
+ self.memo[start] = deduped
277
+ return deduped
278
+
279
+
280
+ def dedupe_terminals(terminals: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
281
+ seen: set[str] = set()
282
+ result: list[dict[str, Any]] = []
283
+ for terminal in terminals:
284
+ tid = str(terminal.get("_terminal_id") or terminal.get("terminal_id") or "")
285
+ if not tid or tid in seen:
286
+ continue
287
+ seen.add(tid)
288
+ result.append(terminal)
289
+ return result
290
+
291
+
292
+ def aggregate_examples(terminals: Iterable[dict[str, Any]], key: str, limit: int) -> list[str]:
293
+ values: list[str] = []
294
+ for terminal in terminals:
295
+ if key == "prefix":
296
+ value = terminal.get("prefix")
297
+ if value is not None:
298
+ values.append(str(value))
299
+ else:
300
+ values.extend(string_list(terminal.get(key)))
301
+ return unique_keep_order(values)[:limit]
302
+
303
+
304
+ def aggregate_terminal_field(terminals: Iterable[dict[str, Any]], key: str, limit: int) -> list[str]:
305
+ values: list[str] = []
306
+ for terminal in terminals:
307
+ value = terminal.get(key)
308
+ if value is not None:
309
+ values.append(str(value))
310
+ return unique_keep_order(values)[:limit]
311
+
312
+
313
+ def heuristic_unit_patch(unit: dict[str, Any]) -> dict[str, Any]:
314
+ episode_titles: list[str] = []
315
+ media_suffixes: list[str] = []
316
+ title_candidates: list[str] = []
317
+ needs_review = False
318
+ for terminal in unit["_terminals"]:
319
+ patch = heuristic_patch(terminal, int(terminal.get("_terminal_index", 0)))
320
+ episode_titles.extend(patch["episode_title_suffixes"])
321
+ media_suffixes.extend(patch["media_suffixes"])
322
+ title_candidates.extend(patch["title_candidates"])
323
+ needs_review = needs_review or bool(patch["needs_llm_review"])
324
+ return {
325
+ "unit_id": unit["unit_id"],
326
+ "terminal_ids": unit["terminal_ids"],
327
+ "episode_title_suffixes": unique_keep_order(episode_titles),
328
+ "media_suffixes": unique_keep_order(media_suffixes),
329
+ "title_candidates": unique_keep_order(title_candidates),
330
+ "llm_label": None,
331
+ "notes": f"heuristic fallback; terminals={len(unit['terminal_ids'])}; needs_review={needs_review}",
332
+ }
333
+
334
+
335
+ def make_unit(
336
+ node: dict[str, Any],
337
+ node_id_value: int,
338
+ terminals: list[dict[str, Any]],
339
+ example_count: int,
340
+ ) -> dict[str, Any]:
341
+ terminal_ids = [str(terminal["_terminal_id"]) for terminal in terminals]
342
+ reachable_weight = int_field(node, "reachable_weight")
343
+ if reachable_weight <= 0:
344
+ reachable_weight = sum(int_field(terminal, "weight", int_field(terminal, "count", 1)) for terminal in terminals)
345
+ return {
346
+ "unit_id": f"dag-node-{node_id_value}",
347
+ "node_id": node_id_value,
348
+ "kind": "shared_suffix",
349
+ "incoming_count": int_field(node, "incoming_count"),
350
+ "reachable_terminals": len(terminals),
351
+ "reachable_weight": reachable_weight,
352
+ "terminal_ids": terminal_ids,
353
+ "prefix_examples": aggregate_examples(terminals, "prefix", example_count),
354
+ "digit_skeleton_examples": aggregate_terminal_field(terminals, "digit_skeleton", example_count),
355
+ "value_examples": aggregate_examples(terminals, "value_examples", example_count),
356
+ "suffix_examples": aggregate_examples(terminals, "suffix_examples", example_count),
357
+ "common_edge_labels": edge_labels(node, example_count),
358
+ "_terminals": terminals,
359
+ }
360
+
361
+
362
+ def make_terminal_cluster_unit(
363
+ terminals: list[dict[str, Any]],
364
+ cluster_index: int,
365
+ example_count: int,
366
+ ) -> dict[str, Any]:
367
+ terminal_ids = [str(terminal["_terminal_id"]) for terminal in terminals]
368
+ reachable_weight = sum(int_field(terminal, "weight", int_field(terminal, "count", 1)) for terminal in terminals)
369
+ return {
370
+ "unit_id": f"terminal-cluster-{cluster_index}",
371
+ "node_id": None,
372
+ "kind": "terminal_cluster",
373
+ "incoming_count": 0,
374
+ "reachable_terminals": len(terminals),
375
+ "reachable_weight": reachable_weight,
376
+ "terminal_ids": terminal_ids,
377
+ "prefix_examples": aggregate_examples(terminals, "prefix", example_count),
378
+ "digit_skeleton_examples": aggregate_terminal_field(terminals, "digit_skeleton", example_count),
379
+ "value_examples": aggregate_examples(terminals, "value_examples", example_count),
380
+ "suffix_examples": aggregate_examples(terminals, "suffix_examples", example_count),
381
+ "common_edge_labels": [],
382
+ "_terminals": terminals,
383
+ }
384
+
385
+
386
+ def all_terminals(dag: dict[str, Any]) -> list[dict[str, Any]]:
387
+ terminals: list[dict[str, Any]] = []
388
+ for index, terminal in enumerate(dag["terminals"]):
389
+ if not isinstance(terminal, dict):
390
+ continue
391
+ row = dict(terminal)
392
+ row["_terminal_id"] = terminal_id(row, index)
393
+ row["_terminal_index"] = index
394
+ terminals.append(row)
395
+ return dedupe_terminals(terminals)
396
+
397
+
398
+ def candidate_shared_units(
399
+ dag: dict[str, Any],
400
+ args: Args,
401
+ ) -> list[dict[str, Any]]:
402
+ nodes, terminals_by_node = build_indexes(dag)
403
+ reachable = ReachableTerminals(nodes, terminals_by_node, args.terminal_sink_threshold)
404
+ root = int(dag.get("root", 0) or 0)
405
+ candidates: list[tuple[tuple[int, int, int, int], int, dict[str, Any]]] = []
406
+ for current_id, node in nodes.items():
407
+ if current_id == root or not has_outgoing_edges(node):
408
+ continue
409
+ incoming_count = int_field(node, "incoming_count")
410
+ reachable_count = int_field(node, "reachable_terminals")
411
+ if incoming_count < args.min_incoming_count:
412
+ continue
413
+ if reachable_count < args.min_reachable_terminals:
414
+ continue
415
+ if args.max_reachable_terminals is not None and reachable_count > args.max_reachable_terminals:
416
+ continue
417
+ sort_key = (-incoming_count, -int_field(node, "reachable_weight"), -reachable_count, current_id)
418
+ candidates.append((sort_key, current_id, node))
419
+
420
+ candidates.sort(key=lambda item: item[0])
421
+ candidates.sort(key=lambda item: item[0])
422
+ units: list[dict[str, Any]] = []
423
+ for _sort_key, current_id, node in candidates:
424
+ terminals = reachable.get(current_id)
425
+ if not terminals:
426
+ continue
427
+ units.append(make_unit(node, current_id, terminals, args.example_count))
428
+ return units
429
+
430
+
431
+ def unique_terminal_coverage(units: Iterable[dict[str, Any]]) -> set[str]:
432
+ covered: set[str] = set()
433
+ for unit in units:
434
+ covered.update(str(terminal_id_value) for terminal_id_value in unit.get("terminal_ids") or [])
435
+ return covered
436
+
437
+
438
+ def shared_only_units(dag: dict[str, Any], args: Args) -> list[dict[str, Any]]:
439
+ limit = args.limit if args.limit > 0 else 0
440
+ return candidate_shared_units(dag, args)[:limit]
441
+
442
+
443
+ def all_terminal_units(dag: dict[str, Any], args: Args) -> list[dict[str, Any]]:
444
+ terminals = all_terminals(dag)
445
+ terminals_by_id = {str(terminal["_terminal_id"]): terminal for terminal in terminals}
446
+ selected: list[dict[str, Any]] = []
447
+ covered: set[str] = set()
448
+
449
+ for unit in candidate_shared_units(dag, args):
450
+ unit_terminal_ids = set(unit["terminal_ids"])
451
+ if not unit_terminal_ids:
452
+ continue
453
+ if unit_terminal_ids & covered:
454
+ continue
455
+ selected.append(unit)
456
+ covered.update(unit_terminal_ids)
457
+
458
+ uncovered = [terminal for terminal in terminals if str(terminal["_terminal_id"]) not in covered]
459
+ for start in range(0, len(uncovered), args.terminal_cluster_size):
460
+ cluster_terminals = uncovered[start : start + args.terminal_cluster_size]
461
+ selected.append(
462
+ make_terminal_cluster_unit(
463
+ cluster_terminals,
464
+ cluster_index=(start // args.terminal_cluster_size) + 1,
465
+ example_count=args.example_count,
466
+ )
467
+ )
468
+ covered.update(str(terminal["_terminal_id"]) for terminal in cluster_terminals)
469
+
470
+ expected_ids = set(terminals_by_id)
471
+ if covered != expected_ids:
472
+ missing = sorted(expected_ids - covered)[:10]
473
+ extra = sorted(covered - expected_ids)[:10]
474
+ raise SystemExit(
475
+ "all-terminals coverage mismatch: "
476
+ f"expected={len(expected_ids)} covered={len(covered)} missing={missing} extra={extra}"
477
+ )
478
+ return selected
479
+
480
+
481
+ def select_units(dag: dict[str, Any], args: Args) -> list[dict[str, Any]]:
482
+ if args.coverage_mode == COVERAGE_ALL_TERMINALS:
483
+ return all_terminal_units(dag, args)
484
+ return shared_only_units(dag, args)
485
+
486
+
487
+ def annotation_schema() -> dict[str, Any]:
488
+ return {
489
+ "type": "object",
490
+ "additionalProperties": False,
491
+ "required": [
492
+ "unit_id",
493
+ "terminal_ids",
494
+ "episode_title_suffixes",
495
+ "media_suffixes",
496
+ "title_candidates",
497
+ "llm_label",
498
+ "notes",
499
+ ],
500
+ "properties": {
501
+ "unit_id": {"type": "string"},
502
+ "terminal_ids": {"type": "array", "items": {"type": "string"}},
503
+ "episode_title_suffixes": {"type": "array", "items": {"type": "string"}},
504
+ "media_suffixes": {"type": "array", "items": {"type": "string"}},
505
+ "title_candidates": {"type": "array", "items": {"type": "string"}},
506
+ "llm_label": {"type": ["string", "null"]},
507
+ "notes": {"type": "string"},
508
+ },
509
+ }
510
+
511
+
512
+ def batch_annotation_schema() -> dict[str, Any]:
513
+ return {
514
+ "type": "object",
515
+ "additionalProperties": False,
516
+ "required": ["annotations"],
517
+ "properties": {
518
+ "annotations": {
519
+ "type": "array",
520
+ "items": annotation_schema(),
521
+ }
522
+ },
523
+ }
524
+
525
+
526
+ def responses_url(base_url: str) -> str:
527
+ return base_url.rstrip("/") + "/responses"
528
+
529
+
530
+ def unit_request_context(unit: dict[str, Any]) -> dict[str, Any]:
531
+ return {
532
+ "unit_id": unit["unit_id"],
533
+ "node_id": unit["node_id"],
534
+ "terminal_ids": unit["terminal_ids"],
535
+ "incoming_count": unit["incoming_count"],
536
+ "reachable_terminals": unit["reachable_terminals"],
537
+ "reachable_weight": unit["reachable_weight"],
538
+ "prefix_examples": unit["prefix_examples"],
539
+ "value_examples": unit["value_examples"],
540
+ "suffix_examples": unit["suffix_examples"],
541
+ "common_edge_labels": unit["common_edge_labels"],
542
+ "heuristic_patch": heuristic_unit_patch(unit),
543
+ }
544
+
545
+
546
+ def request_variants(units: list[dict[str, Any]], args: Args, previous_error: str | None = None) -> list[dict[str, Any]]:
547
+ if not units:
548
+ raise ValueError("request_variants requires at least one unit")
549
+ instructions = (
550
+ "You annotate anime release filename DAG suffix units. Classify only suffix text that is "
551
+ "a human episode title into episode_title_suffixes. Put resolution, codec, source, audio, "
552
+ "subtitle, language, hash, release, and file-format fragments into media_suffixes. "
553
+ "Use title_candidates for possible anime title strings found in prefix/value examples. Return "
554
+ "strict JSON matching the supplied schema with one annotation per input unit. Keep unit_id and "
555
+ "terminal_ids exactly unchanged for every unit."
556
+ )
557
+ if previous_error:
558
+ instructions += f" Previous attempt failed validation: {previous_error}. Correct only that issue."
559
+ input_obj = {
560
+ "units": [unit_request_context(unit) for unit in units[: args.max_context_units]],
561
+ }
562
+ schema = batch_annotation_schema()
563
+ base = {
564
+ "model": args.model,
565
+ "instructions": instructions,
566
+ "input": json.dumps(input_obj, ensure_ascii=False),
567
+ }
568
+ return [
569
+ {
570
+ **base,
571
+ "reasoning": {"effort": "medium"},
572
+ "text": {
573
+ "format": {
574
+ "type": "json_schema",
575
+ "name": "dmhy_dag_batch_annotation",
576
+ "strict": True,
577
+ "schema": schema,
578
+ }
579
+ },
580
+ },
581
+ {
582
+ **base,
583
+ "text": {
584
+ "format": {
585
+ "type": "json_schema",
586
+ "name": "dmhy_dag_batch_annotation",
587
+ "strict": True,
588
+ "schema": schema,
589
+ }
590
+ },
591
+ },
592
+ {
593
+ **base,
594
+ "tools": [
595
+ {
596
+ "type": "function",
597
+ "name": "submit_dmhy_dag_batch_annotation",
598
+ "strict": True,
599
+ "parameters": schema,
600
+ "description": "Submit strict DMHY DAG suffix annotations for all input units.",
601
+ }
602
+ ],
603
+ "tool_choice": {"type": "function", "name": "submit_dmhy_dag_batch_annotation"},
604
+ },
605
+ ]
606
+
607
+
608
+ def http_post_json(payload: dict[str, Any], args: Args) -> dict[str, Any]:
609
+ if not args.api_key:
610
+ raise RuntimeError("--llm requires --api-key or ANIFILEBERT_LLM_API_KEY")
611
+ request = urllib.request.Request(
612
+ responses_url(args.base_url),
613
+ data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
614
+ headers={
615
+ "Authorization": f"Bearer {args.api_key}",
616
+ "Content-Type": "application/json",
617
+ },
618
+ method="POST",
619
+ )
620
+ try:
621
+ with urllib.request.urlopen(request, timeout=args.http_timeout) as response:
622
+ raw = response.read().decode("utf-8")
623
+ except urllib.error.HTTPError as exc:
624
+ body = exc.read().decode("utf-8", errors="replace")
625
+ raise RuntimeError(f"Responses API HTTP {exc.code}: {body[:500]}") from exc
626
+ except (urllib.error.URLError, OSError) as exc:
627
+ raise RuntimeError(f"Responses API request failed: {exc}") from exc
628
+ try:
629
+ data = json.loads(raw)
630
+ except json.JSONDecodeError as exc:
631
+ raise RuntimeError(f"Responses API returned invalid JSON envelope: {raw[:500]}") from exc
632
+ if not isinstance(data, dict):
633
+ raise RuntimeError("Responses API envelope must be a JSON object")
634
+ return data
635
+
636
+
637
+ def extract_annotation_payload(data: dict[str, Any]) -> dict[str, Any]:
638
+ output_text = data.get("output_text")
639
+ if isinstance(output_text, str) and output_text.strip():
640
+ return json.loads(strip_json_fence(output_text))
641
+
642
+ text_chunks: list[str] = []
643
+ for item in data.get("output") or []:
644
+ if not isinstance(item, dict):
645
+ continue
646
+ if item.get("type") in {"function_call", "tool_call"}:
647
+ arguments = item.get("arguments")
648
+ if isinstance(arguments, str) and arguments.strip():
649
+ return json.loads(arguments)
650
+ if isinstance(arguments, dict):
651
+ return arguments
652
+ for content in item.get("content") or []:
653
+ if not isinstance(content, dict):
654
+ continue
655
+ text = content.get("text")
656
+ if isinstance(text, str):
657
+ text_chunks.append(text)
658
+ if content.get("type") in {"function_call", "tool_call"}:
659
+ arguments = content.get("arguments")
660
+ if isinstance(arguments, str) and arguments.strip():
661
+ return json.loads(arguments)
662
+ if isinstance(arguments, dict):
663
+ return arguments
664
+ if text_chunks:
665
+ return json.loads(strip_json_fence("\n".join(text_chunks)))
666
+ raise RuntimeError("Responses API did not include annotation text or tool arguments")
667
+
668
+
669
+ def strip_json_fence(text: str) -> str:
670
+ text = text.strip()
671
+ if text.startswith("```"):
672
+ lines = text.splitlines()
673
+ if lines:
674
+ lines = lines[1:]
675
+ if lines and lines[-1].strip() == "```":
676
+ lines = lines[:-1]
677
+ return "\n".join(lines).strip()
678
+ return text
679
+
680
+
681
+ def validate_annotation(annotation: Any, unit: dict[str, Any]) -> dict[str, Any]:
682
+ if not isinstance(annotation, dict):
683
+ raise RuntimeError("annotation must be a JSON object")
684
+ missing = [key for key in annotation_schema()["required"] if key not in annotation]
685
+ if missing:
686
+ raise RuntimeError(f"annotation missing required keys: {', '.join(missing)}")
687
+ if annotation["unit_id"] != unit["unit_id"]:
688
+ raise RuntimeError(f"unit_id mismatch: {annotation['unit_id']!r}")
689
+ returned_ids = [str(item) for item in annotation.get("terminal_ids") or []]
690
+ if returned_ids != unit["terminal_ids"]:
691
+ raise RuntimeError("terminal_ids mismatch")
692
+ cleaned = {
693
+ "unit_id": unit["unit_id"],
694
+ "terminal_ids": unit["terminal_ids"],
695
+ "episode_title_suffixes": unique_keep_order(str(item) for item in annotation["episode_title_suffixes"]),
696
+ "media_suffixes": unique_keep_order(str(item) for item in annotation["media_suffixes"]),
697
+ "title_candidates": unique_keep_order(str(item) for item in annotation["title_candidates"]),
698
+ "llm_label": annotation["llm_label"] if annotation["llm_label"] is None else str(annotation["llm_label"]),
699
+ "notes": str(annotation["notes"]),
700
+ }
701
+ return cleaned
702
+
703
+
704
+ def validate_batch_annotation(payload: Any, units: list[dict[str, Any]]) -> list[dict[str, Any]]:
705
+ if not isinstance(payload, dict):
706
+ raise RuntimeError("batch annotation must be a JSON object")
707
+ annotations = payload.get("annotations")
708
+ if not isinstance(annotations, list):
709
+ raise RuntimeError("batch annotation missing annotations list")
710
+ expected_ids = [unit["unit_id"] for unit in units]
711
+ returned_ids = [
712
+ str(annotation.get("unit_id"))
713
+ for annotation in annotations
714
+ if isinstance(annotation, dict) and annotation.get("unit_id") is not None
715
+ ]
716
+ if sorted(returned_ids) != sorted(expected_ids) or len(returned_ids) != len(expected_ids):
717
+ raise RuntimeError(f"unit_id set mismatch: expected={expected_ids!r} returned={returned_ids!r}")
718
+ annotations_by_id: dict[str, Any] = {}
719
+ for annotation in annotations:
720
+ if not isinstance(annotation, dict):
721
+ raise RuntimeError("annotations entries must be JSON objects")
722
+ unit_id_value = str(annotation.get("unit_id"))
723
+ if unit_id_value in annotations_by_id:
724
+ raise RuntimeError(f"duplicate annotation unit_id: {unit_id_value}")
725
+ annotations_by_id[unit_id_value] = annotation
726
+ cleaned: list[dict[str, Any]] = []
727
+ for unit in units:
728
+ cleaned.append(validate_annotation(annotations_by_id[unit["unit_id"]], unit))
729
+ return cleaned
730
+
731
+
732
+ def annotate_batch(units: list[dict[str, Any]], args: Args, use_llm: bool) -> list[dict[str, Any]]:
733
+ if not use_llm:
734
+ patches: list[dict[str, Any]] = []
735
+ for unit in units:
736
+ patch = heuristic_unit_patch(unit)
737
+ patch.update({"source": HEURISTIC_SOURCE, "fallback": True, "attempts": 0, "errors": []})
738
+ patches.append(patch)
739
+ return patches
740
+
741
+ errors: list[str] = []
742
+ for attempt in range(args.retries):
743
+ variants = request_variants(units, args, errors[-1] if errors else None)
744
+ payload = variants[min(attempt, len(variants) - 1)]
745
+ try:
746
+ data = http_post_json(payload, args)
747
+ annotations = validate_batch_annotation(extract_annotation_payload(data), units)
748
+ for annotation in annotations:
749
+ annotation.update({"source": LLM_SOURCE, "fallback": False, "attempts": attempt + 1, "errors": []})
750
+ return annotations
751
+ except (RuntimeError, json.JSONDecodeError, TypeError, ValueError) as exc:
752
+ errors.append(str(exc))
753
+ time.sleep(min(2.0, 0.25 * (attempt + 1)))
754
+
755
+ patches = []
756
+ for unit in units:
757
+ patch = heuristic_unit_patch(unit)
758
+ patch.update(
759
+ {
760
+ "source": HEURISTIC_SOURCE,
761
+ "fallback": True,
762
+ "attempts": args.retries,
763
+ "errors": errors[-3:],
764
+ "notes": f"{patch['notes']}; llm_failed_after={args.retries}",
765
+ }
766
+ )
767
+ patches.append(patch)
768
+ return patches
769
+
770
+
771
+ def annotate_unit(unit: dict[str, Any], args: Args, use_llm: bool) -> dict[str, Any]:
772
+ return annotate_batch([unit], args, use_llm)[0]
773
+
774
+
775
+ def chunk_units(units: list[dict[str, Any]], size: int) -> list[list[dict[str, Any]]]:
776
+ return [units[index : index + size] for index in range(0, len(units), size)]
777
+
778
+
779
+ def load_resume_patches(path: Path) -> dict[str, dict[str, Any]]:
780
+ if not path.exists():
781
+ return {}
782
+ completed: dict[str, dict[str, Any]] = {}
783
+ for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
784
+ if not line.strip():
785
+ continue
786
+ try:
787
+ row = json.loads(line)
788
+ except json.JSONDecodeError as exc:
789
+ raise SystemExit(f"invalid resume patch JSON in {path}:{line_number}: {exc}") from exc
790
+ if not isinstance(row, dict):
791
+ continue
792
+ unit_id_value = row.get("unit_id")
793
+ if not unit_id_value:
794
+ continue
795
+ status = row.get("status")
796
+ is_legacy_completed = status is None and ("source" in row or "fallback" in row)
797
+ is_completed = status in {"ok", "fallback"} or is_legacy_completed
798
+ if is_completed:
799
+ completed[str(unit_id_value)] = row
800
+ return completed
801
+
802
+
803
+ def patch_from_resume_row(unit: dict[str, Any], row: dict[str, Any]) -> dict[str, Any]:
804
+ return {
805
+ "unit_id": unit["unit_id"],
806
+ "terminal_ids": unit["terminal_ids"],
807
+ "episode_title_suffixes": unique_keep_order(str(item) for item in row.get("episode_title_suffixes") or []),
808
+ "media_suffixes": unique_keep_order(str(item) for item in row.get("media_suffixes") or []),
809
+ "title_candidates": unique_keep_order(str(item) for item in row.get("title_candidates") or []),
810
+ "llm_label": row.get("llm_label"),
811
+ "notes": str(row.get("notes", "")),
812
+ "source": str(row.get("source") or HEURISTIC_SOURCE),
813
+ "fallback": bool(row.get("fallback", row.get("status") == "fallback")),
814
+ "attempts": int_field(row, "attempts"),
815
+ "errors": list(row.get("errors") or []),
816
+ }
817
+
818
+
819
+ def patch_row(unit: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
820
+ fallback = bool(patch["fallback"])
821
+ return {
822
+ "unit_id": unit["unit_id"],
823
+ "node_id": unit["node_id"],
824
+ "kind": unit["kind"],
825
+ "incoming_count": unit["incoming_count"],
826
+ "reachable_terminals": unit["reachable_terminals"],
827
+ "reachable_weight": unit["reachable_weight"],
828
+ "terminal_ids": patch["terminal_ids"],
829
+ "episode_title_suffixes": patch["episode_title_suffixes"],
830
+ "media_suffixes": patch["media_suffixes"],
831
+ "title_candidates": patch["title_candidates"],
832
+ "llm_label": patch["llm_label"],
833
+ "notes": patch["notes"],
834
+ "source": patch["source"],
835
+ "fallback": fallback,
836
+ "status": "fallback" if fallback else "ok",
837
+ "attempts": patch["attempts"],
838
+ "errors": patch["errors"],
839
+ "annotated_at": datetime.now(timezone.utc).isoformat(),
840
+ }
841
+
842
+
843
+ def unit_source_id(unit: dict[str, Any]) -> str:
844
+ node_id_value = unit.get("node_id")
845
+ if node_id_value is None:
846
+ return str(unit["unit_id"])
847
+ return str(node_id_value)
848
+
849
+
850
+ def unit_public_row(unit: dict[str, Any]) -> dict[str, Any]:
851
+ return {
852
+ "unit_id": unit["unit_id"],
853
+ "source_kind": "prefix_dag",
854
+ "source_id": unit_source_id(unit),
855
+ "terminal_ids": unit["terminal_ids"],
856
+ "weight": int(unit["reachable_weight"]),
857
+ "context": {
858
+ "prefixes": unit["prefix_examples"],
859
+ "digit_skeletons": unit["digit_skeleton_examples"],
860
+ "edge_labels": unit["common_edge_labels"],
861
+ "notes": (
862
+ f"kind={unit['kind']}; incoming_count={unit['incoming_count']}; "
863
+ f"reachable_terminals={unit['reachable_terminals']}"
864
+ ),
865
+ },
866
+ "examples": {
867
+ "values": unit["value_examples"],
868
+ "suffixes": unit["suffix_examples"],
869
+ },
870
+ "expected_output": {
871
+ "schema_version": "dmhy-annotation-v1",
872
+ },
873
+ }
874
+
875
+
876
+ def dataset_records(
877
+ units: list[dict[str, Any]],
878
+ patches: list[dict[str, Any]],
879
+ args: Args,
880
+ ) -> list[dict[str, Any]]:
881
+ tokenizer = AnimeTokenizer()
882
+ records: list[dict[str, Any]] = []
883
+ seen: set[str] = set()
884
+ for unit, patch in zip(units, patches):
885
+ per_terminal_counts: dict[str, int] = {}
886
+ for terminal in unit["_terminals"]:
887
+ terminal_id_value = str(terminal["_terminal_id"])
888
+ for source_index, filename in enumerate(string_list(terminal.get("value_examples"))):
889
+ if len(records) >= args.max_records:
890
+ return records
891
+ if per_terminal_counts.get(terminal_id_value, 0) >= args.records_per_terminal:
892
+ break
893
+ if filename in seen:
894
+ continue
895
+ seen.add(filename)
896
+ sample = weak_label_filename(filename, tokenizer)
897
+ if sample is None:
898
+ continue
899
+ tokens, labels = normalize_generated_tokens(
900
+ sample["tokens"],
901
+ sample["labels"],
902
+ preserve_i_labels=args.preserve_i_labels,
903
+ )
904
+ records.append(
905
+ {
906
+ "file_id": f"prefix-dag:{unit['unit_id']}:{terminal_id_value}:{source_index}",
907
+ "filename": filename,
908
+ "tokens": tokens,
909
+ "labels": labels,
910
+ "terminal_id": terminal_id_value,
911
+ "terminal_index": terminal["_terminal_index"],
912
+ "unit_id": unit["unit_id"],
913
+ "source": patch["source"],
914
+ "needs_llm_review": patch["fallback"],
915
+ "episode_title_suffixes": patch["episode_title_suffixes"],
916
+ "media_suffixes": patch["media_suffixes"],
917
+ "title_candidates": patch["title_candidates"],
918
+ "annotations": {
919
+ "unit_id": unit["unit_id"],
920
+ "terminal_id": terminal_id_value,
921
+ "terminal_index": terminal["_terminal_index"],
922
+ "source": patch["source"],
923
+ "fallback": patch["fallback"],
924
+ "episode_title_suffixes": patch["episode_title_suffixes"],
925
+ "media_suffixes": patch["media_suffixes"],
926
+ "title_candidates": patch["title_candidates"],
927
+ "llm_label": patch["llm_label"],
928
+ "notes": patch["notes"],
929
+ },
930
+ }
931
+ )
932
+ per_terminal_counts[terminal_id_value] = per_terminal_counts.get(terminal_id_value, 0) + 1
933
+ return records
934
+
935
+
936
+ def annotate_units(
937
+ units: list[dict[str, Any]],
938
+ args: Args,
939
+ ) -> tuple[list[tuple[dict[str, Any], dict[str, Any]]], int, int, int]:
940
+ resume_rows = load_resume_patches(args.patch_output) if args.resume else {}
941
+ results: list[dict[str, Any] | None] = [None] * len(units)
942
+ pending: list[tuple[int, dict[str, Any]]] = []
943
+ skipped = 0
944
+ for index, unit in enumerate(units):
945
+ row = resume_rows.get(unit["unit_id"])
946
+ if row is not None and [str(item) for item in row.get("terminal_ids") or []] == unit["terminal_ids"]:
947
+ results[index] = patch_from_resume_row(unit, row)
948
+ skipped += 1
949
+ else:
950
+ pending.append((index, unit))
951
+
952
+ batch_size = max(1, min(args.units_per_request, args.max_context_units))
953
+ batches = [
954
+ ([index for index, _unit in batch], [unit for _index, unit in batch])
955
+ for batch in chunk_units(pending, batch_size)
956
+ ]
957
+ if args.llm:
958
+ runnable_batches = batches[: args.max_requests]
959
+ llm_requested = len(runnable_batches)
960
+ else:
961
+ runnable_batches = batches
962
+ llm_requested = 0
963
+ with ThreadPoolExecutor(max_workers=args.workers) as executor:
964
+ futures = {
965
+ executor.submit(annotate_batch, batch_units, args, args.llm): indices
966
+ for indices, batch_units in runnable_batches
967
+ }
968
+ for future in as_completed(futures):
969
+ indices = futures[future]
970
+ for index, patch in zip(indices, future.result()):
971
+ results[index] = patch
972
+ completed = [
973
+ (unit, patch)
974
+ for unit, patch in zip(units, results)
975
+ if patch is not None
976
+ ]
977
+ pending_unprocessed = len(units) - len(completed)
978
+ return completed, skipped, llm_requested, pending_unprocessed
979
+
980
+
981
+ def main() -> None:
982
+ args = parse_args()
983
+ if args.llm and not args.api_key:
984
+ raise SystemExit("--llm requires --api-key or ANIFILEBERT_LLM_API_KEY")
985
+
986
+ dag = load_dag(args.dag)
987
+ units = select_units(dag, args)
988
+ if not units:
989
+ raise SystemExit("no DAG units selected; adjust --limit/--min-incoming-count")
990
+
991
+ completed_pairs, resume_skipped, llm_requested, pending_unprocessed = annotate_units(units, args)
992
+ completed_units = [unit for unit, _patch in completed_pairs]
993
+ patches = [patch for _unit, patch in completed_pairs]
994
+ patch_rows = [patch_row(unit, patch) for unit, patch in completed_pairs]
995
+ records = dataset_records(completed_units, patches, args)
996
+
997
+ patch_count = write_jsonl(args.patch_output, patch_rows)
998
+ record_count = write_jsonl(args.output, records)
999
+ unit_count = 0
1000
+ if args.units_output is not None:
1001
+ unit_count = write_jsonl(args.units_output, (unit_public_row(unit) for unit in units))
1002
+
1003
+ fallback_count = sum(1 for patch in patches if patch["fallback"])
1004
+ terminal_count = len(all_terminals(dag))
1005
+ unique_coverage_count = len(unique_terminal_coverage(units))
1006
+ summary = {
1007
+ "dag": str(args.dag),
1008
+ "coverage_mode": args.coverage_mode,
1009
+ "units_output": str(args.units_output) if args.units_output is not None else None,
1010
+ "patch_output": str(args.patch_output),
1011
+ "output": str(args.output),
1012
+ "selected_units": len(units),
1013
+ "dag_terminals": terminal_count,
1014
+ "unique_terminal_coverage": unique_coverage_count,
1015
+ "written_units": unit_count,
1016
+ "written_patches": patch_count,
1017
+ "written_records": record_count,
1018
+ "llm_requested": llm_requested,
1019
+ "pending_unprocessed": pending_unprocessed,
1020
+ "units_per_request": args.units_per_request,
1021
+ "max_context_units": args.max_context_units,
1022
+ "resume": args.resume,
1023
+ "resume_skipped": resume_skipped,
1024
+ "fallback_count": fallback_count,
1025
+ "workers": args.workers,
1026
+ "model": args.model if args.llm else None,
1027
+ }
1028
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
1029
+ if fallback_count:
1030
+ print(f"warning: {fallback_count} unit(s) used heuristic fallback", file=sys.stderr)
1031
+
1032
+
1033
+ if __name__ == "__main__":
1034
+ main()
tools/annotate_dmhy_prefix_dag.py CHANGED
@@ -31,6 +31,7 @@ class Args:
31
  min_incoming_count: int
32
  limit: int | None
33
  example_count: int
 
34
 
35
 
36
  def parse_args() -> Args:
@@ -63,6 +64,11 @@ def parse_args() -> Args:
63
  default=5,
64
  help="Maximum examples retained per example field",
65
  )
 
 
 
 
 
66
  ns = parser.parse_args()
67
  return Args(
68
  dag=ns.dag,
@@ -71,6 +77,7 @@ def parse_args() -> Args:
71
  min_incoming_count=max(1, ns.min_incoming_count),
72
  limit=ns.limit,
73
  example_count=max(1, ns.example_count),
 
74
  )
75
 
76
 
@@ -132,22 +139,25 @@ def build_indexes(dag: dict[str, Any]) -> tuple[dict[int, dict[str, Any]], dict[
132
  return nodes, terminals_by_node
133
 
134
 
135
- def reachable_terminals(
136
- start_node_id: int,
137
- nodes: dict[int, dict[str, Any]],
138
- terminals_by_node: dict[int, list[dict[str, Any]]],
139
- ) -> list[dict[str, Any]]:
140
- memo: dict[int, list[dict[str, Any]]] = {}
141
- visiting: set[int] = set()
142
-
143
- def visit(current_id: int) -> list[dict[str, Any]]:
144
- if current_id in memo:
145
- return memo[current_id]
146
- if current_id in visiting:
147
- raise SystemExit(f"cycle detected while traversing DAG at node {current_id}")
148
- visiting.add(current_id)
149
- found = list(terminals_by_node.get(current_id, []))
150
- node = nodes.get(current_id, {})
 
 
 
151
  for edge in node.get("children") or []:
152
  if not isinstance(edge, dict):
153
  continue
@@ -155,14 +165,12 @@ def reachable_terminals(
155
  target = int(edge.get("target"))
156
  except (TypeError, ValueError):
157
  continue
158
- found.extend(visit(target))
159
- visiting.remove(current_id)
160
  deduped = dedupe_terminals(found)
161
- memo[current_id] = deduped
162
  return deduped
163
 
164
- return visit(start_node_id)
165
-
166
 
167
  def dedupe_terminals(terminals: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
168
  seen: set[str] = set()
@@ -197,6 +205,10 @@ def edge_labels(node: dict[str, Any], limit: int) -> list[str]:
197
  return limited_unique(labels, limit)
198
 
199
 
 
 
 
 
200
  def aggregate_examples(terminals: list[dict[str, Any]], key: str, limit: int) -> list[str]:
201
  values: list[str] = []
202
  for terminal in terminals:
@@ -268,29 +280,50 @@ def make_unit(
268
  }
269
 
270
 
 
 
 
 
 
 
 
 
271
  def selected_units(dag: dict[str, Any], args: Args) -> list[dict[str, Any]]:
272
  nodes, terminals_by_node = build_indexes(dag)
 
273
  root = int(dag.get("root", 0) or 0)
274
- candidates: list[tuple[tuple[int, int, int, int, int], dict[str, Any]]] = []
275
 
276
  for current_id, node in nodes.items():
277
- terminals = reachable_terminals(current_id, nodes, terminals_by_node)
278
- if not terminals:
279
  continue
280
  incoming_count = int_field(node, "incoming_count")
281
- reachable_count = len(terminals)
282
  by_shared_incoming = incoming_count >= args.min_incoming_count
283
  by_reachable = current_id != root and reachable_count >= args.min_reachable_terminals
284
  if by_shared_incoming or by_reachable:
285
- unit = make_unit(node, current_id, terminals, "shared_suffix", args.example_count)
286
  sort_key = (
287
  0,
288
- -unit["reachable_weight"],
289
- -unit["reachable_terminals"],
290
- -unit["incoming_count"],
291
  current_id,
292
  )
293
- candidates.append((sort_key, unit))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
 
295
  covered_terminal_ids = {
296
  terminal_id
@@ -298,10 +331,12 @@ def selected_units(dag: dict[str, Any], args: Args) -> list[dict[str, Any]]:
298
  for terminal_id in unit["terminal_ids"]
299
  }
300
  for current_id, terminals in terminals_by_node.items():
 
 
 
301
  uncovered = [terminal for terminal in terminals if terminal["_terminal_id"] not in covered_terminal_ids]
302
  if not uncovered:
303
  continue
304
- node = nodes.get(current_id, {"id": current_id})
305
  unit = make_unit(node, current_id, uncovered, "terminal_cluster", args.example_count)
306
  sort_key = (
307
  1,
 
31
  min_incoming_count: int
32
  limit: int | None
33
  example_count: int
34
+ include_terminal_sink_units: bool
35
 
36
 
37
  def parse_args() -> Args:
 
64
  default=5,
65
  help="Maximum examples retained per example field",
66
  )
67
+ parser.add_argument(
68
+ "--include-terminal-sink-units",
69
+ action="store_true",
70
+ help="Include terminal nodes with no outgoing edges as annotation units",
71
+ )
72
  ns = parser.parse_args()
73
  return Args(
74
  dag=ns.dag,
 
77
  min_incoming_count=max(1, ns.min_incoming_count),
78
  limit=ns.limit,
79
  example_count=max(1, ns.example_count),
80
+ include_terminal_sink_units=ns.include_terminal_sink_units,
81
  )
82
 
83
 
 
139
  return nodes, terminals_by_node
140
 
141
 
142
+ class ReachableTerminalIndex:
143
+ def __init__(
144
+ self,
145
+ nodes: dict[int, dict[str, Any]],
146
+ terminals_by_node: dict[int, list[dict[str, Any]]],
147
+ ) -> None:
148
+ self.nodes = nodes
149
+ self.terminals_by_node = terminals_by_node
150
+ self.memo: dict[int, list[dict[str, Any]]] = {}
151
+ self.visiting: set[int] = set()
152
+
153
+ def get(self, start_node_id: int) -> list[dict[str, Any]]:
154
+ if start_node_id in self.memo:
155
+ return self.memo[start_node_id]
156
+ if start_node_id in self.visiting:
157
+ raise SystemExit(f"cycle detected while traversing DAG at node {start_node_id}")
158
+ self.visiting.add(start_node_id)
159
+ found = list(self.terminals_by_node.get(start_node_id, []))
160
+ node = self.nodes.get(start_node_id, {})
161
  for edge in node.get("children") or []:
162
  if not isinstance(edge, dict):
163
  continue
 
165
  target = int(edge.get("target"))
166
  except (TypeError, ValueError):
167
  continue
168
+ found.extend(self.get(target))
169
+ self.visiting.remove(start_node_id)
170
  deduped = dedupe_terminals(found)
171
+ self.memo[start_node_id] = deduped
172
  return deduped
173
 
 
 
174
 
175
  def dedupe_terminals(terminals: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
176
  seen: set[str] = set()
 
205
  return limited_unique(labels, limit)
206
 
207
 
208
+ def has_outgoing_edges(node: dict[str, Any]) -> bool:
209
+ return any(isinstance(edge, dict) for edge in node.get("children") or [])
210
+
211
+
212
  def aggregate_examples(terminals: list[dict[str, Any]], key: str, limit: int) -> list[str]:
213
  values: list[str] = []
214
  for terminal in terminals:
 
280
  }
281
 
282
 
283
+ def node_reachable_count(node: dict[str, Any]) -> int:
284
+ return int_field(node, "reachable_terminals")
285
+
286
+
287
+ def node_reachable_weight(node: dict[str, Any]) -> int:
288
+ return int_field(node, "reachable_weight")
289
+
290
+
291
  def selected_units(dag: dict[str, Any], args: Args) -> list[dict[str, Any]]:
292
  nodes, terminals_by_node = build_indexes(dag)
293
+ reachable_index = ReachableTerminalIndex(nodes, terminals_by_node)
294
  root = int(dag.get("root", 0) or 0)
295
+ shared_candidates: list[tuple[tuple[int, int, int, int, int], int, dict[str, Any]]] = []
296
 
297
  for current_id, node in nodes.items():
298
+ if not args.include_terminal_sink_units and not has_outgoing_edges(node):
 
299
  continue
300
  incoming_count = int_field(node, "incoming_count")
301
+ reachable_count = node_reachable_count(node)
302
  by_shared_incoming = incoming_count >= args.min_incoming_count
303
  by_reachable = current_id != root and reachable_count >= args.min_reachable_terminals
304
  if by_shared_incoming or by_reachable:
 
305
  sort_key = (
306
  0,
307
+ -node_reachable_weight(node),
308
+ -reachable_count,
309
+ -incoming_count,
310
  current_id,
311
  )
312
+ shared_candidates.append((sort_key, current_id, node))
313
+
314
+ shared_candidates.sort(key=lambda item: item[0])
315
+ candidates: list[tuple[tuple[int, int, int, int, int], dict[str, Any]]] = []
316
+ for sort_key, current_id, node in shared_candidates:
317
+ if args.limit is not None and len(candidates) >= args.limit:
318
+ break
319
+ terminals = reachable_index.get(current_id)
320
+ if not terminals:
321
+ continue
322
+ unit = make_unit(node, current_id, terminals, "shared_suffix", args.example_count)
323
+ candidates.append((sort_key, unit))
324
+
325
+ if args.limit is not None and len(candidates) >= args.limit:
326
+ return [unit for _sort_key, unit in candidates[: max(0, args.limit)]]
327
 
328
  covered_terminal_ids = {
329
  terminal_id
 
331
  for terminal_id in unit["terminal_ids"]
332
  }
333
  for current_id, terminals in terminals_by_node.items():
334
+ node = nodes.get(current_id, {"id": current_id})
335
+ if not args.include_terminal_sink_units and not has_outgoing_edges(node):
336
+ continue
337
  uncovered = [terminal for terminal in terminals if terminal["_terminal_id"] not in covered_terminal_ids]
338
  if not uncovered:
339
  continue
 
340
  unit = make_unit(node, current_id, uncovered, "terminal_cluster", args.example_count)
341
  sort_key = (
342
  1,
tools/annotate_dmhy_prefix_tree_agent.py ADDED
@@ -0,0 +1,698 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agentic strict LLM annotator for DMHY prefix-tree terminals.
2
+
3
+ This runner is intentionally separate from ``annotate_dmhy_prefix_graph.py`` so
4
+ prefix-tree and DAG experiments can evolve independently. It reads the existing
5
+ prefix graph plus the full DMHY source list, emits terminal annotation patches,
6
+ and writes dmhy_weak-compatible dataset JSONL records.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import concurrent.futures
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+ import urllib.error
18
+ import urllib.request
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any, Iterable
22
+
23
+ from anifilebert.tokenizer import AnimeTokenizer
24
+ from tools.annotate_dmhy_prefix_graph import (
25
+ DEFAULT_GRAPH,
26
+ DEFAULT_MODEL,
27
+ DEFAULT_OUTPUT,
28
+ DEFAULT_PATCH_OUTPUT,
29
+ DEFAULT_SOURCE_LIST,
30
+ LLM_SOURCE,
31
+ dataset_records,
32
+ heuristic_patch,
33
+ load_graph,
34
+ selected_terminals,
35
+ source_list_matches,
36
+ string_list,
37
+ )
38
+
39
+
40
+ DEFAULT_BASE_URL = "http://10.137.32.209/v1"
41
+ DEFAULT_UNITS_OUTPUT = Path("datasets/AnimeName/dmhy_prefix_graph.annotation_units.prefix_sample.jsonl")
42
+ NON_TITLE_NUMERIC_FRAGMENT_RE = re.compile(
43
+ r"(?ix)^\s*"
44
+ r"(?:"
45
+ r"[\[(【《]?\s*(?:EP?|\#)?\d{1,4}(?:\s*(?:v|ver|version|rev)\s*\d{1,3})?\s*[\])】》]?|"
46
+ r"(?:v|ver|version|rev)\s*\d{1,3}|"
47
+ r"vol(?:ume)?\.?\s*\d{1,3}|"
48
+ r"S\d{1,2}E\d{1,4}|"
49
+ r"\d{1,2}x\d{1,4}"
50
+ r")"
51
+ r"\s*$"
52
+ )
53
+ ANNOTATION_SCHEMA: dict[str, Any] = {
54
+ "type": "object",
55
+ "additionalProperties": False,
56
+ "required": [
57
+ "terminal_id",
58
+ "episode_title_suffixes",
59
+ "media_suffixes",
60
+ "title_candidates",
61
+ "llm_label",
62
+ "notes",
63
+ ],
64
+ "properties": {
65
+ "terminal_id": {"type": "string"},
66
+ "episode_title_suffixes": {
67
+ "type": "array",
68
+ "items": {"type": "string"},
69
+ },
70
+ "media_suffixes": {
71
+ "type": "array",
72
+ "items": {"type": "string"},
73
+ },
74
+ "title_candidates": {
75
+ "type": "array",
76
+ "items": {"type": "string"},
77
+ },
78
+ "llm_label": {
79
+ "type": ["string", "null"],
80
+ "description": "Short classification such as accept, media_only, episode_title, mixed, or uncertain.",
81
+ },
82
+ "notes": {"type": "string"},
83
+ },
84
+ }
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Args:
89
+ graph: Path
90
+ source_list: Path
91
+ output: Path
92
+ patch_output: Path | None
93
+ units_output: Path | None
94
+ limit: int | None
95
+ min_weight: int | None
96
+ only_needs_review: bool
97
+ llm: bool
98
+ base_url: str
99
+ api_key: str | None
100
+ model: str
101
+ max_requests: int | None
102
+ http_timeout: int
103
+ preserve_i_labels: bool
104
+ examples_only: bool
105
+ workers: int
106
+ retries: int
107
+ resume: bool
108
+ failure_output: Path | None = None
109
+ reasoning_effort: str = "medium"
110
+
111
+
112
+ def parse_args() -> Args:
113
+ parser = argparse.ArgumentParser(
114
+ description="Strict Responses API agent for annotating DMHY prefix-tree terminals"
115
+ )
116
+ parser.add_argument("--graph", type=Path, default=DEFAULT_GRAPH)
117
+ parser.add_argument("--source-list", type=Path, default=DEFAULT_SOURCE_LIST)
118
+ parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
119
+ parser.add_argument("--patch-output", default=str(DEFAULT_PATCH_OUTPUT))
120
+ parser.add_argument(
121
+ "--units-output",
122
+ default=str(DEFAULT_UNITS_OUTPUT),
123
+ help="Optional selected annotation units JSONL; use empty string to disable",
124
+ )
125
+ parser.add_argument("--limit", type=int, default=None)
126
+ parser.add_argument("--min-weight", type=int, default=None)
127
+ parser.add_argument("--only-needs-review", action="store_true")
128
+ parser.add_argument("--llm", action="store_true")
129
+ parser.add_argument(
130
+ "--base-url",
131
+ default=os.environ.get("ANIFILEBERT_LLM_BASE_URL", DEFAULT_BASE_URL),
132
+ help="OpenAI-compatible Responses API base URL",
133
+ )
134
+ parser.add_argument(
135
+ "--api-key",
136
+ default=os.environ.get("ANIFILEBERT_LLM_API_KEY"),
137
+ help="API key; prefer ANIFILEBERT_LLM_API_KEY",
138
+ )
139
+ parser.add_argument("--model", default=DEFAULT_MODEL)
140
+ parser.add_argument("--max-requests", type=int, default=None)
141
+ parser.add_argument("--http-timeout", type=int, default=120)
142
+ parser.add_argument("--preserve-i-labels", action="store_true")
143
+ parser.add_argument("--examples-only", action="store_true")
144
+ parser.add_argument("--workers", type=int, default=8, help="Concurrent LLM terminal patch workers")
145
+ parser.add_argument("--retries", type=int, default=3, help="LLM validation retry attempts per terminal")
146
+ parser.add_argument("--resume", action="store_true", help="Skip ok LLM terminal patches already in patch output")
147
+ parser.add_argument(
148
+ "--reasoning-effort",
149
+ choices=("minimal", "low", "medium", "high", "xhigh"),
150
+ default="medium",
151
+ help="Responses API reasoning effort when the target model supports reasoning",
152
+ )
153
+ parser.add_argument(
154
+ "--failure-output",
155
+ default="",
156
+ help="Optional JSONL path for attempted terminals that failed LLM validation/request retries",
157
+ )
158
+ ns = parser.parse_args()
159
+ patch_arg = str(ns.patch_output).strip()
160
+ units_arg = str(ns.units_output).strip()
161
+ failure_arg = str(ns.failure_output).strip()
162
+ return Args(
163
+ graph=ns.graph,
164
+ source_list=ns.source_list,
165
+ output=ns.output,
166
+ patch_output=Path(patch_arg) if patch_arg else None,
167
+ units_output=Path(units_arg) if units_arg else None,
168
+ limit=ns.limit,
169
+ min_weight=ns.min_weight,
170
+ only_needs_review=ns.only_needs_review,
171
+ llm=ns.llm,
172
+ base_url=ns.base_url,
173
+ api_key=ns.api_key,
174
+ model=ns.model,
175
+ max_requests=max(0, ns.max_requests) if ns.max_requests is not None else None,
176
+ http_timeout=ns.http_timeout,
177
+ preserve_i_labels=ns.preserve_i_labels,
178
+ examples_only=ns.examples_only,
179
+ workers=max(1, ns.workers),
180
+ retries=max(1, ns.retries),
181
+ resume=ns.resume,
182
+ failure_output=Path(failure_arg) if failure_arg else None,
183
+ reasoning_effort=ns.reasoning_effort,
184
+ )
185
+
186
+
187
+ def responses_url(base_url: str) -> str:
188
+ return base_url.rstrip("/") + "/responses"
189
+
190
+
191
+ def redact_secrets(text: str) -> str:
192
+ return re.sub(r"sk-[A-Za-z0-9._-]+", "sk-REDACTED", text)
193
+
194
+
195
+ def unique_strings(value: Any, *, field: str) -> list[str]:
196
+ if not isinstance(value, list):
197
+ raise ValueError(f"{field} must be a list")
198
+ seen: set[str] = set()
199
+ result: list[str] = []
200
+ for item in value:
201
+ if not isinstance(item, str):
202
+ raise ValueError(f"{field} items must be strings")
203
+ cleaned = " ".join(item.split()).strip()
204
+ if cleaned and cleaned not in seen:
205
+ seen.add(cleaned)
206
+ result.append(cleaned)
207
+ return result
208
+
209
+
210
+ def validate_strict_annotation(annotation: Any, *, expected_terminal_id: str) -> dict[str, Any]:
211
+ if not isinstance(annotation, dict):
212
+ raise ValueError("annotation must be a JSON object")
213
+ terminal_id = annotation.get("terminal_id")
214
+ if not isinstance(terminal_id, str):
215
+ raise ValueError("terminal_id must be a string")
216
+ if terminal_id != expected_terminal_id:
217
+ raise ValueError(f"terminal_id mismatch: expected {expected_terminal_id}, got {terminal_id}")
218
+ llm_label = annotation.get("llm_label")
219
+ if llm_label is not None and not isinstance(llm_label, str):
220
+ raise ValueError("llm_label must be a string or null")
221
+ notes = annotation.get("notes")
222
+ if not isinstance(notes, str):
223
+ raise ValueError("notes must be a string")
224
+ validated = {
225
+ "terminal_id": terminal_id,
226
+ "episode_title_suffixes": unique_strings(annotation.get("episode_title_suffixes"), field="episode_title_suffixes"),
227
+ "media_suffixes": unique_strings(annotation.get("media_suffixes"), field="media_suffixes"),
228
+ "title_candidates": unique_strings(annotation.get("title_candidates"), field="title_candidates"),
229
+ "llm_label": llm_label,
230
+ "notes": " ".join(notes.split()).strip(),
231
+ }
232
+ errors = annotation_quality_errors(validated)
233
+ if errors:
234
+ raise ValueError("; ".join(errors))
235
+ return validated
236
+
237
+
238
+ def annotation_quality_errors(annotation: dict[str, Any]) -> list[str]:
239
+ errors: list[str] = []
240
+ for field in ("episode_title_suffixes", "title_candidates"):
241
+ values = annotation.get(field)
242
+ if not isinstance(values, list):
243
+ continue
244
+ bad_values = [
245
+ str(value)
246
+ for value in values
247
+ if isinstance(value, str) and NON_TITLE_NUMERIC_FRAGMENT_RE.fullmatch(value)
248
+ ]
249
+ if bad_values:
250
+ errors.append(
251
+ f"{field} must not contain pure episode/version/volume fragments: {bad_values[:5]}"
252
+ )
253
+ return errors
254
+
255
+
256
+ def extract_response_annotation(data: dict[str, Any]) -> Any:
257
+ output_text = data.get("output_text")
258
+ if isinstance(output_text, str) and output_text.strip():
259
+ return json.loads(output_text)
260
+
261
+ for item in data.get("output") or []:
262
+ if not isinstance(item, dict):
263
+ continue
264
+ if item.get("type") in {"function_call", "tool_call"}:
265
+ arguments = item.get("arguments")
266
+ if isinstance(arguments, str):
267
+ return json.loads(arguments)
268
+ if isinstance(arguments, dict):
269
+ return arguments
270
+ for content in item.get("content") or []:
271
+ if not isinstance(content, dict):
272
+ continue
273
+ if content.get("type") in {"output_json", "json"} and "json" in content:
274
+ return content["json"]
275
+ text = content.get("text")
276
+ if isinstance(text, str) and text.strip():
277
+ return json.loads(text)
278
+ raise ValueError("Responses API returned no annotation payload")
279
+
280
+
281
+ def format_error_hint(error: str | None) -> str:
282
+ if not error:
283
+ return ""
284
+ return (
285
+ "\nPrevious attempt failed validation. Correct the issue exactly and submit a new schema-valid "
286
+ f"annotation. Error: {error}"
287
+ )
288
+
289
+
290
+ def request_body(
291
+ *,
292
+ args: Args,
293
+ terminal: dict[str, Any],
294
+ patch: dict[str, Any],
295
+ previous_error: str | None,
296
+ mode: str,
297
+ ) -> dict[str, Any]:
298
+ task = {
299
+ "terminal_id": patch["terminal_id"],
300
+ "prefix": terminal.get("prefix"),
301
+ "digit_skeleton": terminal.get("digit_skeleton"),
302
+ "suffix_examples": terminal.get("suffix_examples") or [],
303
+ "value_examples": terminal.get("value_examples") or [],
304
+ "heuristic_patch": {
305
+ "episode_title_suffixes": patch.get("episode_title_suffixes") or [],
306
+ "media_suffixes": patch.get("media_suffixes") or [],
307
+ "title_candidates": patch.get("title_candidates") or [],
308
+ "notes": patch.get("notes") or "",
309
+ },
310
+ }
311
+ instructions = (
312
+ "Annotate one anime filename prefix-tree terminal. Distinguish human episode-title suffix text "
313
+ "from media metadata such as resolution, source, codec, audio, subtitles, language, hashes, and "
314
+ "release tags. Do not put pure episode numbers, episode versions like 01v2, version tokens like "
315
+ "ver1, or volume markers like Vol.1 in episode_title_suffixes or title_candidates; those are "
316
+ "episode/version structure, not human title text. Keep the same terminal_id. Return only the "
317
+ "required strict schema data."
318
+ + format_error_hint(previous_error)
319
+ )
320
+ body: dict[str, Any] = {
321
+ "model": args.model,
322
+ "input": [
323
+ {"role": "developer", "content": instructions},
324
+ {"role": "user", "content": json.dumps(task, ensure_ascii=False)},
325
+ ],
326
+ }
327
+ if "reasoning" in mode:
328
+ body["reasoning"] = {"effort": args.reasoning_effort}
329
+ if "json_schema" in mode:
330
+ body["text"] = {
331
+ "format": {
332
+ "type": "json_schema",
333
+ "name": "dmhy_prefix_terminal_annotation",
334
+ "strict": True,
335
+ "schema": ANNOTATION_SCHEMA,
336
+ }
337
+ }
338
+ else:
339
+ body["tools"] = [
340
+ {
341
+ "type": "function",
342
+ "name": "submit_annotation",
343
+ "description": "Submit the validated DMHY terminal annotation.",
344
+ "strict": True,
345
+ "parameters": ANNOTATION_SCHEMA,
346
+ }
347
+ ]
348
+ body["tool_choice"] = {"type": "function", "name": "submit_annotation"}
349
+ return body
350
+
351
+
352
+ def post_responses(body: dict[str, Any], args: Args) -> dict[str, Any]:
353
+ if not args.api_key:
354
+ raise RuntimeError("--llm requires --api-key or ANIFILEBERT_LLM_API_KEY")
355
+ request = urllib.request.Request(
356
+ responses_url(args.base_url),
357
+ data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
358
+ headers={
359
+ "Authorization": f"Bearer {args.api_key}",
360
+ "Content-Type": "application/json",
361
+ },
362
+ method="POST",
363
+ )
364
+ try:
365
+ with urllib.request.urlopen(request, timeout=args.http_timeout) as response:
366
+ return json.loads(response.read().decode("utf-8"))
367
+ except urllib.error.HTTPError as exc:
368
+ detail = exc.read().decode("utf-8", errors="replace")
369
+ raise RuntimeError(f"Responses API HTTP {exc.code}: {redact_secrets(detail[:500])}") from exc
370
+ except urllib.error.URLError as exc:
371
+ raise RuntimeError(f"Responses API request failed: {redact_secrets(str(exc.reason))}") from exc
372
+ except json.JSONDecodeError as exc:
373
+ raise RuntimeError("Responses API returned invalid response JSON") from exc
374
+
375
+
376
+ def next_mode(mode: str, error: str) -> str:
377
+ lowered = error.lower()
378
+ if mode == "reasoning_json_schema" and "reasoning" in lowered:
379
+ return "json_schema"
380
+ if "json_schema" in mode and any(token in lowered for token in ("text.format", "json_schema", "schema")):
381
+ return "function_tool"
382
+ if mode == "reasoning_json_schema":
383
+ return "json_schema"
384
+ return mode
385
+
386
+
387
+ def merge_llm_annotation(patch: dict[str, Any], annotation: dict[str, Any]) -> dict[str, Any]:
388
+ merged = dict(patch)
389
+ merged.update(
390
+ {
391
+ "episode_title_suffixes": annotation["episode_title_suffixes"],
392
+ "media_suffixes": annotation["media_suffixes"],
393
+ "title_candidates": annotation["title_candidates"],
394
+ "llm_label": annotation["llm_label"],
395
+ "notes": annotation["notes"],
396
+ "source": LLM_SOURCE,
397
+ "status": "ok",
398
+ "fallback": False,
399
+ "errors": [],
400
+ }
401
+ )
402
+ return merged
403
+
404
+
405
+ def annotate_terminal_with_llm(
406
+ terminal: dict[str, Any],
407
+ patch: dict[str, Any],
408
+ args: Args,
409
+ ) -> tuple[dict[str, Any], bool]:
410
+ mode = "reasoning_json_schema"
411
+ previous_error: str | None = None
412
+ errors: list[str] = []
413
+ for _attempt in range(args.retries):
414
+ try:
415
+ body = request_body(args=args, terminal=terminal, patch=patch, previous_error=previous_error, mode=mode)
416
+ data = post_responses(body, args)
417
+ raw_annotation = extract_response_annotation(data)
418
+ annotation = validate_strict_annotation(raw_annotation, expected_terminal_id=str(patch["terminal_id"]))
419
+ return merge_llm_annotation(patch, annotation), False
420
+ except (RuntimeError, ValueError, json.JSONDecodeError) as exc:
421
+ error = redact_secrets(str(exc))
422
+ errors.append(error)
423
+ mode = next_mode(mode, error)
424
+ previous_error = error
425
+ fallback = dict(patch)
426
+ fallback["notes"] = f"{fallback.get('notes') or ''}; llm_error={' | '.join(errors)}"
427
+ fallback["status"] = "fallback"
428
+ fallback["fallback"] = True
429
+ fallback["errors"] = errors[-3:]
430
+ return fallback, True
431
+
432
+
433
+ def write_jsonl(path: Path, rows: Iterable[dict[str, Any]]) -> int:
434
+ path.parent.mkdir(parents=True, exist_ok=True)
435
+ count = 0
436
+ with path.open("w", encoding="utf-8", newline="\n") as handle:
437
+ for row in rows:
438
+ handle.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n")
439
+ count += 1
440
+ return count
441
+
442
+
443
+ def is_completed_resume_row(row: dict[str, Any]) -> bool:
444
+ status = row.get("status")
445
+ source = str(row.get("source") or "")
446
+ if status == "ok" and source.startswith("responses"):
447
+ return not annotation_quality_errors(row)
448
+ return status is None and source == LLM_SOURCE
449
+
450
+
451
+ def load_resume_patches(path: Path | None) -> dict[str, dict[str, Any]]:
452
+ if path is None or not path.exists():
453
+ return {}
454
+ completed: dict[str, dict[str, Any]] = {}
455
+ for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
456
+ if not line.strip():
457
+ continue
458
+ try:
459
+ row = json.loads(line)
460
+ except json.JSONDecodeError as exc:
461
+ raise SystemExit(f"invalid resume patch JSON in {path}:{line_number}: {exc}") from exc
462
+ if not isinstance(row, dict) or not is_completed_resume_row(row):
463
+ continue
464
+ keys: list[str] = []
465
+ for key in ("terminal_id", "unit_id"):
466
+ value = row.get(key)
467
+ if isinstance(value, (str, int)) and not isinstance(value, bool):
468
+ keys.append(str(value))
469
+ terminal_ids = row.get("terminal_ids")
470
+ if isinstance(terminal_ids, list):
471
+ keys.extend(str(item) for item in terminal_ids if isinstance(item, (str, int)) and not isinstance(item, bool))
472
+ for key in keys:
473
+ completed[key] = row
474
+ return completed
475
+
476
+
477
+ def patch_from_resume_row(patch: dict[str, Any], row: dict[str, Any]) -> dict[str, Any]:
478
+ merged = dict(patch)
479
+ for key in ("episode_title_suffixes", "media_suffixes", "title_candidates"):
480
+ merged[key] = unique_strings(row.get(key) or [], field=key)
481
+ if "llm_label" in row:
482
+ merged["llm_label"] = row.get("llm_label")
483
+ if "notes" in row:
484
+ merged["notes"] = str(row.get("notes") or "")
485
+ merged["source"] = str(row.get("source") or patch.get("source") or LLM_SOURCE)
486
+ merged["fallback"] = False
487
+ merged["status"] = "ok"
488
+ errors = row.get("errors")
489
+ merged["errors"] = errors if isinstance(errors, list) else []
490
+ return enrich_patch(merged)
491
+
492
+
493
+ def resume_row_for_patch(
494
+ resume_rows: dict[str, dict[str, Any]],
495
+ patch: dict[str, Any],
496
+ ) -> dict[str, Any] | None:
497
+ for key in (patch.get("terminal_id"), patch.get("unit_id")):
498
+ if key is None:
499
+ continue
500
+ row = resume_rows.get(str(key))
501
+ if row is not None:
502
+ return row
503
+ return None
504
+
505
+
506
+ def annotate_selected(
507
+ selected: list[tuple[int, dict[str, Any], dict[str, Any]]],
508
+ args: Args,
509
+ ) -> tuple[list[tuple[int, int, dict[str, Any], dict[str, Any]]], int, int, int, int]:
510
+ resume_rows = load_resume_patches(args.patch_output) if args.resume else {}
511
+ results: list[dict[str, Any] | None] = [None] * len(selected)
512
+ pending_llm: list[tuple[int, dict[str, Any], dict[str, Any]]] = []
513
+ resume_skipped = 0
514
+
515
+ for ordinal, (_index, _terminal, patch) in enumerate(selected):
516
+ row = resume_row_for_patch(resume_rows, patch)
517
+ if row is not None:
518
+ results[ordinal] = patch_from_resume_row(patch, row)
519
+ resume_skipped += 1
520
+ continue
521
+ if not args.llm:
522
+ results[ordinal] = patch
523
+ continue
524
+ pending_llm.append((ordinal, selected[ordinal][1], patch))
525
+
526
+ candidates = pending_llm
527
+ if args.max_requests is not None:
528
+ candidates = candidates[: args.max_requests]
529
+
530
+ fallback_count = 0
531
+ failure_rows: list[dict[str, Any]] = []
532
+ if candidates:
533
+ with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as executor:
534
+ future_to_ordinal = {
535
+ executor.submit(annotate_terminal_with_llm, terminal, patch, args): ordinal
536
+ for ordinal, terminal, patch in candidates
537
+ }
538
+ for future in concurrent.futures.as_completed(future_to_ordinal):
539
+ ordinal = future_to_ordinal[future]
540
+ try:
541
+ patch, used_fallback = future.result()
542
+ except Exception as exc: # defensive; worker should already convert errors to fallback
543
+ patch = dict(selected[ordinal][2])
544
+ patch["errors"] = [redact_secrets(str(exc))]
545
+ used_fallback = True
546
+ if used_fallback:
547
+ fallback_count += 1
548
+ terminal_id = selected[ordinal][2]["terminal_id"]
549
+ failure_rows.append(llm_failure_row(selected[ordinal][1], patch, args))
550
+ print(f"warning: terminal {terminal_id}: LLM failed; leaving pending", file=sys.stderr)
551
+ continue
552
+ results[ordinal] = patch
553
+
554
+ if args.failure_output is not None:
555
+ write_jsonl(args.failure_output, failure_rows)
556
+
557
+ completed = [
558
+ (ordinal, selected[ordinal][0], selected[ordinal][1], patch)
559
+ for ordinal, patch in enumerate(results)
560
+ if patch is not None
561
+ ]
562
+ pending_unprocessed = len(selected) - len(completed)
563
+ return completed, resume_skipped, len(candidates), fallback_count, pending_unprocessed
564
+
565
+
566
+ def llm_failure_row(terminal: dict[str, Any], patch: dict[str, Any], args: Args) -> dict[str, Any]:
567
+ terminal_id = str(patch.get("terminal_id") or terminal.get("terminal_id") or "")
568
+ errors = patch.get("errors")
569
+ return {
570
+ "terminal_id": terminal_id,
571
+ "unit_id": str(patch.get("unit_id") or terminal_id),
572
+ "model": args.model,
573
+ "status": "pending",
574
+ "source": LLM_SOURCE,
575
+ "errors": errors if isinstance(errors, list) else [],
576
+ "prefix": terminal.get("prefix") if isinstance(terminal.get("prefix"), str) else None,
577
+ "digit_skeleton": terminal.get("digit_skeleton") if isinstance(terminal.get("digit_skeleton"), str) else None,
578
+ "suffix_examples": string_list(terminal.get("suffix_examples")),
579
+ "value_examples": string_list(terminal.get("value_examples")),
580
+ }
581
+
582
+
583
+ def source_id(terminal: dict[str, Any], index: int, terminal_id: str) -> str | int:
584
+ for key in ("node_id", "id"):
585
+ value = terminal.get(key)
586
+ if isinstance(value, (str, int)) and not isinstance(value, bool):
587
+ return value
588
+ return terminal_id or index
589
+
590
+
591
+ def int_weight(terminal: dict[str, Any]) -> int:
592
+ try:
593
+ return int(terminal.get("weight") or terminal.get("count") or 0)
594
+ except (TypeError, ValueError):
595
+ return 0
596
+
597
+
598
+ def enrich_patch(patch: dict[str, Any]) -> dict[str, Any]:
599
+ terminal_id = str(patch["terminal_id"])
600
+ enriched = dict(patch)
601
+ enriched.setdefault("unit_id", terminal_id)
602
+ enriched.setdefault("terminal_ids", [terminal_id])
603
+ enriched.setdefault("status", "ok")
604
+ enriched.setdefault("fallback", False)
605
+ enriched.setdefault("errors", [])
606
+ return enriched
607
+
608
+
609
+ def unit_public_row(index: int, terminal: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
610
+ terminal_id = str(patch["terminal_id"])
611
+ return {
612
+ "unit_id": str(patch.get("unit_id") or terminal_id),
613
+ "source_kind": "prefix_tree",
614
+ "source_id": source_id(terminal, index, terminal_id),
615
+ "terminal_ids": [terminal_id],
616
+ "weight": int_weight(terminal),
617
+ "context": {
618
+ "prefixes": string_list([terminal.get("prefix")] if terminal.get("prefix") is not None else []),
619
+ "digit_skeletons": string_list(
620
+ [terminal.get("digit_skeleton")] if terminal.get("digit_skeleton") is not None else []
621
+ ),
622
+ "edge_labels": [],
623
+ "notes": patch.get("notes") if isinstance(patch.get("notes"), str) else None,
624
+ },
625
+ "examples": {
626
+ "values": string_list(terminal.get("value_examples")),
627
+ "suffixes": string_list(terminal.get("suffix_examples")),
628
+ },
629
+ "expected_output": {"schema_version": "dmhy-annotation-v1"},
630
+ }
631
+
632
+
633
+ def main() -> None:
634
+ args = parse_args()
635
+ graph = load_graph(args.graph)
636
+ selected = selected_terminals(graph, args)
637
+ if not selected:
638
+ raise SystemExit("no terminals selected; adjust --limit/--min-weight/--only-needs-review")
639
+
640
+ selected = [(index, terminal, enrich_patch(patch)) for index, terminal, patch in selected]
641
+ completed, resume_skipped, llm_requests, llm_fallbacks, pending_unprocessed = annotate_selected(selected, args)
642
+ tokenizer = AnimeTokenizer()
643
+ source_matches = None if args.examples_only else source_list_matches(args.source_list, selected)
644
+
645
+ records: list[dict[str, Any]] = []
646
+ patches: list[dict[str, Any]] = []
647
+ completed_by_ordinal = {ordinal: patch for ordinal, _index, _terminal, patch in completed}
648
+ for ordinal, index, terminal, patch in completed:
649
+ patches.append(patch)
650
+ records.extend(
651
+ dataset_records(
652
+ terminal,
653
+ index,
654
+ patch,
655
+ tokenizer,
656
+ filenames=None if args.examples_only else source_matches.get(ordinal, []),
657
+ preserve_i_labels=args.preserve_i_labels,
658
+ )
659
+ )
660
+
661
+ record_count = write_jsonl(args.output, records)
662
+ patch_count = 0
663
+ if args.patch_output is not None:
664
+ patch_count = write_jsonl(args.patch_output, patches)
665
+ unit_count = 0
666
+ if args.units_output is not None:
667
+ unit_count = write_jsonl(
668
+ args.units_output,
669
+ (
670
+ unit_public_row(index, terminal, completed_by_ordinal.get(ordinal, patch))
671
+ for ordinal, (index, terminal, patch) in enumerate(selected)
672
+ ),
673
+ )
674
+
675
+ summary = {
676
+ "graph": str(args.graph),
677
+ "source_list": None if args.examples_only else str(args.source_list),
678
+ "output": str(args.output),
679
+ "patch_output": str(args.patch_output) if args.patch_output is not None else None,
680
+ "units_output": str(args.units_output) if args.units_output is not None else None,
681
+ "selected_terminals": len(selected),
682
+ "examples_only": args.examples_only,
683
+ "dataset_records": record_count,
684
+ "patches": patch_count,
685
+ "units": unit_count,
686
+ "llm_enabled": args.llm,
687
+ "llm_requests": llm_requests,
688
+ "llm_fallbacks": llm_fallbacks,
689
+ "resume": args.resume,
690
+ "resume_skipped": resume_skipped,
691
+ "pending_unprocessed": pending_unprocessed,
692
+ "workers": args.workers,
693
+ }
694
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
695
+
696
+
697
+ if __name__ == "__main__":
698
+ main()
tools/expand_dmhy_dag_annotations.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Expand DAG annotation patches over the full DMHY source list.
2
+
3
+ The DAG annotation runner samples terminal.value_examples. This tool keeps the
4
+ LLM patch layer intact, but applies it to every dmhy_list.jsonl value by
5
+ deterministic terminal prefix matching.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ from collections import Counter
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any, Iterable
16
+
17
+ from anifilebert.tokenizer import AnimeTokenizer
18
+ from tools.annotate_dmhy_prefix_graph import (
19
+ build_prefix_trie,
20
+ matching_terminal_ordinal,
21
+ normalize_generated_tokens,
22
+ prefix_boundary_ok as prefix_boundary_ok,
23
+ )
24
+ from tools.dmhy_dataset import weak_label_filename
25
+
26
+
27
+ DEFAULT_DAG = Path("datasets/AnimeName/dmhy_prefix_dag.json")
28
+ DEFAULT_SOURCE_LIST = Path("datasets/AnimeName/dmhy_list.jsonl")
29
+ DEFAULT_PATCHES = Path("datasets/AnimeName/dmhy_prefix_dag.annotations.dag_full.jsonl")
30
+ DEFAULT_OUTPUT = Path("datasets/AnimeName/dmhy_weak.dag_full_expanded.jsonl")
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Args:
35
+ dag: Path
36
+ source_list: Path
37
+ patches: Path
38
+ output: Path
39
+ manifest_output: Path
40
+ limit: int | None
41
+ preserve_i_labels: bool
42
+ strict_boundary: bool
43
+
44
+
45
+ def parse_args() -> Args:
46
+ parser = argparse.ArgumentParser(
47
+ description="Expand DMHY DAG terminal annotations across the full source-list"
48
+ )
49
+ parser.add_argument("--dag", type=Path, default=DEFAULT_DAG)
50
+ parser.add_argument("--source-list", type=Path, default=DEFAULT_SOURCE_LIST)
51
+ parser.add_argument("--patches", type=Path, default=DEFAULT_PATCHES)
52
+ parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
53
+ parser.add_argument(
54
+ "--manifest-output",
55
+ type=Path,
56
+ default=None,
57
+ help="Output manifest JSON; defaults to <output stem>.manifest.json",
58
+ )
59
+ parser.add_argument("--limit", type=int, default=None, help="Maximum source-list rows to scan")
60
+ parser.add_argument("--preserve-i-labels", action="store_true")
61
+ parser.add_argument(
62
+ "--strict-boundary",
63
+ action="store_true",
64
+ help="Require a separator boundary after the matched terminal prefix",
65
+ )
66
+ ns = parser.parse_args()
67
+ manifest_output = ns.manifest_output or ns.output.with_suffix(".manifest.json")
68
+ return Args(
69
+ dag=ns.dag,
70
+ source_list=ns.source_list,
71
+ patches=ns.patches,
72
+ output=ns.output,
73
+ manifest_output=manifest_output,
74
+ limit=None if ns.limit is None else max(0, ns.limit),
75
+ preserve_i_labels=ns.preserve_i_labels,
76
+ strict_boundary=ns.strict_boundary,
77
+ )
78
+
79
+
80
+ def load_json(path: Path) -> dict[str, Any]:
81
+ if not path.exists():
82
+ raise SystemExit(f"file not found: {path}")
83
+ try:
84
+ value = json.loads(path.read_text(encoding="utf-8"))
85
+ except json.JSONDecodeError as exc:
86
+ raise SystemExit(f"invalid JSON in {path}: {exc}") from exc
87
+ if not isinstance(value, dict):
88
+ raise SystemExit(f"invalid JSON schema in {path}: root must be object")
89
+ return value
90
+
91
+
92
+ def iter_jsonl(path: Path) -> Iterable[tuple[int, dict[str, Any]]]:
93
+ if not path.exists():
94
+ raise SystemExit(f"file not found: {path}")
95
+ with path.open("r", encoding="utf-8") as handle:
96
+ for line_number, line in enumerate(handle, start=1):
97
+ if not line.strip():
98
+ continue
99
+ try:
100
+ row = json.loads(line)
101
+ except json.JSONDecodeError as exc:
102
+ raise SystemExit(f"invalid JSON in {path}:{line_number}: {exc}") from exc
103
+ if isinstance(row, dict):
104
+ yield line_number, row
105
+
106
+
107
+ def terminal_id(terminal: dict[str, Any], fallback: int) -> str:
108
+ for key in ("terminal_id", "id", "node_id"):
109
+ value = terminal.get(key)
110
+ if value is not None:
111
+ return str(value)
112
+ return str(fallback)
113
+
114
+
115
+ def load_terminals(dag_path: Path) -> list[tuple[int, dict[str, Any], dict[str, Any]]]:
116
+ dag = load_json(dag_path)
117
+ terminals = dag.get("terminals")
118
+ if not isinstance(terminals, list):
119
+ raise SystemExit(f"invalid DAG schema in {dag_path}: missing terminals list")
120
+
121
+ selected: list[tuple[int, dict[str, Any], dict[str, Any]]] = []
122
+ for index, terminal in enumerate(terminals):
123
+ if not isinstance(terminal, dict):
124
+ continue
125
+ row = dict(terminal)
126
+ row["_terminal_id"] = terminal_id(row, index)
127
+ row["_terminal_index"] = index
128
+ if not str(row.get("prefix") or ""):
129
+ continue
130
+ selected.append((index, row, {}))
131
+ if not selected:
132
+ raise SystemExit(f"DAG has no prefix-bearing terminals: {dag_path}")
133
+ return selected
134
+
135
+
136
+ def load_patches(path: Path) -> tuple[dict[str, dict[str, Any]], Counter[str]]:
137
+ patches_by_terminal: dict[str, dict[str, Any]] = {}
138
+ patch_statuses: Counter[str] = Counter()
139
+ for _line_number, patch in iter_jsonl(path):
140
+ terminal_ids = patch.get("terminal_ids")
141
+ if not isinstance(terminal_ids, list):
142
+ terminal_ids = [patch.get("terminal_id")] if patch.get("terminal_id") is not None else []
143
+ clean_ids = [str(item) for item in terminal_ids if str(item).strip()]
144
+ status = str(patch.get("status") or ("fallback" if patch.get("fallback") else "ok"))
145
+ patch_statuses[status] += 1
146
+ for tid in clean_ids:
147
+ patches_by_terminal[tid] = patch
148
+ if not patches_by_terminal:
149
+ raise SystemExit(f"no terminal patches found in {path}")
150
+ return patches_by_terminal, patch_statuses
151
+
152
+
153
+ def matching_terminal_ordinal_relaxed(
154
+ value: str,
155
+ trie: Any,
156
+ selected: list[tuple[int, dict[str, Any], dict[str, Any]]],
157
+ *,
158
+ strict_boundary: bool,
159
+ ) -> int | None:
160
+ node = trie
161
+ best: int | None = None
162
+ for char in value:
163
+ node = node.children.get(char)
164
+ if node is None:
165
+ break
166
+ for ordinal in node.terminal_ordinals:
167
+ if not strict_boundary:
168
+ best = ordinal
169
+ continue
170
+ prefix = str(selected[ordinal][1].get("prefix") or "")
171
+ if prefix_boundary_ok(value, prefix):
172
+ best = ordinal
173
+ return best
174
+
175
+
176
+ def row_from_match(
177
+ *,
178
+ filename: str,
179
+ source_line: int,
180
+ terminal: dict[str, Any],
181
+ patch: dict[str, Any],
182
+ tokenizer: AnimeTokenizer,
183
+ preserve_i_labels: bool,
184
+ ) -> dict[str, Any] | None:
185
+ sample = weak_label_filename(filename, tokenizer)
186
+ if sample is None:
187
+ return None
188
+ tokens, labels = normalize_generated_tokens(
189
+ sample["tokens"],
190
+ sample["labels"],
191
+ preserve_i_labels=preserve_i_labels,
192
+ )
193
+ terminal_id_value = str(terminal["_terminal_id"])
194
+ unit_id = str(patch.get("unit_id") or terminal_id_value)
195
+ terminal_index = terminal["_terminal_index"]
196
+ source = str(patch.get("source") or "unknown")
197
+ fallback = bool(patch.get("fallback", False))
198
+ status = str(patch.get("status") or ("fallback" if fallback else "ok"))
199
+ episode_title_suffixes = patch.get("episode_title_suffixes") or []
200
+ media_suffixes = patch.get("media_suffixes") or []
201
+ title_candidates = patch.get("title_candidates") or []
202
+ return {
203
+ "file_id": f"prefix-dag-expanded:{unit_id}:{terminal_id_value}:{source_line}",
204
+ "filename": filename,
205
+ "tokens": tokens,
206
+ "labels": labels,
207
+ "terminal_id": terminal_id_value,
208
+ "terminal_index": terminal_index,
209
+ "unit_id": unit_id,
210
+ "source": source,
211
+ "needs_llm_review": fallback,
212
+ "episode_title_suffixes": episode_title_suffixes,
213
+ "media_suffixes": media_suffixes,
214
+ "title_candidates": title_candidates,
215
+ "annotations": {
216
+ "unit_id": unit_id,
217
+ "terminal_id": terminal_id_value,
218
+ "terminal_ids": [str(item) for item in patch.get("terminal_ids") or [terminal_id_value]],
219
+ "terminal_index": terminal_index,
220
+ "source": source,
221
+ "fallback": fallback,
222
+ "status": status,
223
+ "episode_title_suffixes": episode_title_suffixes,
224
+ "media_suffixes": media_suffixes,
225
+ "title_candidates": title_candidates,
226
+ "llm_label": patch.get("llm_label"),
227
+ "notes": patch.get("notes"),
228
+ },
229
+ }
230
+
231
+
232
+ def expand(args: Args) -> dict[str, Any]:
233
+ selected = load_terminals(args.dag)
234
+ patches_by_terminal, patch_statuses = load_patches(args.patches)
235
+ trie = build_prefix_trie(selected)
236
+ tokenizer = AnimeTokenizer()
237
+
238
+ input_rows = 0
239
+ matched_source_rows = 0
240
+ emitted_rows = 0
241
+ unmatched_rows = 0
242
+ weak_label_failed_rows = 0
243
+ patch_covered_rows = 0
244
+ unique_terminals_matched: set[str] = set()
245
+ status_counts: Counter[str] = Counter()
246
+
247
+ args.output.parent.mkdir(parents=True, exist_ok=True)
248
+ with args.output.open("w", encoding="utf-8", newline="\n") as output_handle:
249
+ for source_line, source_row in iter_jsonl(args.source_list):
250
+ if args.limit is not None and input_rows >= args.limit:
251
+ break
252
+ input_rows += 1
253
+ value = source_row.get("value")
254
+ if not isinstance(value, str) or not value.strip():
255
+ unmatched_rows += 1
256
+ status_counts["unmatched"] += 1
257
+ continue
258
+
259
+ ordinal = matching_terminal_ordinal_relaxed(
260
+ value,
261
+ trie,
262
+ selected,
263
+ strict_boundary=args.strict_boundary,
264
+ )
265
+ if ordinal is None:
266
+ unmatched_rows += 1
267
+ status_counts["unmatched"] += 1
268
+ continue
269
+
270
+ matched_source_rows += 1
271
+ terminal = selected[ordinal][1]
272
+ terminal_id_value = str(terminal["_terminal_id"])
273
+ unique_terminals_matched.add(terminal_id_value)
274
+ patch = patches_by_terminal.get(terminal_id_value)
275
+ if patch is None:
276
+ status_counts["missing_patch"] += 1
277
+ continue
278
+
279
+ row = row_from_match(
280
+ filename=value,
281
+ source_line=source_line,
282
+ terminal=terminal,
283
+ patch=patch,
284
+ tokenizer=tokenizer,
285
+ preserve_i_labels=args.preserve_i_labels,
286
+ )
287
+ if row is None:
288
+ weak_label_failed_rows += 1
289
+ status_counts["weak_label_failed"] += 1
290
+ continue
291
+
292
+ output_handle.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n")
293
+ emitted_rows += 1
294
+ patch_covered_rows += 1
295
+ status_counts[str(row["annotations"]["status"])] += 1
296
+
297
+ manifest = {
298
+ "dag": str(args.dag),
299
+ "source_list": str(args.source_list),
300
+ "patches": str(args.patches),
301
+ "output": str(args.output),
302
+ "input_rows": input_rows,
303
+ "matched_rows": matched_source_rows,
304
+ "output_rows": emitted_rows,
305
+ "unmatched_rows": unmatched_rows,
306
+ "weak_label_failed_rows": weak_label_failed_rows,
307
+ "patch_covered_rows": patch_covered_rows,
308
+ "unique_terminals_matched": len(unique_terminals_matched),
309
+ "status_counts": dict(sorted(status_counts.items())),
310
+ "patch_status_counts": dict(sorted(patch_statuses.items())),
311
+ }
312
+ args.manifest_output.parent.mkdir(parents=True, exist_ok=True)
313
+ args.manifest_output.write_text(
314
+ json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
315
+ encoding="utf-8",
316
+ )
317
+ return manifest
318
+
319
+
320
+ def main() -> None:
321
+ manifest = expand(parse_args())
322
+ print(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True))
323
+
324
+
325
+ if __name__ == "__main__":
326
+ main()
tools/test_annotate_dmhy_dag_agent.py ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke tests for the independent DMHY DAG annotation runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from pathlib import Path
11
+
12
+ import tools.annotate_dmhy_dag_agent as agent
13
+
14
+
15
+ def make_args(tmp: Path | None = None, **overrides) -> agent.Args:
16
+ values = {
17
+ "dag": Path("dag.json"),
18
+ "output": Path("out.jsonl"),
19
+ "patch_output": (tmp / "patch.jsonl") if tmp is not None else Path("patch.jsonl"),
20
+ "units_output": Path("units.jsonl"),
21
+ "limit": 10,
22
+ "max_requests": 10,
23
+ "units_per_request": 10,
24
+ "max_context_units": 10,
25
+ "workers": 8,
26
+ "coverage_mode": agent.COVERAGE_SHARED_ONLY,
27
+ "min_incoming_count": 2,
28
+ "min_reachable_terminals": 2,
29
+ "max_reachable_terminals": 500,
30
+ "example_count": 2,
31
+ "max_records": 20,
32
+ "records_per_terminal": 1,
33
+ "terminal_sink_threshold": 1000,
34
+ "terminal_cluster_size": 1,
35
+ "llm": True,
36
+ "base_url": "http://example.test/v1",
37
+ "api_key": "test-key",
38
+ "model": "gpt-5.4-mini",
39
+ "http_timeout": 1,
40
+ "retries": 2,
41
+ "preserve_i_labels": False,
42
+ "resume": False,
43
+ }
44
+ values.update(overrides)
45
+ return agent.Args(**values)
46
+
47
+
48
+ def make_unit(unit_id: str, terminal_ids: list[str]) -> dict:
49
+ terminals = [
50
+ {
51
+ "_terminal_id": terminal_id,
52
+ "_terminal_index": index,
53
+ "value_examples": [f"Show {terminal_id} 01 [1080P].mkv"],
54
+ "suffix_examples": ["01 [1080P]"],
55
+ "weight": 1,
56
+ }
57
+ for index, terminal_id in enumerate(terminal_ids)
58
+ ]
59
+ return {
60
+ "unit_id": unit_id,
61
+ "node_id": 1,
62
+ "kind": "shared_suffix",
63
+ "incoming_count": 2,
64
+ "reachable_terminals": len(terminal_ids),
65
+ "reachable_weight": len(terminal_ids),
66
+ "terminal_ids": terminal_ids,
67
+ "prefix_examples": ["Show "],
68
+ "digit_skeleton_examples": [],
69
+ "value_examples": [terminal["value_examples"][0] for terminal in terminals],
70
+ "suffix_examples": ["01 [1080P]"],
71
+ "common_edge_labels": ["01"],
72
+ "_terminals": terminals,
73
+ }
74
+
75
+
76
+ def response_for(units: list[dict], wrong_first_id: str | None = None) -> dict:
77
+ annotations = []
78
+ for index, unit in enumerate(units):
79
+ annotations.append(
80
+ {
81
+ "unit_id": wrong_first_id if index == 0 and wrong_first_id is not None else unit["unit_id"],
82
+ "terminal_ids": unit["terminal_ids"],
83
+ "episode_title_suffixes": [],
84
+ "media_suffixes": ["[1080P]"],
85
+ "title_candidates": [],
86
+ "llm_label": "media",
87
+ "notes": "ok",
88
+ }
89
+ )
90
+ return {"output_text": json.dumps({"annotations": annotations}, ensure_ascii=False)}
91
+
92
+
93
+ class AnnotateDmhyDagAgentTests(unittest.TestCase):
94
+ def test_batch_request_schema_contains_annotations_array(self) -> None:
95
+ args = make_args()
96
+ units = [make_unit("u1", ["t1"]), make_unit("u2", ["t2"])]
97
+ body = agent.request_variants(units, args)[0]
98
+ schema = body["text"]["format"]["schema"]
99
+ self.assertEqual(body["text"]["format"]["name"], "dmhy_dag_batch_annotation")
100
+ self.assertIn("annotations", schema["required"])
101
+ self.assertEqual(schema["properties"]["annotations"]["items"]["required"][0], "unit_id")
102
+ payload = json.loads(body["input"])
103
+ self.assertEqual([unit["unit_id"] for unit in payload["units"]], ["u1", "u2"])
104
+
105
+ def test_batch_mismatch_retries_with_validation_error(self) -> None:
106
+ args = make_args(retries=2)
107
+ units = [make_unit("u1", ["t1"]), make_unit("u2", ["t2"])]
108
+ calls: list[dict] = []
109
+ original_post = agent.http_post_json
110
+ original_sleep = agent.time.sleep
111
+
112
+ def fake_post(body, _args):
113
+ calls.append(body)
114
+ return response_for(units, wrong_first_id="wrong") if len(calls) == 1 else response_for(units)
115
+
116
+ try:
117
+ agent.http_post_json = fake_post
118
+ agent.time.sleep = lambda _seconds: None
119
+ patches = agent.annotate_batch(units, args, use_llm=True)
120
+ finally:
121
+ agent.http_post_json = original_post
122
+ agent.time.sleep = original_sleep
123
+
124
+ self.assertEqual(len(calls), 2)
125
+ self.assertIn("Previous attempt failed validation", calls[1]["instructions"])
126
+ self.assertEqual([patch["unit_id"] for patch in patches], ["u1", "u2"])
127
+ self.assertTrue(all(patch["source"] == agent.LLM_SOURCE for patch in patches))
128
+ self.assertTrue(all(not patch["fallback"] for patch in patches))
129
+
130
+ def test_resume_skips_existing_completed_units_and_keeps_order(self) -> None:
131
+ with tempfile.TemporaryDirectory() as tmpdir:
132
+ tmp = Path(tmpdir)
133
+ args = make_args(tmp, resume=True, units_per_request=10, max_context_units=10, workers=1)
134
+ units = [make_unit("u1", ["t1"]), make_unit("u2", ["t2"])]
135
+ args.patch_output.write_text(
136
+ json.dumps(
137
+ {
138
+ "unit_id": "u1",
139
+ "terminal_ids": ["t1"],
140
+ "episode_title_suffixes": ["Finale"],
141
+ "media_suffixes": [],
142
+ "title_candidates": [],
143
+ "llm_label": "title",
144
+ "notes": "existing",
145
+ "source": agent.LLM_SOURCE,
146
+ "fallback": False,
147
+ "status": "ok",
148
+ "attempts": 1,
149
+ "errors": [],
150
+ },
151
+ ensure_ascii=False,
152
+ )
153
+ + "\n",
154
+ encoding="utf-8",
155
+ )
156
+ calls: list[list[str]] = []
157
+ original_post = agent.http_post_json
158
+
159
+ def fake_post(body, _args):
160
+ payload = json.loads(body["input"])
161
+ calls.append([unit["unit_id"] for unit in payload["units"]])
162
+ return response_for([units[1]])
163
+
164
+ try:
165
+ agent.http_post_json = fake_post
166
+ completed, skipped, llm_requested, pending_unprocessed = agent.annotate_units(units, args)
167
+ finally:
168
+ agent.http_post_json = original_post
169
+
170
+ self.assertEqual(skipped, 1)
171
+ self.assertEqual(llm_requested, 1)
172
+ self.assertEqual(pending_unprocessed, 0)
173
+ self.assertEqual(calls, [["u2"]])
174
+ patches = [patch for _unit, patch in completed]
175
+ self.assertEqual([patch["unit_id"] for patch in patches], ["u1", "u2"])
176
+ self.assertEqual(patches[0]["episode_title_suffixes"], ["Finale"])
177
+
178
+ def test_max_requests_partial_does_not_fallback_unprocessed_units(self) -> None:
179
+ args = make_args(max_requests=1, units_per_request=2, max_context_units=2, workers=1)
180
+ units = [make_unit("u1", ["t1"]), make_unit("u2", ["t2"]), make_unit("u3", ["t3"])]
181
+ calls: list[list[str]] = []
182
+ original_post = agent.http_post_json
183
+
184
+ def fake_post(body, _args):
185
+ payload = json.loads(body["input"])
186
+ batch_units = [unit for unit in units if unit["unit_id"] in {item["unit_id"] for item in payload["units"]}]
187
+ calls.append([unit["unit_id"] for unit in payload["units"]])
188
+ return response_for(batch_units)
189
+
190
+ try:
191
+ agent.http_post_json = fake_post
192
+ completed, skipped, llm_requested, pending_unprocessed = agent.annotate_units(units, args)
193
+ finally:
194
+ agent.http_post_json = original_post
195
+
196
+ self.assertEqual(skipped, 0)
197
+ self.assertEqual(llm_requested, 1)
198
+ self.assertEqual(pending_unprocessed, 1)
199
+ self.assertEqual(calls, [["u1", "u2"]])
200
+ patches = [patch for _unit, patch in completed]
201
+ self.assertEqual([patch["unit_id"] for patch in patches], ["u1", "u2"])
202
+ self.assertTrue(all(not patch["fallback"] for patch in patches))
203
+
204
+ def test_resume_continues_with_next_unfinished_batch(self) -> None:
205
+ with tempfile.TemporaryDirectory() as tmpdir:
206
+ tmp = Path(tmpdir)
207
+ args = make_args(tmp, resume=True, max_requests=1, units_per_request=1, max_context_units=1, workers=1)
208
+ units = [make_unit("u1", ["t1"]), make_unit("u2", ["t2"]), make_unit("u3", ["t3"])]
209
+ args.patch_output.write_text(
210
+ json.dumps(
211
+ {
212
+ "unit_id": "u1",
213
+ "terminal_ids": ["t1"],
214
+ "episode_title_suffixes": [],
215
+ "media_suffixes": ["[1080P]"],
216
+ "title_candidates": [],
217
+ "llm_label": "media",
218
+ "notes": "existing",
219
+ "source": agent.LLM_SOURCE,
220
+ "fallback": False,
221
+ "status": "ok",
222
+ "attempts": 1,
223
+ "errors": [],
224
+ },
225
+ ensure_ascii=False,
226
+ )
227
+ + "\n",
228
+ encoding="utf-8",
229
+ )
230
+ calls: list[list[str]] = []
231
+ original_post = agent.http_post_json
232
+
233
+ def fake_post(body, _args):
234
+ payload = json.loads(body["input"])
235
+ calls.append([unit["unit_id"] for unit in payload["units"]])
236
+ return response_for([units[1]])
237
+
238
+ try:
239
+ agent.http_post_json = fake_post
240
+ completed, skipped, llm_requested, pending_unprocessed = agent.annotate_units(units, args)
241
+ finally:
242
+ agent.http_post_json = original_post
243
+
244
+ self.assertEqual(skipped, 1)
245
+ self.assertEqual(llm_requested, 1)
246
+ self.assertEqual(pending_unprocessed, 1)
247
+ self.assertEqual(calls, [["u2"]])
248
+ self.assertEqual([unit["unit_id"] for unit, _patch in completed], ["u1", "u2"])
249
+
250
+ def test_cli_heuristic_sample_skips_terminal_sink_and_writes_dataset_rows(self) -> None:
251
+ with tempfile.TemporaryDirectory() as tmpdir:
252
+ tmp = Path(tmpdir)
253
+ dag_path = tmp / "dag.json"
254
+ output_path = tmp / "dmhy_weak.dag_sample.jsonl"
255
+ patch_path = tmp / "patch.jsonl"
256
+ units_path = tmp / "units.jsonl"
257
+ dag_path.write_text(
258
+ json.dumps(
259
+ {
260
+ "root": 0,
261
+ "nodes": [
262
+ {
263
+ "id": 0,
264
+ "children": [{"label": "[A] Test Show - 0", "target": 1}],
265
+ },
266
+ {
267
+ "id": 1,
268
+ "terminal": False,
269
+ "children": [{"label": "1 [1080P][WEB-DL]", "target": 2}],
270
+ "incoming_count": 2,
271
+ "reachable_terminals": 2,
272
+ "reachable_weight": 2,
273
+ },
274
+ {
275
+ "id": 2,
276
+ "terminal": True,
277
+ "children": [],
278
+ "incoming_count": 10,
279
+ "reachable_terminals": 100,
280
+ },
281
+ ],
282
+ "terminals": [
283
+ {
284
+ "terminal_id": "t0",
285
+ "node_id": 1,
286
+ "prefix": "[A] Test Show - 0",
287
+ "weight": 1,
288
+ "suffix_examples": ["1 [1080P][WEB-DL]"],
289
+ "value_examples": ["[A] Test Show - 01 [1080P][WEB-DL].mkv"],
290
+ },
291
+ {
292
+ "terminal_id": "t1",
293
+ "node_id": 1,
294
+ "prefix": "[A] Test Show - 0",
295
+ "weight": 1,
296
+ "suffix_examples": ["2 [1080P][WEB-DL]"],
297
+ "value_examples": ["[A] Test Show - 02 [1080P][WEB-DL].mkv"],
298
+ },
299
+ ],
300
+ },
301
+ ensure_ascii=False,
302
+ ),
303
+ encoding="utf-8",
304
+ )
305
+
306
+ run = subprocess.run(
307
+ [
308
+ sys.executable,
309
+ "-m",
310
+ "tools.annotate_dmhy_dag_agent",
311
+ "--dag",
312
+ str(dag_path),
313
+ "--output",
314
+ str(output_path),
315
+ "--patch-output",
316
+ str(patch_path),
317
+ "--units-output",
318
+ str(units_path),
319
+ "--limit",
320
+ "1",
321
+ "--max-requests",
322
+ "0",
323
+ "--workers",
324
+ "2",
325
+ ],
326
+ check=False,
327
+ capture_output=True,
328
+ text=True,
329
+ )
330
+ self.assertEqual(run.returncode, 0, run.stderr)
331
+
332
+ patches = [json.loads(line) for line in patch_path.read_text(encoding="utf-8").splitlines()]
333
+ self.assertEqual(len(patches), 1)
334
+ self.assertEqual(patches[0]["unit_id"], "dag-node-1")
335
+ self.assertEqual(patches[0]["terminal_ids"], ["t0", "t1"])
336
+
337
+ units = [json.loads(line) for line in units_path.read_text(encoding="utf-8").splitlines()]
338
+ self.assertEqual(len(units), 1)
339
+ self.assertEqual(
340
+ set(units[0]),
341
+ {
342
+ "unit_id",
343
+ "source_kind",
344
+ "source_id",
345
+ "terminal_ids",
346
+ "weight",
347
+ "context",
348
+ "examples",
349
+ "expected_output",
350
+ },
351
+ )
352
+ self.assertEqual(units[0]["source_kind"], "prefix_dag")
353
+ self.assertEqual(units[0]["source_id"], "1")
354
+ self.assertEqual(units[0]["terminal_ids"], ["t0", "t1"])
355
+ self.assertEqual(units[0]["weight"], 2)
356
+ self.assertEqual(units[0]["context"]["prefixes"], ["[A] Test Show - 0"])
357
+ self.assertEqual(units[0]["context"]["digit_skeletons"], [])
358
+ self.assertEqual(units[0]["context"]["edge_labels"], ["1 [1080P][WEB-DL]"])
359
+ self.assertIn("incoming_count=2", units[0]["context"]["notes"])
360
+ self.assertEqual(
361
+ units[0]["examples"]["values"],
362
+ [
363
+ "[A] Test Show - 01 [1080P][WEB-DL].mkv",
364
+ "[A] Test Show - 02 [1080P][WEB-DL].mkv",
365
+ ],
366
+ )
367
+ self.assertEqual(units[0]["examples"]["suffixes"], ["1 [1080P][WEB-DL]", "2 [1080P][WEB-DL]"])
368
+ self.assertEqual(units[0]["expected_output"], {"schema_version": "dmhy-annotation-v1"})
369
+
370
+ rows = [json.loads(line) for line in output_path.read_text(encoding="utf-8").splitlines()]
371
+ self.assertEqual(len(rows), 2)
372
+ self.assertEqual(rows[0]["tokens"][0], "[")
373
+ self.assertTrue(all(label == "O" or label.startswith("B-") for row in rows for label in row["labels"]))
374
+
375
+ def test_cli_all_terminals_adds_terminal_cluster_for_uncovered_terminal(self) -> None:
376
+ with tempfile.TemporaryDirectory() as tmpdir:
377
+ tmp = Path(tmpdir)
378
+ dag_path = tmp / "dag.json"
379
+ output_path = tmp / "dmhy_weak.dag_all.jsonl"
380
+ patch_path = tmp / "patch.jsonl"
381
+ units_path = tmp / "units.jsonl"
382
+ dag_path.write_text(
383
+ json.dumps(
384
+ {
385
+ "root": 0,
386
+ "nodes": [
387
+ {
388
+ "id": 0,
389
+ "children": [
390
+ {"label": "A shared", "target": 1},
391
+ {"label": "B solo", "target": 4},
392
+ ],
393
+ },
394
+ {
395
+ "id": 1,
396
+ "children": [
397
+ {"label": " 01", "target": 2},
398
+ {"label": " 02", "target": 3},
399
+ ],
400
+ "incoming_count": 2,
401
+ "reachable_terminals": 2,
402
+ "reachable_weight": 20,
403
+ },
404
+ {
405
+ "id": 2,
406
+ "children": [],
407
+ "incoming_count": 1,
408
+ "reachable_terminals": 1,
409
+ "reachable_weight": 10,
410
+ },
411
+ {
412
+ "id": 3,
413
+ "children": [],
414
+ "incoming_count": 1,
415
+ "reachable_terminals": 1,
416
+ "reachable_weight": 10,
417
+ },
418
+ {
419
+ "id": 4,
420
+ "children": [],
421
+ "incoming_count": 1,
422
+ "reachable_terminals": 1,
423
+ "reachable_weight": 5,
424
+ },
425
+ ],
426
+ "terminals": [
427
+ {
428
+ "terminal_id": "t0",
429
+ "node_id": 2,
430
+ "prefix": "Shared Show 01",
431
+ "weight": 10,
432
+ "suffix_examples": [" [1080P]"],
433
+ "value_examples": ["Shared Show 01 [1080P].mkv"],
434
+ },
435
+ {
436
+ "terminal_id": "t1",
437
+ "node_id": 3,
438
+ "prefix": "Shared Show 02",
439
+ "weight": 10,
440
+ "suffix_examples": [" [1080P]"],
441
+ "value_examples": ["Shared Show 02 [1080P].mkv"],
442
+ },
443
+ {
444
+ "terminal_id": "t2",
445
+ "node_id": 4,
446
+ "prefix": "Solo Show 03",
447
+ "weight": 5,
448
+ "suffix_examples": [" [WEB-DL]"],
449
+ "value_examples": ["Solo Show 03 [WEB-DL].mkv"],
450
+ },
451
+ ],
452
+ },
453
+ ensure_ascii=False,
454
+ ),
455
+ encoding="utf-8",
456
+ )
457
+
458
+ run = subprocess.run(
459
+ [
460
+ sys.executable,
461
+ "-m",
462
+ "tools.annotate_dmhy_dag_agent",
463
+ "--dag",
464
+ str(dag_path),
465
+ "--output",
466
+ str(output_path),
467
+ "--patch-output",
468
+ str(patch_path),
469
+ "--units-output",
470
+ str(units_path),
471
+ "--coverage-mode",
472
+ "all-terminals",
473
+ "--min-incoming-count",
474
+ "2",
475
+ "--min-reachable-terminals",
476
+ "2",
477
+ "--limit",
478
+ "100",
479
+ "--max-requests",
480
+ "0",
481
+ ],
482
+ check=False,
483
+ capture_output=True,
484
+ text=True,
485
+ )
486
+ self.assertEqual(run.returncode, 0, run.stderr)
487
+ summary = json.loads(run.stdout)
488
+ self.assertEqual(summary["coverage_mode"], "all-terminals")
489
+ self.assertEqual(summary["dag_terminals"], 3)
490
+ self.assertEqual(summary["unique_terminal_coverage"], 3)
491
+
492
+ patches = [json.loads(line) for line in patch_path.read_text(encoding="utf-8").splitlines()]
493
+ self.assertEqual([patch["kind"] for patch in patches], ["shared_suffix", "terminal_cluster"])
494
+ self.assertEqual(patches[0]["terminal_ids"], ["t0", "t1"])
495
+ self.assertEqual(patches[1]["terminal_ids"], ["t2"])
496
+
497
+ units = [json.loads(line) for line in units_path.read_text(encoding="utf-8").splitlines()]
498
+ self.assertEqual(len(units), 2)
499
+ self.assertEqual(units[0]["unit_id"], "dag-node-1")
500
+ self.assertEqual(units[0]["terminal_ids"], ["t0", "t1"])
501
+ self.assertEqual(units[1]["unit_id"], "terminal-cluster-1")
502
+ self.assertEqual(units[1]["source_kind"], "prefix_dag")
503
+ self.assertEqual(units[1]["source_id"], "terminal-cluster-1")
504
+ self.assertEqual(units[1]["terminal_ids"], ["t2"])
505
+ self.assertIn("kind=terminal_cluster", units[1]["context"]["notes"])
506
+ self.assertEqual(
507
+ {terminal_id for unit in units for terminal_id in unit["terminal_ids"]},
508
+ {"t0", "t1", "t2"},
509
+ )
510
+
511
+ rows = [json.loads(line) for line in output_path.read_text(encoding="utf-8").splitlines()]
512
+ self.assertEqual({row["terminal_id"] for row in rows}, {"t0", "t1", "t2"})
513
+ self.assertIn("terminal-cluster-1", {row["unit_id"] for row in rows})
514
+
515
+
516
+ if __name__ == "__main__":
517
+ unittest.main()
tools/test_annotated_dmhy_workflow.py CHANGED
@@ -10,6 +10,14 @@ import unittest
10
  from pathlib import Path
11
 
12
  from tools.annotate_dmhy_prefix_graph import normalize_generated_tokens
 
 
 
 
 
 
 
 
13
  from tools.convert_annotated_dmhy_dataset import (
14
  iter_validated_jsonl,
15
  validate_record,
@@ -18,6 +26,214 @@ from tools.convert_to_char_dataset import convert_record
18
 
19
 
20
  class AnnotatedDmhyWorkflowTests(unittest.TestCase):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  def test_generated_tokens_split_punctuation_and_use_b_only_labels(self) -> None:
22
  tokens, labels = normalize_generated_tokens(
23
  ["[ANi]", " ", "Title-Name", "07"],
@@ -39,6 +255,234 @@ class AnnotatedDmhyWorkflowTests(unittest.TestCase):
39
  self.assertEqual(tokens, ["Title", "-", "Name"])
40
  self.assertEqual(labels, ["I-TITLE", "O", "I-TITLE"])
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  def test_validation_rejects_embedded_punctuation(self) -> None:
43
  record = {
44
  "filename": "Title-Name 07",
@@ -440,6 +884,157 @@ class AnnotatedDmhyWorkflowTests(unittest.TestCase):
440
  self.assertEqual(rows[0]["common_edge_labels"], [" 01", " 02"])
441
  self.assertIn("annotations", rows[0])
442
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
 
444
  if __name__ == "__main__":
445
  unittest.main()
 
10
  from pathlib import Path
11
 
12
  from tools.annotate_dmhy_prefix_graph import normalize_generated_tokens
13
+ from tools.annotate_dmhy_prefix_tree_agent import (
14
+ Args as PrefixTreeAgentArgs,
15
+ annotate_terminal_with_llm,
16
+ annotate_selected,
17
+ enrich_patch,
18
+ request_body,
19
+ validate_strict_annotation,
20
+ )
21
  from tools.convert_annotated_dmhy_dataset import (
22
  iter_validated_jsonl,
23
  validate_record,
 
26
 
27
 
28
  class AnnotatedDmhyWorkflowTests(unittest.TestCase):
29
+ def prefix_tree_agent_args(self) -> PrefixTreeAgentArgs:
30
+ return PrefixTreeAgentArgs(
31
+ graph=Path("graph.json"),
32
+ source_list=Path("source.jsonl"),
33
+ output=Path("out.jsonl"),
34
+ patch_output=Path("patch.jsonl"),
35
+ units_output=Path("units.jsonl"),
36
+ limit=None,
37
+ min_weight=None,
38
+ only_needs_review=False,
39
+ llm=True,
40
+ base_url="http://example.test/v1",
41
+ api_key="test-key",
42
+ model="gpt-5.4-mini",
43
+ max_requests=None,
44
+ http_timeout=1,
45
+ preserve_i_labels=False,
46
+ examples_only=False,
47
+ workers=8,
48
+ retries=2,
49
+ resume=False,
50
+ reasoning_effort="medium",
51
+ )
52
+
53
+ def prefix_selected(self, count: int = 3) -> list[tuple[int, dict, dict]]:
54
+ selected: list[tuple[int, dict, dict]] = []
55
+ for index in range(count):
56
+ terminal_id = f"t{index}"
57
+ terminal = {
58
+ "terminal_id": terminal_id,
59
+ "prefix": f"Show {index} - ",
60
+ "suffix_examples": [f"0{index} - Title [1080P]"],
61
+ "value_examples": [f"Show {index} - 0{index} - Title [1080P].mkv"],
62
+ "weight": index + 1,
63
+ }
64
+ patch = enrich_patch(
65
+ {
66
+ "terminal_id": terminal_id,
67
+ "needs_llm_review": True,
68
+ "episode_title_suffixes": [],
69
+ "media_suffixes": ["[1080P]"],
70
+ "title_candidates": [],
71
+ "llm_label": None,
72
+ "notes": "heuristic",
73
+ "source": "heuristic-v1",
74
+ }
75
+ )
76
+ selected.append((index, terminal, patch))
77
+ return selected
78
+
79
+ def test_prefix_tree_agent_max_requests_does_not_fallback_unprocessed_terminals(self) -> None:
80
+ args = self.prefix_tree_agent_args()
81
+ args = PrefixTreeAgentArgs(**{**args.__dict__, "max_requests": 1, "workers": 1})
82
+ selected = self.prefix_selected(3)
83
+
84
+ def fake_annotate(_terminal, patch, _args):
85
+ merged = dict(patch)
86
+ merged.update({"source": "responses-v1", "status": "ok", "fallback": False, "errors": []})
87
+ return merged, False
88
+
89
+ import tools.annotate_dmhy_prefix_tree_agent as agent
90
+
91
+ original_annotate = agent.annotate_terminal_with_llm
92
+ try:
93
+ agent.annotate_terminal_with_llm = fake_annotate
94
+ completed, resume_skipped, llm_requests, llm_fallbacks, pending_unprocessed = annotate_selected(selected, args)
95
+ finally:
96
+ agent.annotate_terminal_with_llm = original_annotate
97
+
98
+ self.assertEqual([patch["terminal_id"] for _ord, _idx, _term, patch in completed], ["t0"])
99
+ self.assertEqual(resume_skipped, 0)
100
+ self.assertEqual(llm_requests, 1)
101
+ self.assertEqual(llm_fallbacks, 0)
102
+ self.assertEqual(pending_unprocessed, 2)
103
+ self.assertFalse(any(patch["fallback"] for _ord, _idx, _term, patch in completed))
104
+
105
+ def test_prefix_tree_agent_resume_continues_next_batch(self) -> None:
106
+ with tempfile.TemporaryDirectory() as tmpdir:
107
+ patch_path = Path(tmpdir) / "patches.jsonl"
108
+ patch_path.write_text(
109
+ json.dumps(
110
+ {
111
+ "terminal_id": "t0",
112
+ "unit_id": "t0",
113
+ "terminal_ids": ["t0"],
114
+ "episode_title_suffixes": ["old"],
115
+ "media_suffixes": ["[1080P]"],
116
+ "title_candidates": ["old"],
117
+ "llm_label": "old",
118
+ "notes": "previous",
119
+ "source": "responses-v1",
120
+ "status": "ok",
121
+ "fallback": False,
122
+ "errors": [],
123
+ },
124
+ ensure_ascii=False,
125
+ separators=(",", ":"),
126
+ )
127
+ + "\n",
128
+ encoding="utf-8",
129
+ )
130
+ args = self.prefix_tree_agent_args()
131
+ args = PrefixTreeAgentArgs(
132
+ **{**args.__dict__, "patch_output": patch_path, "resume": True, "max_requests": 1, "workers": 1}
133
+ )
134
+ selected = self.prefix_selected(3)
135
+
136
+ def fake_annotate(_terminal, patch, _args):
137
+ merged = dict(patch)
138
+ merged.update(
139
+ {
140
+ "episode_title_suffixes": [patch["terminal_id"]],
141
+ "source": "responses-v1",
142
+ "status": "ok",
143
+ "fallback": False,
144
+ "errors": [],
145
+ }
146
+ )
147
+ return merged, False
148
+
149
+ import tools.annotate_dmhy_prefix_tree_agent as agent
150
+
151
+ original_annotate = agent.annotate_terminal_with_llm
152
+ try:
153
+ agent.annotate_terminal_with_llm = fake_annotate
154
+ completed, resume_skipped, llm_requests, _llm_fallbacks, pending_unprocessed = annotate_selected(
155
+ selected, args
156
+ )
157
+ finally:
158
+ agent.annotate_terminal_with_llm = original_annotate
159
+
160
+ patches = [patch for _ord, _idx, _term, patch in completed]
161
+ self.assertEqual([patch["terminal_id"] for patch in patches], ["t0", "t1"])
162
+ self.assertEqual(patches[0]["notes"], "previous")
163
+ self.assertEqual(patches[1]["episode_title_suffixes"], ["t1"])
164
+ self.assertEqual(resume_skipped, 1)
165
+ self.assertEqual(llm_requests, 1)
166
+ self.assertEqual(pending_unprocessed, 1)
167
+
168
+ def test_prefix_tree_agent_completed_order_is_deterministic(self) -> None:
169
+ args = self.prefix_tree_agent_args()
170
+ args = PrefixTreeAgentArgs(**{**args.__dict__, "max_requests": 3, "workers": 3})
171
+ selected = self.prefix_selected(3)
172
+
173
+ def fake_annotate(_terminal, patch, _args):
174
+ merged = dict(patch)
175
+ merged.update({"source": "responses-v1", "status": "ok", "fallback": False, "errors": []})
176
+ return merged, False
177
+
178
+ import tools.annotate_dmhy_prefix_tree_agent as agent
179
+
180
+ original_annotate = agent.annotate_terminal_with_llm
181
+ try:
182
+ agent.annotate_terminal_with_llm = fake_annotate
183
+ completed, _resume_skipped, _llm_requests, _llm_fallbacks, _pending_unprocessed = annotate_selected(
184
+ selected, args
185
+ )
186
+ finally:
187
+ agent.annotate_terminal_with_llm = original_annotate
188
+
189
+ self.assertEqual([patch["terminal_id"] for _ord, _idx, _term, patch in completed], ["t0", "t1", "t2"])
190
+
191
+ def test_prefix_tree_agent_writes_llm_failures_without_completing_patches(self) -> None:
192
+ with tempfile.TemporaryDirectory() as tmpdir:
193
+ failure_path = Path(tmpdir) / "failures.jsonl"
194
+ args = self.prefix_tree_agent_args()
195
+ args = PrefixTreeAgentArgs(
196
+ **{**args.__dict__, "max_requests": 1, "workers": 1, "failure_output": failure_path}
197
+ )
198
+ selected = self.prefix_selected(2)
199
+
200
+ def fake_annotate(_terminal, patch, _args):
201
+ failed = dict(patch)
202
+ failed.update(
203
+ {
204
+ "status": "fallback",
205
+ "fallback": True,
206
+ "errors": ["Responses API HTTP 429: busy"],
207
+ }
208
+ )
209
+ return failed, True
210
+
211
+ import tools.annotate_dmhy_prefix_tree_agent as agent
212
+
213
+ original_annotate = agent.annotate_terminal_with_llm
214
+ try:
215
+ agent.annotate_terminal_with_llm = fake_annotate
216
+ completed, _resume_skipped, llm_requests, llm_fallbacks, pending_unprocessed = annotate_selected(
217
+ selected, args
218
+ )
219
+ finally:
220
+ agent.annotate_terminal_with_llm = original_annotate
221
+
222
+ self.assertEqual(completed, [])
223
+ self.assertEqual(llm_requests, 1)
224
+ self.assertEqual(llm_fallbacks, 1)
225
+ self.assertEqual(pending_unprocessed, 2)
226
+ failures = [
227
+ json.loads(line)
228
+ for line in failure_path.read_text(encoding="utf-8").splitlines()
229
+ if line.strip()
230
+ ]
231
+ self.assertEqual(len(failures), 1)
232
+ self.assertEqual(failures[0]["terminal_id"], "t0")
233
+ self.assertEqual(failures[0]["status"], "pending")
234
+ self.assertEqual(failures[0]["source"], "responses-v1")
235
+ self.assertIn("Responses API HTTP 429", failures[0]["errors"][0])
236
+
237
  def test_generated_tokens_split_punctuation_and_use_b_only_labels(self) -> None:
238
  tokens, labels = normalize_generated_tokens(
239
  ["[ANi]", " ", "Title-Name", "07"],
 
255
  self.assertEqual(tokens, ["Title", "-", "Name"])
256
  self.assertEqual(labels, ["I-TITLE", "O", "I-TITLE"])
257
 
258
+ def test_prefix_tree_agent_strict_schema_payload(self) -> None:
259
+ args = self.prefix_tree_agent_args()
260
+ terminal = {
261
+ "terminal_id": "t0",
262
+ "prefix": "Show - 01",
263
+ "suffix_examples": [" [1080P][WEB-DL]"],
264
+ "value_examples": ["Show - 01 [1080P][WEB-DL].mkv"],
265
+ }
266
+ patch = {
267
+ "terminal_id": "t0",
268
+ "episode_title_suffixes": [],
269
+ "media_suffixes": ["[1080P]", "[WEB-DL]"],
270
+ "title_candidates": [],
271
+ "notes": "heuristic",
272
+ }
273
+ body = request_body(args=args, terminal=terminal, patch=patch, previous_error=None, mode="reasoning_json_schema")
274
+ self.assertEqual(body["model"], "gpt-5.4-mini")
275
+ self.assertEqual(body["reasoning"], {"effort": "medium"})
276
+ schema_format = body["text"]["format"]
277
+ self.assertEqual(schema_format["type"], "json_schema")
278
+ self.assertTrue(schema_format["strict"])
279
+ self.assertIn("terminal_id", schema_format["schema"]["required"])
280
+ self.assertIn("episode_title_suffixes", schema_format["schema"]["required"])
281
+
282
+ def test_prefix_tree_agent_reasoning_effort_is_configurable(self) -> None:
283
+ args = self.prefix_tree_agent_args()
284
+ args = PrefixTreeAgentArgs(**{**args.__dict__, "reasoning_effort": "xhigh"})
285
+ body = request_body(
286
+ args=args,
287
+ terminal={"terminal_id": "t0"},
288
+ patch={"terminal_id": "t0", "notes": ""},
289
+ previous_error=None,
290
+ mode="reasoning_json_schema",
291
+ )
292
+ self.assertEqual(body["reasoning"], {"effort": "xhigh"})
293
+
294
+ def test_prefix_tree_agent_terminal_id_mismatch_validation(self) -> None:
295
+ with self.assertRaisesRegex(ValueError, "terminal_id mismatch"):
296
+ validate_strict_annotation(
297
+ {
298
+ "terminal_id": "wrong",
299
+ "episode_title_suffixes": [],
300
+ "media_suffixes": [],
301
+ "title_candidates": [],
302
+ "llm_label": "uncertain",
303
+ "notes": "bad id",
304
+ },
305
+ expected_terminal_id="expected",
306
+ )
307
+
308
+ def test_prefix_tree_agent_retries_after_terminal_id_mismatch(self) -> None:
309
+ args = self.prefix_tree_agent_args()
310
+ terminal = {
311
+ "terminal_id": "t0",
312
+ "prefix": "Show - 01",
313
+ "suffix_examples": [" - Finale [1080P]"],
314
+ "value_examples": ["Show - 01 - Finale [1080P].mkv"],
315
+ }
316
+ patch = {
317
+ "terminal_id": "t0",
318
+ "needs_llm_review": True,
319
+ "episode_title_suffixes": [],
320
+ "media_suffixes": ["[1080P]"],
321
+ "title_candidates": [],
322
+ "llm_label": None,
323
+ "notes": "heuristic",
324
+ "source": "heuristic-v1",
325
+ }
326
+ calls: list[dict] = []
327
+
328
+ def fake_post(body, _args):
329
+ calls.append(body)
330
+ terminal_id = "wrong" if len(calls) == 1 else "t0"
331
+ return {
332
+ "output_text": json.dumps(
333
+ {
334
+ "terminal_id": terminal_id,
335
+ "episode_title_suffixes": ["Finale"],
336
+ "media_suffixes": ["[1080P]"],
337
+ "title_candidates": ["Finale"],
338
+ "llm_label": "mixed",
339
+ "notes": "validated",
340
+ }
341
+ )
342
+ }
343
+
344
+ import tools.annotate_dmhy_prefix_tree_agent as agent
345
+
346
+ original_post = agent.post_responses
347
+ try:
348
+ agent.post_responses = fake_post
349
+ merged, used_fallback = annotate_terminal_with_llm(terminal, patch, args)
350
+ finally:
351
+ agent.post_responses = original_post
352
+
353
+ self.assertFalse(used_fallback)
354
+ self.assertEqual(len(calls), 2)
355
+ self.assertIn("Previous attempt failed validation", calls[1]["input"][0]["content"])
356
+ self.assertEqual(merged["terminal_id"], "t0")
357
+ self.assertEqual(merged["source"], "responses-v1")
358
+ self.assertEqual(merged["episode_title_suffixes"], ["Finale"])
359
+
360
+ def test_prefix_tree_agent_cli_writes_validator_compatible_units(self) -> None:
361
+ with tempfile.TemporaryDirectory() as tmpdir:
362
+ tmp = Path(tmpdir)
363
+ graph_path = tmp / "graph.json"
364
+ dataset_path = tmp / "dmhy_weak.prefix_sample.jsonl"
365
+ patch_path = tmp / "patches.jsonl"
366
+ units_path = tmp / "units.jsonl"
367
+ graph_path.write_text(
368
+ json.dumps(
369
+ {
370
+ "terminals": [
371
+ {
372
+ "terminal_id": "t0",
373
+ "node_id": 42,
374
+ "prefix": "[ANi] Prefix Show - ",
375
+ "digit_skeleton": "[ANi] Prefix Show - ##",
376
+ "weight": 7,
377
+ "value_examples": [
378
+ "[ANi] Prefix Show - 01 [1080P][WEB-DL].mkv"
379
+ ],
380
+ "suffix_examples": ["01 [1080P][WEB-DL]"],
381
+ }
382
+ ]
383
+ },
384
+ ensure_ascii=False,
385
+ ),
386
+ encoding="utf-8",
387
+ )
388
+
389
+ annotate = subprocess.run(
390
+ [
391
+ sys.executable,
392
+ "-m",
393
+ "tools.annotate_dmhy_prefix_tree_agent",
394
+ "--graph",
395
+ str(graph_path),
396
+ "--output",
397
+ str(dataset_path),
398
+ "--patch-output",
399
+ str(patch_path),
400
+ "--units-output",
401
+ str(units_path),
402
+ "--examples-only",
403
+ "--workers",
404
+ "1",
405
+ ],
406
+ check=False,
407
+ capture_output=True,
408
+ text=True,
409
+ )
410
+ self.assertEqual(annotate.returncode, 0, annotate.stderr)
411
+
412
+ units = [
413
+ json.loads(line)
414
+ for line in units_path.read_text(encoding="utf-8").splitlines()
415
+ if line.strip()
416
+ ]
417
+ self.assertEqual(len(units), 1)
418
+ self.assertEqual(
419
+ set(units[0]),
420
+ {
421
+ "unit_id",
422
+ "source_kind",
423
+ "source_id",
424
+ "terminal_ids",
425
+ "weight",
426
+ "context",
427
+ "examples",
428
+ "expected_output",
429
+ },
430
+ )
431
+ self.assertEqual(units[0]["unit_id"], "t0")
432
+ self.assertEqual(units[0]["source_kind"], "prefix_tree")
433
+ self.assertEqual(units[0]["source_id"], 42)
434
+ self.assertEqual(units[0]["terminal_ids"], ["t0"])
435
+ self.assertEqual(units[0]["weight"], 7)
436
+ self.assertEqual(units[0]["context"]["prefixes"], ["[ANi] Prefix Show - "])
437
+ self.assertEqual(units[0]["context"]["digit_skeletons"], ["[ANi] Prefix Show - ##"])
438
+ self.assertEqual(units[0]["context"]["edge_labels"], [])
439
+ self.assertEqual(
440
+ units[0]["examples"]["values"],
441
+ ["[ANi] Prefix Show - 01 [1080P][WEB-DL].mkv"],
442
+ )
443
+ self.assertEqual(units[0]["examples"]["suffixes"], ["01 [1080P][WEB-DL]"])
444
+ self.assertEqual(units[0]["expected_output"], {"schema_version": "dmhy-annotation-v1"})
445
+
446
+ patches = [
447
+ json.loads(line)
448
+ for line in patch_path.read_text(encoding="utf-8").splitlines()
449
+ if line.strip()
450
+ ]
451
+ self.assertEqual(patches[0]["unit_id"], "t0")
452
+ self.assertEqual(patches[0]["terminal_ids"], ["t0"])
453
+ self.assertEqual(patches[0]["status"], "ok")
454
+ self.assertEqual(patches[0]["errors"], [])
455
+ self.assertFalse(patches[0]["fallback"])
456
+
457
+ validate_units = subprocess.run(
458
+ [
459
+ sys.executable,
460
+ "-m",
461
+ "tools.validate_dmhy_annotation_pipeline",
462
+ "validate-units",
463
+ str(units_path),
464
+ ],
465
+ check=False,
466
+ capture_output=True,
467
+ text=True,
468
+ )
469
+ self.assertEqual(validate_units.returncode, 0, validate_units.stdout + validate_units.stderr)
470
+ validate_patches = subprocess.run(
471
+ [
472
+ sys.executable,
473
+ "-m",
474
+ "tools.validate_dmhy_annotation_pipeline",
475
+ "validate-patches",
476
+ str(patch_path),
477
+ "--units",
478
+ str(units_path),
479
+ ],
480
+ check=False,
481
+ capture_output=True,
482
+ text=True,
483
+ )
484
+ self.assertEqual(validate_patches.returncode, 0, validate_patches.stdout + validate_patches.stderr)
485
+
486
  def test_validation_rejects_embedded_punctuation(self) -> None:
487
  record = {
488
  "filename": "Title-Name 07",
 
884
  self.assertEqual(rows[0]["common_edge_labels"], [" 01", " 02"])
885
  self.assertIn("annotations", rows[0])
886
 
887
+ def test_cli_dag_annotation_units_skip_terminal_sink_by_default(self) -> None:
888
+ with tempfile.TemporaryDirectory() as tmpdir:
889
+ tmp = Path(tmpdir)
890
+ dag_path = tmp / "dmhy_prefix_dag.json"
891
+ output_path = tmp / "dmhy_prefix_dag.annotation_units.jsonl"
892
+ dag_path.write_text(
893
+ json.dumps(
894
+ {
895
+ "meta": {"version": "prefix-dag-v1"},
896
+ "root": 0,
897
+ "nodes": [
898
+ {
899
+ "id": 0,
900
+ "terminal": False,
901
+ "children": [{"label": "shared", "target": 2}],
902
+ "incoming_count": 0,
903
+ "reachable_terminals": 1,
904
+ "reachable_weight": 12908,
905
+ },
906
+ {
907
+ "id": 2,
908
+ "terminal": True,
909
+ "children": [],
910
+ "incoming_count": 12908,
911
+ "reachable_terminals": 1,
912
+ "reachable_weight": 12908,
913
+ },
914
+ ],
915
+ "terminals": [
916
+ {
917
+ "terminal_id": "t-sink",
918
+ "node_id": 2,
919
+ "prefix": "Shared Sink",
920
+ "digit_skeleton": "Shared Sink",
921
+ "count": 12908,
922
+ "weight": 12908,
923
+ "suffix_examples": [],
924
+ "value_examples": ["Shared Sink.mkv"],
925
+ "annotations": {},
926
+ }
927
+ ],
928
+ },
929
+ ensure_ascii=False,
930
+ ),
931
+ encoding="utf-8",
932
+ )
933
+
934
+ annotate = subprocess.run(
935
+ [
936
+ sys.executable,
937
+ "-m",
938
+ "tools.annotate_dmhy_prefix_dag",
939
+ "--dag",
940
+ str(dag_path),
941
+ "--output",
942
+ str(output_path),
943
+ "--min-reachable-terminals",
944
+ "1",
945
+ "--min-incoming-count",
946
+ "2",
947
+ ],
948
+ check=False,
949
+ capture_output=True,
950
+ text=True,
951
+ )
952
+ self.assertEqual(annotate.returncode, 0, annotate.stderr)
953
+ rows = [
954
+ json.loads(line)
955
+ for line in output_path.read_text(encoding="utf-8").splitlines()
956
+ if line.strip()
957
+ ]
958
+ self.assertEqual(rows, [])
959
+
960
+ def test_cli_dag_annotation_units_include_terminal_sink_when_requested(self) -> None:
961
+ with tempfile.TemporaryDirectory() as tmpdir:
962
+ tmp = Path(tmpdir)
963
+ dag_path = tmp / "dmhy_prefix_dag.json"
964
+ output_path = tmp / "dmhy_prefix_dag.annotation_units.jsonl"
965
+ dag_path.write_text(
966
+ json.dumps(
967
+ {
968
+ "meta": {"version": "prefix-dag-v1"},
969
+ "root": 0,
970
+ "nodes": [
971
+ {
972
+ "id": 0,
973
+ "terminal": False,
974
+ "children": [{"label": "shared", "target": 2}],
975
+ "incoming_count": 0,
976
+ "reachable_terminals": 1,
977
+ "reachable_weight": 12908,
978
+ },
979
+ {
980
+ "id": 2,
981
+ "terminal": True,
982
+ "children": [],
983
+ "incoming_count": 12908,
984
+ "reachable_terminals": 1,
985
+ "reachable_weight": 12908,
986
+ },
987
+ ],
988
+ "terminals": [
989
+ {
990
+ "terminal_id": "t-sink",
991
+ "node_id": 2,
992
+ "prefix": "Shared Sink",
993
+ "digit_skeleton": "Shared Sink",
994
+ "count": 12908,
995
+ "weight": 12908,
996
+ "suffix_examples": [],
997
+ "value_examples": ["Shared Sink.mkv"],
998
+ "annotations": {},
999
+ }
1000
+ ],
1001
+ },
1002
+ ensure_ascii=False,
1003
+ ),
1004
+ encoding="utf-8",
1005
+ )
1006
+
1007
+ annotate = subprocess.run(
1008
+ [
1009
+ sys.executable,
1010
+ "-m",
1011
+ "tools.annotate_dmhy_prefix_dag",
1012
+ "--dag",
1013
+ str(dag_path),
1014
+ "--output",
1015
+ str(output_path),
1016
+ "--min-reachable-terminals",
1017
+ "1",
1018
+ "--min-incoming-count",
1019
+ "2",
1020
+ "--include-terminal-sink-units",
1021
+ ],
1022
+ check=False,
1023
+ capture_output=True,
1024
+ text=True,
1025
+ )
1026
+ self.assertEqual(annotate.returncode, 0, annotate.stderr)
1027
+ rows = [
1028
+ json.loads(line)
1029
+ for line in output_path.read_text(encoding="utf-8").splitlines()
1030
+ if line.strip()
1031
+ ]
1032
+ self.assertEqual(len(rows), 1)
1033
+ self.assertEqual(rows[0]["unit_id"], "dag-node-2")
1034
+ self.assertEqual(rows[0]["kind"], "shared_suffix")
1035
+ self.assertEqual(rows[0]["terminal_ids"], ["t-sink"])
1036
+ self.assertEqual(rows[0]["common_edge_labels"], [])
1037
+
1038
 
1039
  if __name__ == "__main__":
1040
  unittest.main()
tools/test_expand_dmhy_dag_annotations.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for full source-list expansion of DAG annotation patches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from pathlib import Path
11
+
12
+
13
+ def write_jsonl(path: Path, rows: list[dict]) -> None:
14
+ path.write_text(
15
+ "\n".join(json.dumps(row, ensure_ascii=False) for row in rows) + "\n",
16
+ encoding="utf-8",
17
+ )
18
+
19
+
20
+ class ExpandDmhyDagAnnotationsTests(unittest.TestCase):
21
+ def run_expand(
22
+ self,
23
+ tmp: Path,
24
+ dag: dict,
25
+ source_rows: list[dict],
26
+ patch_rows: list[dict],
27
+ *,
28
+ limit: int | None = None,
29
+ ) -> tuple[list[dict], dict, subprocess.CompletedProcess[str]]:
30
+ dag_path = tmp / "dag.json"
31
+ source_path = tmp / "dmhy_list.jsonl"
32
+ patches_path = tmp / "patches.jsonl"
33
+ output_path = tmp / "expanded.jsonl"
34
+ manifest_path = tmp / "expanded.manifest.json"
35
+ dag_path.write_text(json.dumps(dag, ensure_ascii=False), encoding="utf-8")
36
+ write_jsonl(source_path, source_rows)
37
+ write_jsonl(patches_path, patch_rows)
38
+
39
+ command = [
40
+ sys.executable,
41
+ "-m",
42
+ "tools.expand_dmhy_dag_annotations",
43
+ "--dag",
44
+ str(dag_path),
45
+ "--source-list",
46
+ str(source_path),
47
+ "--patches",
48
+ str(patches_path),
49
+ "--output",
50
+ str(output_path),
51
+ "--manifest-output",
52
+ str(manifest_path),
53
+ ]
54
+ if limit is not None:
55
+ command.extend(["--limit", str(limit)])
56
+ run = subprocess.run(command, check=False, capture_output=True, text=True)
57
+ self.assertEqual(run.returncode, 0, run.stderr)
58
+ rows = [
59
+ json.loads(line)
60
+ for line in output_path.read_text(encoding="utf-8").splitlines()
61
+ if line.strip()
62
+ ]
63
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
64
+ return rows, manifest, run
65
+
66
+ def test_source_list_expands_multiple_values_for_same_terminal(self) -> None:
67
+ with tempfile.TemporaryDirectory() as tmpdir:
68
+ rows, manifest, _run = self.run_expand(
69
+ Path(tmpdir),
70
+ {
71
+ "nodes": [],
72
+ "terminals": [
73
+ {
74
+ "terminal_id": "t0",
75
+ "node_id": 1,
76
+ "prefix": "[ANi] Full Show - ",
77
+ "value_examples": ["[ANi] Full Show - 01 [1080P].mkv"],
78
+ }
79
+ ],
80
+ },
81
+ [
82
+ {"value": "[ANi] Full Show - 01 [1080P].mkv"},
83
+ {"value": "[ANi] Full Show - 02 [1080P].mkv"},
84
+ {"value": "[ANi] Full Show - 03 [1080P].mkv"},
85
+ ],
86
+ [
87
+ {
88
+ "unit_id": "u0",
89
+ "terminal_ids": ["t0"],
90
+ "episode_title_suffixes": [],
91
+ "media_suffixes": ["[1080P]"],
92
+ "title_candidates": ["Full Show"],
93
+ "source": "responses-dag-v1",
94
+ "fallback": False,
95
+ "status": "ok",
96
+ }
97
+ ],
98
+ )
99
+
100
+ self.assertEqual([row["filename"] for row in rows], [
101
+ "[ANi] Full Show - 01 [1080P].mkv",
102
+ "[ANi] Full Show - 02 [1080P].mkv",
103
+ "[ANi] Full Show - 03 [1080P].mkv",
104
+ ])
105
+ self.assertTrue(all(row["terminal_id"] == "t0" for row in rows))
106
+ self.assertEqual(manifest["input_rows"], 3)
107
+ self.assertEqual(manifest["matched_rows"], 3)
108
+ self.assertEqual(manifest["output_rows"], 3)
109
+ self.assertEqual(manifest["unique_terminals_matched"], 1)
110
+
111
+ def test_unmatched_values_are_counted(self) -> None:
112
+ with tempfile.TemporaryDirectory() as tmpdir:
113
+ rows, manifest, _run = self.run_expand(
114
+ Path(tmpdir),
115
+ {
116
+ "nodes": [],
117
+ "terminals": [
118
+ {
119
+ "terminal_id": "t0",
120
+ "node_id": 1,
121
+ "prefix": "Known Show - ",
122
+ "value_examples": ["Known Show - 01 [1080P].mkv"],
123
+ }
124
+ ],
125
+ },
126
+ [
127
+ {"value": "Known Show - 01 [1080P].mkv"},
128
+ {"value": "Other Show - 01 [1080P].mkv"},
129
+ {"value": ""},
130
+ ],
131
+ [
132
+ {
133
+ "unit_id": "u0",
134
+ "terminal_ids": ["t0"],
135
+ "episode_title_suffixes": [],
136
+ "media_suffixes": ["[1080P]"],
137
+ "title_candidates": [],
138
+ "source": "responses-dag-v1",
139
+ "fallback": False,
140
+ "status": "ok",
141
+ }
142
+ ],
143
+ )
144
+
145
+ self.assertEqual(len(rows), 1)
146
+ self.assertEqual(manifest["input_rows"], 3)
147
+ self.assertEqual(manifest["matched_rows"], 1)
148
+ self.assertEqual(manifest["unmatched_rows"], 2)
149
+ self.assertEqual(manifest["status_counts"]["unmatched"], 2)
150
+
151
+ def test_patch_with_multiple_terminal_ids_maps_to_each_terminal(self) -> None:
152
+ with tempfile.TemporaryDirectory() as tmpdir:
153
+ rows, manifest, _run = self.run_expand(
154
+ Path(tmpdir),
155
+ {
156
+ "nodes": [],
157
+ "terminals": [
158
+ {
159
+ "terminal_id": "t0",
160
+ "node_id": 1,
161
+ "prefix": "Shared A - ",
162
+ "value_examples": ["Shared A - 01 [1080P].mkv"],
163
+ },
164
+ {
165
+ "terminal_id": "t1",
166
+ "node_id": 2,
167
+ "prefix": "Shared B - ",
168
+ "value_examples": ["Shared B - 01 [1080P].mkv"],
169
+ },
170
+ ],
171
+ },
172
+ [
173
+ {"value": "Shared A - 01 [1080P].mkv"},
174
+ {"value": "Shared B - 02 [1080P].mkv"},
175
+ ],
176
+ [
177
+ {
178
+ "unit_id": "dag-node-1",
179
+ "terminal_ids": ["t0", "t1"],
180
+ "episode_title_suffixes": ["Finale"],
181
+ "media_suffixes": ["[1080P]"],
182
+ "title_candidates": ["Shared"],
183
+ "source": "responses-dag-v1",
184
+ "fallback": False,
185
+ "status": "ok",
186
+ }
187
+ ],
188
+ )
189
+
190
+ self.assertEqual([row["terminal_id"] for row in rows], ["t0", "t1"])
191
+ self.assertEqual({row["unit_id"] for row in rows}, {"dag-node-1"})
192
+ self.assertTrue(all(row["annotations"]["terminal_ids"] == ["t0", "t1"] for row in rows))
193
+ self.assertTrue(all(row["episode_title_suffixes"] == ["Finale"] for row in rows))
194
+ self.assertEqual(manifest["patch_covered_rows"], 2)
195
+ self.assertEqual(manifest["unique_terminals_matched"], 2)
196
+
197
+ def test_limit_scans_only_requested_source_rows(self) -> None:
198
+ with tempfile.TemporaryDirectory() as tmpdir:
199
+ rows, manifest, _run = self.run_expand(
200
+ Path(tmpdir),
201
+ {
202
+ "nodes": [],
203
+ "terminals": [
204
+ {
205
+ "terminal_id": "t0",
206
+ "node_id": 1,
207
+ "prefix": "Limit Show - ",
208
+ "value_examples": ["Limit Show - 01 [1080P].mkv"],
209
+ }
210
+ ],
211
+ },
212
+ [
213
+ {"value": "Limit Show - 01 [1080P].mkv"},
214
+ {"value": "Limit Show - 02 [1080P].mkv"},
215
+ ],
216
+ [
217
+ {
218
+ "unit_id": "u0",
219
+ "terminal_ids": ["t0"],
220
+ "episode_title_suffixes": [],
221
+ "media_suffixes": ["[1080P]"],
222
+ "title_candidates": [],
223
+ "source": "responses-dag-v1",
224
+ "fallback": False,
225
+ "status": "ok",
226
+ }
227
+ ],
228
+ limit=1,
229
+ )
230
+
231
+ self.assertEqual(len(rows), 1)
232
+ self.assertEqual(manifest["input_rows"], 1)
233
+
234
+
235
+ if __name__ == "__main__":
236
+ unittest.main()
tools/test_schema_v2_synthetic_augment.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration smoke test for the schema v2 synthetic augment Rust tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import tempfile
10
+ import unittest
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ REPO_ROOT = Path(__file__).resolve().parents[1]
16
+ MANIFEST_PATH = Path("tools/schema_v2_synthetic_augment/Cargo.toml")
17
+ RECIPES_PATH = Path("reports/dmhy_template_recipes.full_top5000.seed.jsonl")
18
+ LABEL_SCHEMA_PATH = Path("label_schema.json")
19
+ NUMERIC_TITLE_SEEDS_PATH = Path("data/synthetic_numeric_titles.txt")
20
+ PATH_PREFIX_SEEDS_PATH = Path("data/synthetic_path_prefixes.txt")
21
+
22
+ CATEGORY_FIELDS = (
23
+ "source",
24
+ "source_type",
25
+ "augmentation",
26
+ "augmentation_type",
27
+ "augment_type",
28
+ "kind",
29
+ "category",
30
+ "row_type",
31
+ "generator",
32
+ "variant",
33
+ )
34
+ TAG_PATH_SEGMENTS = {"Gekijouban", "2004", "TV"}
35
+
36
+
37
+ def load_jsonl(path: Path) -> list[dict[str, Any]]:
38
+ rows: list[dict[str, Any]] = []
39
+ with path.open("r", encoding="utf-8") as handle:
40
+ for line_no, line in enumerate(handle, 1):
41
+ line = line.strip()
42
+ if not line:
43
+ continue
44
+ try:
45
+ row = json.loads(line)
46
+ except json.JSONDecodeError as exc:
47
+ raise AssertionError(f"{path}:{line_no}: invalid JSON") from exc
48
+ if not isinstance(row, dict):
49
+ raise AssertionError(f"{path}:{line_no}: row must be a JSON object")
50
+ rows.append(row)
51
+ return rows
52
+
53
+
54
+ def load_text_lines(path: Path) -> list[str]:
55
+ return [
56
+ line.strip()
57
+ for line in path.read_text(encoding="utf-8").splitlines()
58
+ if line.strip()
59
+ ]
60
+
61
+
62
+ def label_entity(label: str) -> str | None:
63
+ if label == "O":
64
+ return None
65
+ return label.split("-", 1)[1]
66
+
67
+
68
+ def has_entity(row: dict[str, Any], entity: str) -> bool:
69
+ return any(label_entity(label) == entity for label in row["labels"])
70
+
71
+
72
+ def has_path_entity(row: dict[str, Any]) -> bool:
73
+ return any(
74
+ (entity := label_entity(label)) is not None and entity.startswith("PATH_")
75
+ for label in row["labels"]
76
+ )
77
+
78
+
79
+ def category_text(row: dict[str, Any]) -> str:
80
+ values = []
81
+ for field in CATEGORY_FIELDS:
82
+ value = row.get(field)
83
+ if isinstance(value, str):
84
+ values.append(value)
85
+ return " ".join(values).lower().replace("-", "_")
86
+
87
+
88
+ def is_path_aug_row(row: dict[str, Any]) -> bool:
89
+ text = category_text(row)
90
+ return "path_aug" in text or "path_prefix" in text or has_path_entity(row)
91
+
92
+
93
+ def is_path_series_row(row: dict[str, Any]) -> bool:
94
+ return "path_series" in category_text(row)
95
+
96
+
97
+ def is_path_movie_row(row: dict[str, Any]) -> bool:
98
+ return "path_movie" in category_text(row)
99
+
100
+
101
+ def is_path_special_row(row: dict[str, Any]) -> bool:
102
+ return "path_special" in category_text(row)
103
+
104
+
105
+ def is_path_confuser_row(row: dict[str, Any]) -> bool:
106
+ return "path_confuser" in category_text(row)
107
+
108
+
109
+ def is_numeric_title_row(row: dict[str, Any], numeric_title_seeds: list[str]) -> bool:
110
+ text = category_text(row)
111
+ if "numeric_title" in text:
112
+ return True
113
+ if is_path_aug_row(row):
114
+ return False
115
+ filename = row.get("filename")
116
+ return isinstance(filename, str) and any(seed in filename for seed in numeric_title_seeds)
117
+
118
+
119
+ def span_range(filename: str, text: str, *, start_at: int = 0, end_at: int | None = None) -> range:
120
+ start = filename.find(text, start_at, end_at)
121
+ if start < 0:
122
+ raise AssertionError(f"span {text!r} not found in {filename!r}")
123
+ return range(start, start + len(text))
124
+
125
+
126
+ def span_labels(row: dict[str, Any], text: str, *, start_at: int = 0, end_at: int | None = None) -> list[str]:
127
+ filename = row["filename"]
128
+ indexes = span_range(filename, text, start_at=start_at, end_at=end_at)
129
+ return [row["labels"][index] for index in indexes]
130
+
131
+
132
+ def assert_span_not_entity(
133
+ testcase: unittest.TestCase,
134
+ row: dict[str, Any],
135
+ text: str,
136
+ entity: str,
137
+ *,
138
+ start_at: int = 0,
139
+ end_at: int | None = None,
140
+ ) -> None:
141
+ labels = span_labels(row, text, start_at=start_at, end_at=end_at)
142
+ testcase.assertFalse(
143
+ any(label_entity(label) == entity for label in labels),
144
+ msg=f"{text!r} in {row['filename']!r} was labeled as {entity}: {labels}",
145
+ )
146
+
147
+
148
+ def iter_path_segments(filename: str) -> list[tuple[str, int, int]]:
149
+ segments: list[tuple[str, int, int]] = []
150
+ start = 0
151
+ for match in re.finditer(r"[\\/]", filename):
152
+ end = match.start()
153
+ if end > start:
154
+ segments.append((filename[start:end], start, end))
155
+ start = match.end()
156
+ if start < len(filename):
157
+ segments.append((filename[start:], start, len(filename)))
158
+ return segments
159
+
160
+
161
+ class SchemaV2SyntheticAugmentSmokeTests(unittest.TestCase):
162
+ tmpdir: tempfile.TemporaryDirectory[str]
163
+ output_path: Path
164
+ manifest_output_path: Path
165
+ rows: list[dict[str, Any]]
166
+ manifest: dict[str, Any]
167
+ allowed_labels: set[str]
168
+ numeric_title_seeds: list[str]
169
+
170
+ @classmethod
171
+ def setUpClass(cls) -> None:
172
+ cls.tmpdir = tempfile.TemporaryDirectory(prefix="anifilebert_schema_v2_aug_")
173
+ tmp_path = Path(cls.tmpdir.name)
174
+ cls.output_path = tmp_path / "aug.jsonl"
175
+ cls.manifest_output_path = tmp_path / "aug.manifest.json"
176
+ cls.numeric_title_seeds = load_text_lines(REPO_ROOT / NUMERIC_TITLE_SEEDS_PATH)
177
+ cls.allowed_labels = set(
178
+ json.loads((REPO_ROOT / LABEL_SCHEMA_PATH).read_text(encoding="utf-8"))[
179
+ "labels"
180
+ ]
181
+ )
182
+
183
+ command = [
184
+ "cargo",
185
+ "run",
186
+ "--manifest-path",
187
+ str(MANIFEST_PATH),
188
+ "--bin",
189
+ "schema_v2_synthetic_augment",
190
+ "--",
191
+ "--recipes",
192
+ str(RECIPES_PATH),
193
+ "--label-schema-file",
194
+ str(LABEL_SCHEMA_PATH),
195
+ "--numeric-title-seeds",
196
+ str(NUMERIC_TITLE_SEEDS_PATH),
197
+ "--path-prefix-seeds",
198
+ str(PATH_PREFIX_SEEDS_PATH),
199
+ "--limit-templates",
200
+ "80",
201
+ "--max-rows",
202
+ "2000",
203
+ "--output",
204
+ str(cls.output_path),
205
+ "--manifest-output",
206
+ str(cls.manifest_output_path),
207
+ ]
208
+ env = os.environ.copy()
209
+ env["CARGO_TARGET_DIR"] = str(tmp_path / "cargo-target")
210
+ result = subprocess.run(
211
+ command,
212
+ cwd=REPO_ROOT,
213
+ env=env,
214
+ capture_output=True,
215
+ text=True,
216
+ encoding="utf-8",
217
+ errors="replace",
218
+ )
219
+ if result.returncode != 0:
220
+ raise RuntimeError(
221
+ "\n".join(
222
+ [
223
+ f"cargo run failed with exit code {result.returncode}",
224
+ "command: " + " ".join(command),
225
+ "stdout:",
226
+ result.stdout.strip(),
227
+ "stderr:",
228
+ result.stderr.strip(),
229
+ ]
230
+ )
231
+ )
232
+
233
+ cls.rows = load_jsonl(cls.output_path)
234
+ cls.manifest = json.loads(
235
+ cls.manifest_output_path.read_text(encoding="utf-8")
236
+ )
237
+
238
+ @classmethod
239
+ def tearDownClass(cls) -> None:
240
+ cls.tmpdir.cleanup()
241
+
242
+ def test_jsonl_rows_are_char_tokenized_and_strict_bio(self) -> None:
243
+ self.assertGreater(len(self.rows), 0, "generator produced no rows")
244
+ for row_number, row in enumerate(self.rows, 1):
245
+ with self.subTest(row=row_number):
246
+ self.assertIsInstance(row.get("filename"), str)
247
+ self.assertEqual(row["tokens"], list(row["filename"]))
248
+ self.assertEqual(len(row["tokens"]), len(row["labels"]))
249
+
250
+ previous_entity: str | None = None
251
+ for index, label in enumerate(row["labels"]):
252
+ self.assertIn(label, self.allowed_labels)
253
+ if label == "O":
254
+ previous_entity = None
255
+ continue
256
+ prefix, entity = label.split("-", 1)
257
+ self.assertIn(prefix, {"B", "I"})
258
+ if prefix == "I":
259
+ self.assertEqual(
260
+ previous_entity,
261
+ entity,
262
+ msg=(
263
+ f"orphan I-tag at row {row_number}, index {index}: "
264
+ f"{label} in {row['filename']!r}"
265
+ ),
266
+ )
267
+ previous_entity = entity
268
+
269
+ def test_manifest_counts_match_data_categories(self) -> None:
270
+ required_keys = {"generated_rows", "numeric_title_rows", "path_rows"}
271
+ self.assertFalse(required_keys - self.manifest.keys())
272
+
273
+ numeric_title_rows = [
274
+ row for row in self.rows if is_numeric_title_row(row, self.numeric_title_seeds)
275
+ ]
276
+ path_aug_rows = [row for row in self.rows if is_path_aug_row(row)]
277
+ path_series_rows = [row for row in self.rows if is_path_series_row(row)]
278
+ path_movie_rows = [row for row in self.rows if is_path_movie_row(row)]
279
+ path_special_rows = [row for row in self.rows if is_path_special_row(row)]
280
+ path_confuser_rows = [row for row in self.rows if is_path_confuser_row(row)]
281
+
282
+ self.assertGreater(len(numeric_title_rows), 0)
283
+ self.assertGreater(len(path_aug_rows), 0)
284
+ self.assertGreater(len(path_series_rows), 0)
285
+ self.assertGreater(len(path_movie_rows), 0)
286
+ self.assertGreater(len(path_special_rows), 0)
287
+ self.assertGreater(len(path_confuser_rows), 0)
288
+ self.assertEqual(self.manifest["generated_rows"], len(self.rows))
289
+ self.assertEqual(self.manifest["numeric_title_rows"], len(numeric_title_rows))
290
+ self.assertEqual(self.manifest["path_rows"], len(path_aug_rows))
291
+ self.assertEqual(self.manifest["path_series_rows"], len(path_series_rows))
292
+ self.assertEqual(self.manifest["path_movie_rows"], len(path_movie_rows))
293
+ self.assertEqual(self.manifest["path_special_rows"], len(path_special_rows))
294
+ self.assertEqual(self.manifest["path_confuser_rows"], len(path_confuser_rows))
295
+ self.assertEqual(
296
+ self.manifest["path_rows"],
297
+ self.manifest["path_series_rows"]
298
+ + self.manifest["path_movie_rows"]
299
+ + self.manifest["path_special_rows"]
300
+ + self.manifest["path_confuser_rows"],
301
+ )
302
+
303
+ def test_numeric_title_digits_are_not_episode(self) -> None:
304
+ for title, numeric_part in (("91 Days", "91"), ("7-nin", "7")):
305
+ matches = [row for row in self.rows if title in row["filename"]]
306
+ self.assertGreater(len(matches), 0, f"missing numeric title sample {title!r}")
307
+ for row in matches:
308
+ title_start = row["filename"].find(title)
309
+ title_end = title_start + len(title)
310
+ assert_span_not_entity(
311
+ self,
312
+ row,
313
+ numeric_part,
314
+ "EPISODE",
315
+ start_at=title_start,
316
+ end_at=title_end,
317
+ )
318
+
319
+ def test_special_codes_do_not_create_episode_labels(self) -> None:
320
+ special_cases = ("[NCOP2]", "[NCED]", "[PV][01]")
321
+ seen: set[str] = set()
322
+ for row in self.rows:
323
+ filename = row["filename"]
324
+ for special in special_cases:
325
+ if special not in filename:
326
+ continue
327
+ seen.add(special)
328
+ assert_span_not_entity(self, row, special, "EPISODE")
329
+ self.assertIn("B-SPECIAL", span_labels(row, special))
330
+ self.assertEqual(
331
+ seen,
332
+ set(special_cases),
333
+ msg=f"missing special-code samples: {sorted(set(special_cases) - seen)}",
334
+ )
335
+
336
+ def test_path_aug_labels_path_title_path_season_and_tag_directories(self) -> None:
337
+ path_rows = [row for row in self.rows if is_path_aug_row(row)]
338
+ self.assertTrue(any(has_entity(row, "PATH_TITLE_LATIN") for row in path_rows))
339
+ self.assertTrue(any(has_entity(row, "PATH_SEASON") for row in path_rows))
340
+ self.assertTrue(any(is_path_confuser_row(row) for row in path_rows))
341
+
342
+ seen_tag_segments: set[str] = set()
343
+ for row in path_rows:
344
+ for segment, start, end in iter_path_segments(row["filename"]):
345
+ if segment not in TAG_PATH_SEGMENTS:
346
+ continue
347
+ seen_tag_segments.add(segment)
348
+ labels = row["labels"][start:end]
349
+ self.assertEqual(
350
+ labels,
351
+ ["B-TAG", *(["I-TAG"] * (len(segment) - 1))],
352
+ msg=f"path segment {segment!r} should be TAG in {row['filename']!r}",
353
+ )
354
+ self.assertFalse(
355
+ any(label_entity(label) == "PATH_SEASON" for label in labels),
356
+ msg=f"path segment {segment!r} should not be PATH_SEASON",
357
+ )
358
+
359
+ self.assertEqual(
360
+ seen_tag_segments,
361
+ TAG_PATH_SEGMENTS,
362
+ msg=f"missing path tag samples: {sorted(TAG_PATH_SEGMENTS - seen_tag_segments)}",
363
+ )
364
+
365
+
366
+ if __name__ == "__main__":
367
+ unittest.main(verbosity=2)
tools/test_validate_dmhy_annotation_pipeline.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the DMHY annotation pipeline interchange validator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from pathlib import Path
11
+
12
+
13
+ class DmhyAnnotationPipelineValidatorTests(unittest.TestCase):
14
+ def write_jsonl(self, path: Path, rows: list[dict]) -> None:
15
+ path.write_text(
16
+ "".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows),
17
+ encoding="utf-8",
18
+ )
19
+
20
+ def unit(self, terminal_ids: list[str] | None = None) -> dict:
21
+ return {
22
+ "unit_id": "u-1",
23
+ "source_kind": "prefix_tree",
24
+ "source_id": "t-1",
25
+ "terminal_ids": terminal_ids or ["t-1", "t-2"],
26
+ "weight": 2,
27
+ "context": {
28
+ "prefixes": ["Show - 01"],
29
+ "digit_skeletons": ["Show - ##"],
30
+ "edge_labels": [" [1080p]"],
31
+ "notes": None,
32
+ },
33
+ "examples": {
34
+ "values": ["Show - 01 [1080p].mkv"],
35
+ "suffixes": [" [1080p]"],
36
+ },
37
+ "expected_output": {"schema_version": "dmhy-annotation-v1"},
38
+ }
39
+
40
+ def patch(self, terminal_ids: list[str] | None = None, status: str = "ok") -> dict:
41
+ return {
42
+ "unit_id": "u-1",
43
+ "terminal_ids": terminal_ids or ["t-1", "t-2"],
44
+ "annotation": {
45
+ "episode_title_suffixes": [],
46
+ "media_suffixes": ["[1080p]"],
47
+ "title_candidates": [],
48
+ "llm_label": None,
49
+ "notes": "clean",
50
+ },
51
+ "status": status,
52
+ "errors": [],
53
+ }
54
+
55
+ def run_cli(self, *args: str) -> subprocess.CompletedProcess[str]:
56
+ return subprocess.run(
57
+ [sys.executable, "-m", "tools.validate_dmhy_annotation_pipeline", *args],
58
+ check=False,
59
+ capture_output=True,
60
+ text=True,
61
+ )
62
+
63
+ def test_validate_units_and_manifest(self) -> None:
64
+ with tempfile.TemporaryDirectory() as tmpdir:
65
+ tmp = Path(tmpdir)
66
+ units = tmp / "units.jsonl"
67
+ manifest = tmp / "manifest.json"
68
+ self.write_jsonl(units, [self.unit()])
69
+
70
+ result = self.run_cli("validate-units", str(units), "--manifest-output", str(manifest))
71
+
72
+ self.assertEqual(result.returncode, 0, result.stderr)
73
+ report = json.loads(manifest.read_text(encoding="utf-8"))
74
+ self.assertEqual(report["total_rows"], 1)
75
+ self.assertEqual(report["error_count"], 0)
76
+ self.assertEqual(report["terminal_coverage_count"], 2)
77
+
78
+ def test_validate_patches_reports_terminal_mismatch(self) -> None:
79
+ with tempfile.TemporaryDirectory() as tmpdir:
80
+ tmp = Path(tmpdir)
81
+ units = tmp / "units.jsonl"
82
+ patches = tmp / "patches.jsonl"
83
+ self.write_jsonl(units, [self.unit(["t-1", "t-2"])])
84
+ self.write_jsonl(patches, [self.patch(["t-1"])])
85
+
86
+ result = self.run_cli("validate-patches", str(patches), "--units", str(units))
87
+
88
+ self.assertEqual(result.returncode, 1)
89
+ report = json.loads(result.stdout)
90
+ self.assertEqual(report["terminal_id_mismatch_count"], 1)
91
+ self.assertEqual(report["common_field_errors"]["terminal_ids.mismatch"], 1)
92
+
93
+ def test_compare_patches_counts_statuses_intersection_and_empty_rates(self) -> None:
94
+ with tempfile.TemporaryDirectory() as tmpdir:
95
+ tmp = Path(tmpdir)
96
+ left = tmp / "tree.jsonl"
97
+ right = tmp / "dag.jsonl"
98
+ left_rows = [
99
+ self.patch(["t-1", "t-2"], "ok"),
100
+ {
101
+ **self.patch(["t-3"], "fallback"),
102
+ "unit_id": "u-2",
103
+ "annotation": {
104
+ "episode_title_suffixes": [],
105
+ "media_suffixes": [],
106
+ "title_candidates": [],
107
+ "llm_label": None,
108
+ "notes": "empty fallback",
109
+ },
110
+ },
111
+ ]
112
+ right_rows = [
113
+ self.patch(["t-2", "t-4"], "failed"),
114
+ ]
115
+ right_rows[0]["errors"] = ["timeout"]
116
+ self.write_jsonl(left, left_rows)
117
+ self.write_jsonl(right, right_rows)
118
+
119
+ result = self.run_cli(
120
+ "compare-patches",
121
+ str(left),
122
+ str(right),
123
+ "--left-label",
124
+ "prefix_tree",
125
+ "--right-label",
126
+ "prefix_dag",
127
+ )
128
+
129
+ self.assertEqual(result.returncode, 0, result.stderr)
130
+ report = json.loads(result.stdout)
131
+ self.assertEqual(report["left"]["status_counts"]["ok"], 1)
132
+ self.assertEqual(report["left"]["status_counts"]["fallback"], 1)
133
+ self.assertEqual(report["right"]["status_counts"]["failed"], 1)
134
+ self.assertEqual(report["terminal_intersection_count"], 1)
135
+ self.assertEqual(report["terminal_union_count"], 4)
136
+ self.assertEqual(report["left"]["annotation_empty_rates"]["all_annotation_arrays"], 0.5)
137
+ self.assertEqual(report["right"]["text_summary"]["errors"][0]["text"], "timeout")
138
+
139
+
140
+ if __name__ == "__main__":
141
+ unittest.main()
tools/validate_dmhy_annotation_pipeline.py ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Validate and compare DMHY annotation pipeline interchange JSONL.
2
+
3
+ This tool is intentionally independent from the prefix-tree and DAG runners.
4
+ It defines the shared annotation-unit and annotation-patch contract that both
5
+ pipelines can emit while they evolve separately.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from collections import Counter
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any, Iterable
17
+
18
+
19
+ SCHEMA_VERSION = "dmhy-annotation-v1"
20
+ SOURCE_KINDS = {"prefix_tree", "prefix_dag"}
21
+ PATCH_STATUSES = {"ok", "fallback", "failed"}
22
+ ANNOTATION_ARRAY_FIELDS = (
23
+ "episode_title_suffixes",
24
+ "media_suffixes",
25
+ "title_candidates",
26
+ )
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class JsonlRow:
31
+ path: Path
32
+ line_number: int
33
+ value: Any
34
+
35
+
36
+ def parse_args() -> argparse.Namespace:
37
+ parser = argparse.ArgumentParser(
38
+ description="Validate and compare DMHY annotation-unit and patch JSONL files"
39
+ )
40
+ subparsers = parser.add_subparsers(dest="command", required=True)
41
+
42
+ units = subparsers.add_parser("validate-units", help="Validate annotation-unit JSONL")
43
+ units.add_argument("units_jsonl", type=Path)
44
+ units.add_argument("--manifest-output", type=Path)
45
+
46
+ patches = subparsers.add_parser("validate-patches", help="Validate annotation-patch JSONL")
47
+ patches.add_argument("patches_jsonl", type=Path)
48
+ patches.add_argument("--units", type=Path, help="Optional units JSONL for terminal_id mismatch checks")
49
+ patches.add_argument("--manifest-output", type=Path)
50
+
51
+ compare = subparsers.add_parser("compare-patches", help="Compare two annotation-patch JSONL files")
52
+ compare.add_argument("left_patches_jsonl", type=Path)
53
+ compare.add_argument("right_patches_jsonl", type=Path)
54
+ compare.add_argument("--left-label", default="left")
55
+ compare.add_argument("--right-label", default="right")
56
+ compare.add_argument("--manifest-output", type=Path)
57
+ compare.add_argument("--summary-limit", type=int, default=8)
58
+
59
+ return parser.parse_args()
60
+
61
+
62
+ def iter_jsonl(path: Path) -> Iterable[JsonlRow]:
63
+ if not path.exists():
64
+ raise SystemExit(f"JSONL not found: {path}")
65
+ with path.open("r", encoding="utf-8") as handle:
66
+ for line_number, line in enumerate(handle, start=1):
67
+ if not line.strip():
68
+ continue
69
+ try:
70
+ value = json.loads(line)
71
+ except json.JSONDecodeError as exc:
72
+ yield JsonlRow(path, line_number, {"__json_error__": str(exc)})
73
+ continue
74
+ yield JsonlRow(path, line_number, value)
75
+
76
+
77
+ def error_key(message: str) -> str:
78
+ return message.split(":", 1)[0]
79
+
80
+
81
+ def add_error(errors: list[str], field_errors: Counter[str], message: str) -> None:
82
+ errors.append(message)
83
+ field_errors[error_key(message)] += 1
84
+
85
+
86
+ def is_string_list(value: Any) -> bool:
87
+ return isinstance(value, list) and all(isinstance(item, str) for item in value)
88
+
89
+
90
+ def is_int_not_bool(value: Any) -> bool:
91
+ return isinstance(value, int) and not isinstance(value, bool)
92
+
93
+
94
+ def validate_unit_row(row: Any) -> tuple[list[str], str | None, list[str]]:
95
+ field_errors: Counter[str] = Counter()
96
+ errors: list[str] = []
97
+ if not isinstance(row, dict):
98
+ add_error(errors, field_errors, "row_type: expected object")
99
+ return errors, None, []
100
+
101
+ unit_id = row.get("unit_id")
102
+ terminal_ids = row.get("terminal_ids")
103
+ if not isinstance(unit_id, str) or not unit_id:
104
+ add_error(errors, field_errors, "unit_id: expected non-empty string")
105
+ if row.get("source_kind") not in SOURCE_KINDS:
106
+ add_error(errors, field_errors, "source_kind: expected prefix_tree or prefix_dag")
107
+ source_id = row.get("source_id")
108
+ if not isinstance(source_id, (str, int)) or isinstance(source_id, bool):
109
+ add_error(errors, field_errors, "source_id: expected string or integer")
110
+ if not is_string_list(terminal_ids):
111
+ add_error(errors, field_errors, "terminal_ids: expected string array")
112
+ clean_terminal_ids: list[str] = []
113
+ else:
114
+ clean_terminal_ids = terminal_ids
115
+ if not is_int_not_bool(row.get("weight")):
116
+ add_error(errors, field_errors, "weight: expected integer")
117
+
118
+ context = row.get("context")
119
+ if not isinstance(context, dict):
120
+ add_error(errors, field_errors, "context: expected object")
121
+ else:
122
+ for key in ("prefixes", "digit_skeletons", "edge_labels"):
123
+ if not is_string_list(context.get(key)):
124
+ add_error(errors, field_errors, f"context.{key}: expected string array")
125
+ if context.get("notes") is not None and not isinstance(context.get("notes"), str):
126
+ add_error(errors, field_errors, "context.notes: expected string or null")
127
+
128
+ examples = row.get("examples")
129
+ if not isinstance(examples, dict):
130
+ add_error(errors, field_errors, "examples: expected object")
131
+ else:
132
+ for key in ("values", "suffixes"):
133
+ if not is_string_list(examples.get(key)):
134
+ add_error(errors, field_errors, f"examples.{key}: expected string array")
135
+
136
+ expected_output = row.get("expected_output")
137
+ if not isinstance(expected_output, dict):
138
+ add_error(errors, field_errors, "expected_output: expected object")
139
+ elif expected_output.get("schema_version") != SCHEMA_VERSION:
140
+ add_error(errors, field_errors, f"expected_output.schema_version: expected {SCHEMA_VERSION}")
141
+
142
+ return errors, unit_id if isinstance(unit_id, str) else None, clean_terminal_ids
143
+
144
+
145
+ def normalize_patch_row(row: Any) -> dict[str, Any]:
146
+ """Return the canonical patch view, accepting legacy flat patch rows too."""
147
+ if not isinstance(row, dict):
148
+ return {}
149
+ annotation = row.get("annotation")
150
+ if isinstance(annotation, dict):
151
+ annotation_obj = annotation
152
+ else:
153
+ annotation_obj = {
154
+ key: row.get(key)
155
+ for key in (*ANNOTATION_ARRAY_FIELDS, "llm_label", "notes")
156
+ if key in row
157
+ }
158
+ status = row.get("status")
159
+ if status is None:
160
+ source = str(row.get("source") or "")
161
+ if row.get("fallback") is True:
162
+ status = "fallback"
163
+ elif source.startswith("responses"):
164
+ status = "ok"
165
+ else:
166
+ status = "ok"
167
+ errors = row.get("errors")
168
+ if errors is None:
169
+ errors = []
170
+ return {
171
+ "unit_id": row.get("unit_id", row.get("terminal_id")),
172
+ "terminal_ids": row.get("terminal_ids", [row["terminal_id"]] if "terminal_id" in row else None),
173
+ "annotation": annotation_obj,
174
+ "status": status,
175
+ "errors": errors,
176
+ }
177
+
178
+
179
+ def validate_patch_row(row: Any) -> tuple[list[str], str | None, list[str], dict[str, Any]]:
180
+ field_errors: Counter[str] = Counter()
181
+ errors: list[str] = []
182
+ canonical = normalize_patch_row(row)
183
+ if not isinstance(row, dict):
184
+ add_error(errors, field_errors, "row_type: expected object")
185
+ return errors, None, [], canonical
186
+
187
+ unit_id = canonical.get("unit_id")
188
+ terminal_ids = canonical.get("terminal_ids")
189
+ annotation = canonical.get("annotation")
190
+ if not isinstance(unit_id, str) or not unit_id:
191
+ add_error(errors, field_errors, "unit_id: expected non-empty string")
192
+ if not is_string_list(terminal_ids):
193
+ add_error(errors, field_errors, "terminal_ids: expected string array")
194
+ clean_terminal_ids: list[str] = []
195
+ else:
196
+ clean_terminal_ids = terminal_ids
197
+ if not isinstance(annotation, dict):
198
+ add_error(errors, field_errors, "annotation: expected object")
199
+ else:
200
+ for key in ANNOTATION_ARRAY_FIELDS:
201
+ if not is_string_list(annotation.get(key)):
202
+ add_error(errors, field_errors, f"annotation.{key}: expected string array")
203
+ if annotation.get("llm_label") is not None and not isinstance(annotation.get("llm_label"), str):
204
+ add_error(errors, field_errors, "annotation.llm_label: expected string or null")
205
+ if annotation.get("notes") is not None and not isinstance(annotation.get("notes"), str):
206
+ add_error(errors, field_errors, "annotation.notes: expected string or null")
207
+ if canonical.get("status") not in PATCH_STATUSES:
208
+ add_error(errors, field_errors, "status: expected ok, fallback, or failed")
209
+ if not is_string_list(canonical.get("errors")):
210
+ add_error(errors, field_errors, "errors: expected string array")
211
+ return errors, unit_id if isinstance(unit_id, str) else None, clean_terminal_ids, canonical
212
+
213
+
214
+ def terminal_set(ids: Iterable[str]) -> set[str]:
215
+ return {str(item) for item in ids if str(item)}
216
+
217
+
218
+ def unit_index(path: Path) -> tuple[dict[str, set[str]], dict[str, Any]]:
219
+ units: dict[str, set[str]] = {}
220
+ report = validate_units(path)
221
+ for item in report["valid_units"]:
222
+ units[item["unit_id"]] = set(item["terminal_ids"])
223
+ return units, report
224
+
225
+
226
+ def summarize_report(report: dict[str, Any], manifest_output: Path | None) -> None:
227
+ printable = {key: value for key, value in report.items() if not key.startswith("valid_")}
228
+ text = json.dumps(printable, ensure_ascii=False, indent=2, sort_keys=True)
229
+ print(text)
230
+ if manifest_output is not None:
231
+ manifest_output.parent.mkdir(parents=True, exist_ok=True)
232
+ manifest_output.write_text(text + "\n", encoding="utf-8")
233
+
234
+
235
+ def validate_units(path: Path) -> dict[str, Any]:
236
+ field_errors: Counter[str] = Counter()
237
+ duplicate_unit_ids = 0
238
+ seen_unit_ids: set[str] = set()
239
+ valid_units: list[dict[str, Any]] = []
240
+ total = 0
241
+ error_rows = 0
242
+ for jsonl_row in iter_jsonl(path):
243
+ total += 1
244
+ row = jsonl_row.value
245
+ if isinstance(row, dict) and "__json_error__" in row:
246
+ field_errors["json"] += 1
247
+ error_rows += 1
248
+ continue
249
+ errors, unit_id, terminal_ids = validate_unit_row(row)
250
+ for message in errors:
251
+ field_errors[error_key(message)] += 1
252
+ if unit_id:
253
+ if unit_id in seen_unit_ids:
254
+ duplicate_unit_ids += 1
255
+ field_errors["unit_id.duplicate"] += 1
256
+ errors.append("unit_id.duplicate")
257
+ seen_unit_ids.add(unit_id)
258
+ if errors:
259
+ error_rows += 1
260
+ else:
261
+ valid_units.append({"unit_id": unit_id, "terminal_ids": terminal_ids})
262
+ return {
263
+ "schema": "dmhy_annotation_units",
264
+ "schema_version": SCHEMA_VERSION,
265
+ "path": str(path),
266
+ "total_rows": total,
267
+ "error_rows": error_rows,
268
+ "error_count": sum(field_errors.values()),
269
+ "common_field_errors": dict(field_errors.most_common()),
270
+ "duplicate_unit_ids": duplicate_unit_ids,
271
+ "terminal_coverage_count": len({tid for unit in valid_units for tid in unit["terminal_ids"]}),
272
+ "valid_units": valid_units,
273
+ }
274
+
275
+
276
+ def validate_patches(path: Path, units_path: Path | None = None) -> dict[str, Any]:
277
+ field_errors: Counter[str] = Counter()
278
+ status_counts: Counter[str] = Counter()
279
+ duplicate_unit_ids = 0
280
+ seen_unit_ids: set[str] = set()
281
+ valid_patches: list[dict[str, Any]] = []
282
+ total = 0
283
+ error_rows = 0
284
+ terminal_mismatch_count = 0
285
+ unknown_unit_count = 0
286
+ units_by_id: dict[str, set[str]] = {}
287
+ units_report: dict[str, Any] | None = None
288
+ if units_path is not None:
289
+ units_by_id, units_report = unit_index(units_path)
290
+
291
+ for jsonl_row in iter_jsonl(path):
292
+ total += 1
293
+ row = jsonl_row.value
294
+ if isinstance(row, dict) and "__json_error__" in row:
295
+ field_errors["json"] += 1
296
+ error_rows += 1
297
+ continue
298
+ errors, unit_id, terminal_ids, canonical = validate_patch_row(row)
299
+ for message in errors:
300
+ field_errors[error_key(message)] += 1
301
+ if unit_id:
302
+ if unit_id in seen_unit_ids:
303
+ duplicate_unit_ids += 1
304
+ field_errors["unit_id.duplicate"] += 1
305
+ errors.append("unit_id.duplicate")
306
+ seen_unit_ids.add(unit_id)
307
+ if units_by_id:
308
+ expected = units_by_id.get(unit_id)
309
+ if expected is None:
310
+ unknown_unit_count += 1
311
+ field_errors["unit_id.unknown"] += 1
312
+ errors.append("unit_id.unknown")
313
+ elif set(terminal_ids) != expected:
314
+ terminal_mismatch_count += 1
315
+ field_errors["terminal_ids.mismatch"] += 1
316
+ errors.append("terminal_ids.mismatch")
317
+ if isinstance(canonical.get("status"), str):
318
+ status_counts[str(canonical["status"])] += 1
319
+ if errors:
320
+ error_rows += 1
321
+ else:
322
+ valid_patches.append(canonical)
323
+
324
+ return {
325
+ "schema": "dmhy_annotation_patches",
326
+ "schema_version": SCHEMA_VERSION,
327
+ "path": str(path),
328
+ "units_path": str(units_path) if units_path else None,
329
+ "total_rows": total,
330
+ "error_rows": error_rows,
331
+ "error_count": sum(field_errors.values()),
332
+ "common_field_errors": dict(field_errors.most_common()),
333
+ "duplicate_unit_ids": duplicate_unit_ids,
334
+ "terminal_id_mismatch_count": terminal_mismatch_count,
335
+ "unknown_unit_count": unknown_unit_count,
336
+ "status_counts": {status: status_counts.get(status, 0) for status in sorted(PATCH_STATUSES)},
337
+ "terminal_coverage_count": len({tid for patch in valid_patches for tid in patch["terminal_ids"]}),
338
+ "units_validation": None
339
+ if units_report is None
340
+ else {key: value for key, value in units_report.items() if not key.startswith("valid_")},
341
+ "valid_patches": valid_patches,
342
+ }
343
+
344
+
345
+ def annotation_empty_rates(patches: list[dict[str, Any]]) -> dict[str, float]:
346
+ if not patches:
347
+ return {key: 0.0 for key in (*ANNOTATION_ARRAY_FIELDS, "all_annotation_arrays")}
348
+ counts: Counter[str] = Counter()
349
+ for patch in patches:
350
+ annotation = patch.get("annotation") or {}
351
+ all_empty = True
352
+ for key in ANNOTATION_ARRAY_FIELDS:
353
+ empty = not annotation.get(key)
354
+ counts[key] += int(empty)
355
+ all_empty = all_empty and empty
356
+ counts["all_annotation_arrays"] += int(all_empty)
357
+ return {key: round(counts[key] / len(patches), 6) for key in (*ANNOTATION_ARRAY_FIELDS, "all_annotation_arrays")}
358
+
359
+
360
+ def text_summary(patches: list[dict[str, Any]], limit: int) -> dict[str, list[dict[str, Any]]]:
361
+ notes: Counter[str] = Counter()
362
+ errors: Counter[str] = Counter()
363
+ for patch in patches:
364
+ annotation = patch.get("annotation") or {}
365
+ note = annotation.get("notes")
366
+ if isinstance(note, str) and note.strip():
367
+ notes[note.strip()[:240]] += 1
368
+ for error in patch.get("errors") or []:
369
+ if isinstance(error, str) and error.strip():
370
+ errors[error.strip()[:240]] += 1
371
+ return {
372
+ "notes": [{"text": text, "count": count} for text, count in notes.most_common(limit)],
373
+ "errors": [{"text": text, "count": count} for text, count in errors.most_common(limit)],
374
+ }
375
+
376
+
377
+ def compact_patch_stats(path: Path, label: str, summary_limit: int) -> dict[str, Any]:
378
+ report = validate_patches(path)
379
+ patches = report["valid_patches"]
380
+ terminals = {tid for patch in patches for tid in patch["terminal_ids"]}
381
+ statuses = Counter(str(patch.get("status")) for patch in patches)
382
+ return {
383
+ "label": label,
384
+ "path": str(path),
385
+ "validation": {key: value for key, value in report.items() if not key.startswith("valid_")},
386
+ "status_counts": {status: statuses.get(status, 0) for status in sorted(PATCH_STATUSES)},
387
+ "terminal_coverage_count": len(terminals),
388
+ "terminals": terminals,
389
+ "annotation_empty_rates": annotation_empty_rates(patches),
390
+ "text_summary": text_summary(patches, summary_limit),
391
+ }
392
+
393
+
394
+ def compare_patches(
395
+ left_path: Path,
396
+ right_path: Path,
397
+ left_label: str,
398
+ right_label: str,
399
+ summary_limit: int,
400
+ ) -> dict[str, Any]:
401
+ left = compact_patch_stats(left_path, left_label, summary_limit)
402
+ right = compact_patch_stats(right_path, right_label, summary_limit)
403
+ left_terminals = left.pop("terminals")
404
+ right_terminals = right.pop("terminals")
405
+ return {
406
+ "schema": "dmhy_annotation_patch_comparison",
407
+ "schema_version": SCHEMA_VERSION,
408
+ "left": left,
409
+ "right": right,
410
+ "terminal_intersection_count": len(left_terminals & right_terminals),
411
+ "left_only_terminal_count": len(left_terminals - right_terminals),
412
+ "right_only_terminal_count": len(right_terminals - left_terminals),
413
+ "terminal_union_count": len(left_terminals | right_terminals),
414
+ }
415
+
416
+
417
+ def main() -> None:
418
+ args = parse_args()
419
+ if args.command == "validate-units":
420
+ report = validate_units(args.units_jsonl)
421
+ summarize_report(report, args.manifest_output)
422
+ raise SystemExit(1 if report["error_count"] else 0)
423
+ if args.command == "validate-patches":
424
+ report = validate_patches(args.patches_jsonl, args.units)
425
+ summarize_report(report, args.manifest_output)
426
+ raise SystemExit(1 if report["error_count"] else 0)
427
+ if args.command == "compare-patches":
428
+ report = compare_patches(
429
+ args.left_patches_jsonl,
430
+ args.right_patches_jsonl,
431
+ args.left_label,
432
+ args.right_label,
433
+ max(1, args.summary_limit),
434
+ )
435
+ summarize_report(report, args.manifest_output)
436
+ left_errors = report["left"]["validation"]["error_count"]
437
+ right_errors = report["right"]["validation"]["error_count"]
438
+ raise SystemExit(1 if left_errors or right_errors else 0)
439
+ raise SystemExit(f"unknown command: {args.command}")
440
+
441
+
442
+ if __name__ == "__main__":
443
+ try:
444
+ main()
445
+ except BrokenPipeError:
446
+ sys.exit(1)