from __future__ import annotations import math import pytest from deberta_ime import Candidate, CandidateScoringError, RerankRequest from deberta_ime.benchmark import ( ScoredExample, evaluate_setting, mcnemar_exact_p, paired_bootstrap_gain_interval, parse_conllu, prepare_examples, score_prepared_split, select_setting, ) from deberta_ime.benchmark_runner import BenchmarkConfig, run_benchmark from deberta_ime.benchmark_v2 import prepare_mozc_examples from deberta_ime.mozc import MozcDictionaryIndex, build_mozc_index from deberta_ime.ud_gsd import CorpusArtifact CONLLU = """\ # sent_id = sample-1 # text = 川の橋を渡る。 1\t川\t川\tNOUN\t名詞\t_\t3\tnmod\t_\tSpaceAfter=No|UnidicInfo=カワ,川,川 2\tの\tの\tADP\t助詞\t_\t1\tcase\t_\tSpaceAfter=No|UnidicInfo=ノ,の,の 3\t橋\t橋\tNOUN\t名詞\t_\t4\tobj\t_\tSpaceAfter=No|UnidicInfo=ハシ,橋,橋 4\tを\tを\tADP\t助詞\t_\t3\tcase\t_\tSpaceAfter=No|UnidicInfo=ヲ,を,を 5\t渡る\t渡る\tVERB\t動詞\t_\t0\troot\t_\tSpaceAfter=No|UnidicInfo=ワタル,渡る,渡る 6\t。\t。\tPUNCT\t補助記号\t_\t5\tpunct\t_\tUnidicInfo=。,。,。 """ TRAIN_CONLLU = """\ # sent_id = train-1 1\t箸\t箸\tNOUN\t名詞\t_\t0\troot\t_\tUnidicInfo=ハシ,箸,箸 # sent_id = train-2 1\t箸\t箸\tNOUN\t名詞\t_\t0\troot\t_\tUnidicInfo=ハシ,箸,箸 # sent_id = train-3 1\t橋\t橋\tNOUN\t名詞\t_\t0\troot\t_\tUnidicInfo=ハシ,橋,橋 """ INFLECTED_CONLLU = """\ # sent_id = inflected-1 1\tし\t為る\tAUX\t動詞\t_\t0\troot\t_\tUnidicInfo=スル,為る,し,する,シ,,,スル,スル,する """ def test_parse_conllu_extracts_surface_reading_and_pos() -> None: sentences = parse_conllu(CONLLU) assert len(sentences) == 1 assert [(token.surface, token.reading, token.upos) for token in sentences[0]] == [ ("川", "カワ", "NOUN"), ("の", "ノ", "ADP"), ("橋", "ハシ", "NOUN"), ("を", "ヲ", "ADP"), ("渡る", "ワタル", "VERB"), ("。", "。", "PUNCT"), ] def test_parse_conllu_uses_surface_pronunciation_instead_of_lexeme_reading() -> None: sentences = parse_conllu(INFLECTED_CONLLU) assert sentences[0][0].surface == "し" assert sentences[0][0].reading == "シ" def test_prepare_examples_builds_frequency_ranked_oracle_candidate_pool() -> None: prepared = prepare_examples( parse_conllu(TRAIN_CONLLU), parse_conllu(CONLLU), pool_size=8, ) assert prepared.coverage.ambiguous_known_reading == 1 assert prepared.coverage.oracle_in_pool == 1 assert prepared.coverage.oracle_miss == 0 assert len(prepared.examples) == 1 example = prepared.examples[0] assert example.expected == "橋" assert [candidate.surface for candidate in example.request.candidates] == ["箸", "橋"] assert [candidate.prior_score for candidate in example.request.candidates] == [ math.log1p(2), math.log1p(1), ] assert example.request.left_context == ("川", "の") assert example.request.right_context == ("を", "渡る", "。") def test_prepare_mozc_examples_uses_surface_reading_cost_and_context_mode(tmp_path) -> None: dictionary_dir = tmp_path / "dictionary_oss" dictionary_dir.mkdir() (dictionary_dir / "dictionary00.txt").write_text( "はし\t1\t1\t3500\t箸\nはし\t1\t1\t3800\t橋\n", encoding="utf-8", ) index_path = tmp_path / "mozc.sqlite3" build_mozc_index(dictionary_dir, index_path, source_revision="fixture") with MozcDictionaryIndex(index_path) as index: prepared = prepare_mozc_examples( parse_conllu(CONLLU), index, pool_size=8, context_mode="left_only", ) assert prepared.coverage.ambiguous_known_reading == 1 assert prepared.coverage.oracle_in_pool == 1 assert prepared.coverage.oracle_miss == 0 example = prepared.examples[0] assert example.expected == "橋" assert [candidate.surface for candidate in example.request.candidates] == ["箸", "橋"] assert [candidate.prior_score for candidate in example.request.candidates] == pytest.approx( [0.0, -0.3] ) assert example.request.left_context == ("川", "の") assert example.request.right_context == () def test_evaluate_setting_separates_improvements_and_regressions() -> None: def scored( expected: str, candidates: tuple[str, ...], scores: tuple[float, ...] ) -> ScoredExample: return ScoredExample( request=RerankRequest( reading="テスト", candidates=tuple(Candidate(surface) for surface in candidates), ), expected=expected, model_scores=scores, latency_ms=1.0, ) examples = ( scored("正", ("誤", "正"), (0.0, 2.0)), scored("正", ("正", "誤"), (0.0, 2.0)), scored("正", ("正", "誤"), (2.0, 0.0)), scored("正", ("誤一", "正", "誤二"), (0.0, 1.0, 2.0)), ) metrics = evaluate_setting(examples, prior_weight=0.0, min_margin=0.0) assert metrics.total == 4 assert metrics.baseline_correct == 2 assert metrics.reranked_correct == 2 assert metrics.improved == 1 assert metrics.regressed == 1 assert metrics.both_correct == 1 assert metrics.both_wrong == 1 assert metrics.changed == 3 def test_select_setting_uses_only_supplied_development_rows() -> None: request = RerankRequest( reading="ハシ", candidates=(Candidate("箸", prior_score=2.0), Candidate("橋", prior_score=0.0)), ) rows = ( ScoredExample( request=request, expected="橋", model_scores=(0.0, 2.0), latency_ms=1.0, ), ) selected = select_setting( rows, prior_weights=(0.0, 2.0), min_margins=(0.0, 3.0), ) assert selected.prior_weight == 0.0 assert selected.min_margin == 0.0 assert selected.metrics.reranked_correct == 1 def test_paired_statistics_have_known_exact_cases() -> None: assert mcnemar_exact_p(improved=10, regressed=0) == pytest.approx(0.001953125) assert mcnemar_exact_p(improved=5, regressed=5) == 1.0 assert paired_bootstrap_gain_interval( [1] * 20, samples=200, seed=20260810, ) == (1.0, 1.0) def test_score_prepared_split_keeps_rows_and_records_model_failures() -> None: prepared = prepare_examples( parse_conllu(TRAIN_CONLLU), parse_conllu(CONLLU), pool_size=8, ) class FailingScorer: def score_candidates(self, request: RerankRequest) -> list[float]: raise RuntimeError("unavailable") scored = score_prepared_split(prepared, FailingScorer()) assert len(scored.examples) == 1 assert scored.examples[0].model_scores is None assert scored.scoring_errors == 1 assert scored.total_available == 1 def test_score_prepared_split_records_stable_failure_codes() -> None: prepared = prepare_examples( parse_conllu(TRAIN_CONLLU), parse_conllu(CONLLU), pool_size=8, ) class UnknownTokenScorer: def score_candidates(self, request: RerankRequest) -> list[float]: raise CandidateScoringError("unknown_token", "candidate contains unknown token") scored = score_prepared_split(prepared, UnknownTokenScorer()) assert scored.examples[0].error_code == "unknown_token" def test_benchmark_report_tunes_on_dev_and_reports_test_separately(tmp_path) -> None: texts = {"train": TRAIN_CONLLU, "dev": CONLLU, "test": CONLLU} def artifact_loader(split: str, cache_dir) -> CorpusArtifact: text = texts[split] return CorpusArtifact( split=split, path=tmp_path / f"{split}.conllu", url=f"https://example.invalid/{split}", revision="fixture", sha256=split * 8, size_bytes=len(text.encode()), text=text, ) class ContextScorer: def score_candidates(self, request: RerankRequest) -> list[float]: return [0.0, 2.0] run = run_benchmark( ContextScorer(), data_dir=tmp_path, config=BenchmarkConfig( prior_weights=(0.0,), min_margins=(0.0,), bootstrap_samples=50, prototype_test_exclusion_count=0, ), artifact_loader=artifact_loader, ) test_metrics = run.report["metrics"]["sealed_test_remainder"] assert run.report["selection"]["source"] == "dev only" assert test_metrics["baseline"]["correct"] == 0 assert test_metrics["tuned"]["correct"] == 1 assert run.report["paired_test_statistics"]["improved"] == 1