diff --git a/benchmarks/report_attribution.py b/benchmarks/report_attribution.py new file mode 100644 index 0000000..138cc04 --- /dev/null +++ b/benchmarks/report_attribution.py @@ -0,0 +1,151 @@ +# SPDX-License-Identifier: MIT +"""Join saved raw Report events to final candidates, without replaying ranking.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import re +import struct +from pathlib import Path +from typing import Any + + +def candidate_key(value: dict[str, Any]) -> tuple[str, str | None, str]: + encoding, language, bits = ( + value.get("encoding"), + value.get("language"), + value.get("confidence_bits"), + ) + if not isinstance(encoding, str) or not encoding: + raise ValueError("candidate encoding must be nonempty") + if language is not None and (not isinstance(language, str) or not language): + raise ValueError("candidate language must be null or nonempty") + if not isinstance(bits, str) or not re.fullmatch(r"[0-9a-fA-F]{8}", bits): + raise ValueError("candidate confidence must be binary32 bits") + if not math.isfinite(struct.unpack("!f", bytes.fromhex(bits))[0]): + raise ValueError("candidate confidence must be finite") + return encoding, language, bits.lower() + + +def analyze(trace: list[dict[str, Any]], final: dict[str, Any]) -> dict[str, Any]: + """Exact observed-value attribution, not codec equivalence or a causal model.""" + if ( + not isinstance(trace, list) + or not trace + or any( + not isinstance(row, dict) + or type(row.get("schema_version")) is not int + or row["schema_version"] != 1 + for row in trace + ) + ): + raise ValueError("requires nonempty schema 1 trace") + if trace[0].get("event") != "initial" or trace[-1].get("event") != "after_end": + raise ValueError("requires complete initial-to-after_end trace") + if ( + not isinstance(final, dict) + or type(final.get("schema_version")) is not int + or final.get("schema_version") != 1 + or type(final.get("input_index")) is not int + or final.get("input_index") != 0 + ): + raise ValueError("requires one schema 1 final observation for input_index 0") + snapshots, raw = [], [] + for name in ("byte_length", "feed_calls"): + if type(final.get(name)) is not int or final[name] < (1 if name == "feed_calls" else 0): + raise ValueError("final length/feed count must be nonnegative/positive integers") + if any(type(final.get(name)) is not bool for name in ("initial_done", "final_done")): + raise ValueError("final completion flags must be boolean") + for index, row in enumerate(trace): + if row.get("event") == "raw_report": + raw.append((index, candidate_key(row))) + elif row.get("event") in ("initial", "after_feed", "after_end"): + snapshots.append(row) + else: + raise ValueError("unknown trace event") + if [row["event"] for row in snapshots] != ["initial"] + ["after_feed"] * ( + len(snapshots) - 2 + ) + ["after_end"]: + raise ValueError("unexpected snapshot order") + offsets: list[int] = [] + for row in snapshots: + offset = row.get("offset") + if type(offset) is not int or offset < 0: + raise ValueError("snapshot offsets must be nonnegative integers") + offsets.append(offset) + if len(snapshots) < 3 or offsets[-1] != offsets[-2]: + raise ValueError("after_end must follow the last feed at the same offset") + if offsets != sorted(offsets) or offsets[0] != 0 or offsets[-1] != final.get("byte_length"): + raise ValueError("trace/final length mismatch") + if len(snapshots) - 2 != final.get("feed_calls"): + raise ValueError("trace/final feed count mismatch") + if any(type(row.get("done")) is not bool for row in snapshots): + raise ValueError("snapshot done must be boolean") + if snapshots[0]["done"] != final.get("initial_done") or snapshots[-1]["done"] != final.get( + "final_done" + ): + raise ValueError("trace/final completion mismatch") + candidates = final.get("candidates") + if ( + not isinstance(candidates, list) + or type(final.get("candidate_count")) is not int + or final["candidate_count"] != len(candidates) + ): + raise ValueError("final candidate count mismatch") + matches = [] + matched_indices = set() + for rank, candidate in enumerate(candidates, 1): + if not isinstance(candidate, dict): + raise ValueError("candidate must be an object") + key = candidate_key(candidate) + exact = [index for index, other in raw if other == key] + same_label = [index for index, other in raw if other[:2] == key[:2]] + matched_indices.update(exact) + matches.append( + dict( + rank=rank, + encoding=key[0], + language=key[1], + confidence_bits=key[2], + exact_raw_event_indices=exact, + same_label_raw_event_indices=same_label, + status="EXACT_RAW_VALUE_OBSERVED" if exact else "NO_EXACT_RAW_VALUE", + ) + ) + return dict( + schema_version=1, + raw_report_count=len(raw), + final_candidate_count=len(candidates), + final_candidates=matches, + unmatched_raw_event_indices=[index for index, _ in raw if index not in matched_indices], + ranking_reason="UNRESOLVED", + limitations=[ + "Matching values do not authenticate that files came from the same input/build.", + "Duplicate equal raw values cannot identify which event supplied a candidate.", + "No deduplication, tie-breaking, weighting or ranking algorithm is replayed.", + ], + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--trace", type=Path, required=True) + parser.add_argument("--final", type=Path, required=True) + args = parser.parse_args() + trace_bytes, final_bytes = args.trace.read_bytes(), args.final.read_bytes() + trace = [json.loads(line) for line in trace_bytes.splitlines()] + final = json.loads(final_bytes) + report = analyze(trace, final) + report["artifacts"] = dict( + trace_sha256=hashlib.sha256(trace_bytes).hexdigest(), + final_sha256=hashlib.sha256(final_bytes).hexdigest(), + analysis_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + ) + print(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/docs/v3-foundation-results.md b/docs/v3-foundation-results.md index b9cc54f..60fc7f4 100644 --- a/docs/v3-foundation-results.md +++ b/docs/v3-foundation-results.md @@ -143,3 +143,21 @@ archive自体のmetadata差、他compiler、一般的な性能測定とは区別 観測した。model外の文字対は分母のみ増えるため、単純なcategory比率と同一視できない。 これはUnicode language detectorの観測であり、新規SBCS trainerとの適合を確認したわけではない。 reject/ranking原因はunknownを維持し、#120全体は未完了とする。 + +[raw Reportと最終候補の対応照合](v3-report-attribution.md)も追加した。 +保存artifactの完全一致候補を列挙するだけでrankingを再実装せず、同値重複の曖昧さや +異なるinput/buildを照合した可能性を隠さない。既存小fixtureの9通りでも確認した。 + +## coding state・SBCS統計の観測(2026-09-21) + +native traceの追加field `prober_evidence` で、MBCS childのcoding stateと +SBCS modelの初期化済みcounterを読み取る。Big5は独自処理なのでcoding stateをnullとし、 +Hebrewの補助proberは統計model一覧から除く。静的model labelを最終候補と同一視しない。 + +baseline `a56fd958` と同じGCC16.2.1 Release buildで、全61 library object memberが +byte一致した。既存3小fixtureの5 feed scheduleで追加field以外の観測が一致し、 +fresh/reuseと固定randomを含む6 scheduleでも最終候補が一致した。 +追加testは4件成功。詳細とskip条件はnative診断文書を参照する。 + +これはfeed後のsnapshotであり、全byteの状態遷移履歴ではない。rejectの根本原因や +ranking理由は未解決のまま。P01保留中の作業や追加の不正入力探索は再開していない。 diff --git a/docs/v3-improvement-priorities.md b/docs/v3-improvement-priorities.md index 44e24fa..035daf1 100644 --- a/docs/v3-improvement-priorities.md +++ b/docs/v3-improvement-priorities.md @@ -18,7 +18,7 @@ uv run --locked python -m benchmarks.failure_analysis \ | 観測 | 母数と差分 | 現段階の扱い | | --- | --- | --- | -| UTF-16 | 4件中exact 2件。日本語LE/BEの2件は正解候補不在 | 構造判定の改善候補。ただしmodel不足/早期棄却/入力不足の原因は未確定 | +| UTF-16 | 4件中exact 2件。日本語LE/BEの2件は正解候補不在 | 後続source確認でBOMなし構造判定経路の不足を確認。ranking調整とは分ける | | Western Latin | 25件中exact 23件。2件はdecode-equivalent | 異なるcodec名だけを理由に精度修正しない | | Hebrew | 2件中exact 1件、compatible 2件 | superset/互換関係を維持して扱う | | family未分類 | v1で34件、全件exact | detector未対応ではなく分析側mappingの未整備 | @@ -26,6 +26,10 @@ uv run --locked python -m benchmarks.failure_analysis \ 2026-09-21の[family mapping v2](v3-family-mapping.md)で未分類34件を明示分類した。 分類field以外のsample値は全件不変。以下のv1に基づく診断を精度改善とは読み替えない。 +同日の[UTF-16 source確認](v3-utf-structure-gap.md)では、保存観測時と現状の判定経路が +同一で、BOMなしUTF-16候補を生成する構造proberがないことを確認した。 +これは固定revisionの手動診断で、一般toolの候補不在を自動的に未対応と断定する変更ではない。 + 残りのfixtureはexact一致。これは外部corpusや実Web上の精度を保証しない。 各familyの母数が少なく、実運用での出現頻度も測れていないので、 この数字だけではArabic・Vietnamese等の新規対応の順序は決められない。 diff --git a/docs/v3-report-attribution.md b/docs/v3-report-attribution.md new file mode 100644 index 0000000..af0b1d2 --- /dev/null +++ b/docs/v3-report-attribution.md @@ -0,0 +1,71 @@ + +# raw Reportと最終候補の対応照合 + +`benchmarks.report_attribution` は保存済みtrace JSONLと、1入力分の +`uchardet-conformance` JSONを照合するPython-only tool。native実行やrankingの再実装はしない。 + +```sh +uv run --locked python -m benchmarks.report_attribution \ + --trace /disk/trace.jsonl --final /disk/final.json +``` + +完全なinitial〜after_end snapshot、入力長、feed回数、開始・終了done、候補数と +有限binary32 confidence bitsを確認する。最終候補の順序をそのままrankとして保持する。 +各候補についてencoding名・language(nullを含む)・confidence bitsが完全一致する +raw Reportのevent indexを列挙し、同labelだがscoreが違うeventも別に示す。 +indexはJSONL全eventの0始まりであり、raw Reportだけの番号ではない。 + +- codec aliasやdecode互換性ではなく、観測値の完全一致を扱う。 +- 同値のraw eventが複数あれば全indexを残し、採用元を一つに決めつけない。 +- `unmatched_raw_event_indices` は最終候補のどの値とも完全一致しなかったevent。 + 「棄却された理由」や「unusedだったevent」の証明ではない。 +- 異なるscoreを見ても、weight適用や較正が原因だったと推測しない。 +- input本文・新しいconfidence計算・deduplication/tie-breakingの再実装は含まない。 + +出力にはtrace/final artifactと分析toolのSHA-256を含める。 +**このhashはファイルの同一性を追跡するもので、同じ入力・build・chunkから +生成されたことを認証するものではない。** 長さとfeed回数の一致だけでも証明できない。 +収集時のinput hash、native revision、tool hash、同じfeed scheduleを別途固定する必要がある。 +ranking_reasonは一致した場合も `UNRESOLVED` のままにする。 + +## 限定した実行確認(2026-09-21) + +19件の単体testに加え、既存の空・ASCII・135-byte日本語fixtureを +whole / 1-byte / 7-byteでfeedする9通りを実際のtrace/conformanceで照合した。 +同じtemporary inputを両toolへ渡し、全最終候補について同値raw Reportが存在した。 +これはencoding accuracyや順位原因の証明ではない。 + +```sh +UCHARDET_TRACE=/path/to/uchardet-trace \ +UCHARDET_CONFORMANCE=/path/to/uchardet-conformance \ + uv run --locked pytest tests/test_report_attribution.py -q +``` + +この実行は28 tests成功。環境変数なしではnative比較9件がskipとなる。 +native toolはlanguage観測追加commit `a9b7405701d96c04dfca4432817190078c86208e` の +GCC16.2.1 / Release / static buildを使用した。merge先は `a56fd9584d11e7a1f5cbcc75df9b5c3ec4837ab4`。 + +使用toolのSHA-256: + +- trace: `90a3b320fd189674a5d8883d12a92ca986344432a8c655191954299f5138a2ea` +- conformance: `2944d943c50af9a76222617e9591bb6294526b5e18d6390b978501187d67501e` + +固定した入力(UTF-8。検証toolが作るtemporary fileで両実行に共用): + +| 内容 | bytes | SHA-256 | +| --- | ---: | --- | +| 空 | 0 | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | +| `plain text` + LF | 11 | `c30a92f9ef889c07c781a7cf99f5b71415d4d1289e84473d1b9e6f01feffc62d` | +| `日本語の文章です。` を5回 | 135 | `85a90cf384c10ea7cb1bd217352fad10c14393daaf7aaccd9227b2344aef2c2f` | + +新規fuzz、不正入力探索、大入力、独立holdout評価、既定model/API変更は行っていない。 + +## prober内部観測追加後の再確認 + +native `063ad3e9a62c90db482550e278a8682b98d0198a` +(merge `e3c8526ab0effa6960dff173be991a18e732782d`)でも同じ28 testsが成功した。 +追加のsnapshot fieldがあっても、保存済みraw値と最終候補の照合結果は変わらない。 +同じGCC16.2.1 / Release / static buildのtool SHA-256: + +- trace: `018734eb85809cbeb8ff9946d4ea25196d48d8bfd94f2836f0ef75463e0bb9ce` +- conformance: `2944d943c50af9a76222617e9591bb6294526b5e18d6390b978501187d67501e` diff --git a/docs/v3-utf-structure-gap.md b/docs/v3-utf-structure-gap.md new file mode 100644 index 0000000..fc83b73 --- /dev/null +++ b/docs/v3-utf-structure-gap.md @@ -0,0 +1,54 @@ + +# BOMなしUTF-16候補不在のsource確認 + +2026-09-21。既存の日本語UTF-16 LE/BE fixtureの保存観測とsourceを照合した。 +新しいnative実行、入力探索、BOM試作の変更は行っていない。 + +## 観測と由来 + +観測revisionは `7993e0afe3b6c64f0ea3b43c7d4987118f6c5c12`。 +今回のsource確認先は `a56fd9584d11e7a1f5cbcc75df9b5c3ec4837ab4`。 +両revisionの `src/nsUniversalDetector.cpp` は同じGit blob +`06c2d9a2306afdb136d99e9020863c576d22c492`。 +MBCS/SBCS groupとescape proberのcppにも、このrevision間の差分はない。 + +| fixture | bytes | 先頭4 bytes | 保存された先頭候補 | +| --- | ---: | --- | --- | +| ja/utf-16le.txt | 1416 | 55 00 54 00 | UTF-8 / hu / confidence bits 3ef86af4 | +| ja/utf-16be.txt | 1416 | 00 55 00 54 | UTF-8 / pl / confidence bits 3efd78c9 | + +fixtureのSHA-256はLEが +`74e4dc7cb2cc04df9d1cdb3ee4ac33222f8e87a2a1d4605a60b6bdb6eb60a824`、BEが +`73df6f4ac1e8be28b71ba4fb37f689cbaa7e08f74cd8034120089122b1a3df01`。 +現在の本文hashを保存観測と照合し、Pythonの指定codecによるstrict decodeも成功した。 +ラベルはlegacy fixture path由来であり、独立した正解確認を追加したわけではない。 +両者とも保存観測内に正解候補がなく、top-kを広げても得られない。 + +## sourceから分かる範囲 + +- [先頭判定](https://github.com/PyYoshi/uchardet/blob/a56fd9584d11e7a1f5cbcc75df9b5c3ec4837ab4/src/nsUniversalDetector.cpp#L118) + では最初のfeedのBOMからUTF-16/UTF-32 shortcutを設定する。 +- [MBCSの生成](https://github.com/PyYoshi/uchardet/blob/a56fd9584d11e7a1f5cbcc75df9b5c3ec4837ab4/src/nsMBCSGroupProber.cpp#L61) + ではUTF-8、SJIS、EUC-JP、GB18030、EUC-KR、Big5、EUC-TW、Johabの8 proberを作る。 + BOMなしUTF-16/UTF-32用の構造proberは登録されていない。 +- この固定sourceのUTF-16/UTF-32 encoding labelは先頭BOM shortcutにあり、 + 後続のSBCS・escape経路がBOMなしUTF-16を候補として補う設計ではない。 + +したがって、この2件を単純なconfidence/ranking調整の対象とするのは不適切である。 +**不足しているのはBOMなし入力からUTF-16候補を作る経路**であって、 +「UTF-16全体が未対応」「日本語modelが未対応」とは言わない。 +統計modelの追加と構造判定の追加も別の変更として扱う。 + +これはsourceに基づく手動診断。一般的な失敗分類toolは候補不在だけから同じ原因を +自動断定せず、既存の `cause_status: UNRESOLVED` を維持する。 +他revision・別library・別入力にこの結論を一般化しない。 + +## 改善前に必要な評価 + +先頭BOMのchunk bufferingだけでは、BOM自体がないこの2件は解決しない。 +構造判定を追加する場合は、通常ASCII/UTF-8、embedded NUL、短い入力、code unit境界、 +surrogate、chunking、処理量制限との識別・互換性を別途評価する必要がある。 +これらは将来の受け入れ条件であり、今回試験したと主張しない。 + +現在の2件の試作枠を無断で増やしたり、P01を再開したりしない。 +新しい実装・採用は #125/#126 とD02/D03の判断範囲へ残す。 diff --git a/src/ext/uchardet b/src/ext/uchardet index a56fd95..e3c8526 160000 --- a/src/ext/uchardet +++ b/src/ext/uchardet @@ -1 +1 @@ -Subproject commit a56fd9584d11e7a1f5cbcc75df9b5c3ec4837ab4 +Subproject commit e3c8526ab0effa6960dff173be991a18e732782d diff --git a/tests/test_report_attribution.py b/tests/test_report_attribution.py new file mode 100644 index 0000000..cb34b43 --- /dev/null +++ b/tests/test_report_attribution.py @@ -0,0 +1,169 @@ +# SPDX-License-Identifier: MIT +import copy +import hashlib +import json +import os +import subprocess +import sys + +import pytest + +from benchmarks.report_attribution import analyze + + +def candidate(language="fr", bits="3f400000"): + return dict(encoding="UTF-8", language=language, confidence_bits=bits) + + +def observation(raw, final=None): + trace = [ + dict(schema_version=1, event="initial", offset=0, done=False), + dict(schema_version=1, event="after_feed", offset=3, done=False), + *[dict(schema_version=1, event="raw_report", offset=3, **row) for row in raw], + dict(schema_version=1, event="after_end", offset=3, done=False), + ] + candidates = raw if final is None else final + final_row = dict( + schema_version=1, + input_index=0, + byte_length=3, + feed_calls=1, + initial_done=False, + final_done=False, + candidate_count=len(candidates), + candidates=candidates, + ) + return trace, final_row + + +def test_duplicate_values_are_ambiguous_not_deduplicated_by_analyzer(): + trace, final = observation([candidate(), candidate()], [candidate()]) + before = copy.deepcopy((trace, final)) + report = analyze(trace, final) + assert report["final_candidates"][0]["exact_raw_event_indices"] == [2, 3] + assert report["ranking_reason"] == "UNRESOLVED" + assert report["unmatched_raw_event_indices"] == [] + assert (trace, final) == before + + +def test_changed_score_is_not_automatically_attributed_to_weights(): + report = analyze(*observation([candidate()], [candidate(bits="3f000000")])) + row = report["final_candidates"][0] + assert row["status"] == "NO_EXACT_RAW_VALUE" + assert row["same_label_raw_event_indices"] == [2] + assert row["exact_raw_event_indices"] == [] + assert report["unmatched_raw_event_indices"] == [2] + + +def test_null_language_and_binary32_spelling_preserve_exact_observation(): + report = analyze(*observation([candidate(None, "3F400000")])) + row = report["final_candidates"][0] + assert row["language"] is None + assert row["confidence_bits"] == "3f400000" + assert row["status"] == "EXACT_RAW_VALUE_OBSERVED" + + +def test_rank_is_final_order_not_raw_order_or_score_sort(): + low, high = candidate(bits="3f000000"), candidate(bits="3f400000") + report = analyze(*observation([low, high], [high, low])) + assert [c["exact_raw_event_indices"] for c in report["final_candidates"]] == [[3], [2]] + assert [c["rank"] for c in report["final_candidates"]] == [1, 2] + + +@pytest.mark.parametrize("bits", ["bad", "7fc00000", "7f800000", "ff800000", None]) +def test_invalid_confidence_rejected(bits): + with pytest.raises(ValueError, match="confidence"): + analyze(*observation([candidate(bits=bits)])) + + +@pytest.mark.parametrize( + "field,value", + [ + ("byte_length", 4), + ("feed_calls", 2), + ("feed_calls", True), + ("candidate_count", 2), + ("initial_done", True), + ("final_done", 0), + ("input_index", 1), + ], +) +def test_mismatched_metadata_rejected(field, value): + trace, final = observation([candidate()]) + final[field] = value + with pytest.raises(ValueError): + analyze(trace, final) + + +def test_incomplete_and_unknown_events_rejected(): + trace, final = observation([candidate()]) + with pytest.raises(ValueError): + analyze(trace[:-1], final) + trace[1]["event"] = "invented" + with pytest.raises(ValueError): + analyze(trace, final) + + +def test_empty_candidate_sets_are_valid(): + report = analyze(*observation([])) + assert report["final_candidates"] == [] + assert report["raw_report_count"] == 0 + + +def test_cli_records_artifact_hashes(tmp_path): + trace, final = observation([candidate()]) + trace_path, final_path = tmp_path / "trace.jsonl", tmp_path / "final.json" + trace_path.write_text("\n".join(json.dumps(row) for row in trace), encoding="utf-8") + final_path.write_text(json.dumps(final), encoding="utf-8") + result = subprocess.run( + [ + sys.executable, + "-m", + "benchmarks.report_attribution", + "--trace", + str(trace_path), + "--final", + str(final_path), + ], + capture_output=True, + text=True, + check=True, + timeout=30, + ) + report = json.loads(result.stdout) + assert ( + report["artifacts"]["trace_sha256"] == hashlib.sha256(trace_path.read_bytes()).hexdigest() + ) + assert ( + report["artifacts"]["final_sha256"] == hashlib.sha256(final_path.read_bytes()).hexdigest() + ) + + +@pytest.mark.skipif( + not (os.environ.get("UCHARDET_TRACE") and os.environ.get("UCHARDET_CONFORMANCE")), + reason="set trace and conformance tools for existing tiny fixtures", +) +@pytest.mark.parametrize("data", [b"", b"plain text\n", "日本語の文章です。".encode() * 5]) +@pytest.mark.parametrize("chunk", ["0", "1", "7"]) +def test_existing_native_fixtures(tmp_path, data, chunk): + path = tmp_path / "input" + path.write_bytes(data) + trace_output = subprocess.run( + [os.environ["UCHARDET_TRACE"], chunk, str(path)], + capture_output=True, + text=True, + check=True, + timeout=30, + ).stdout + final_output = subprocess.run( + [os.environ["UCHARDET_CONFORMANCE"], "fresh", chunk, str(path)], + capture_output=True, + text=True, + check=True, + timeout=30, + ).stdout + report = analyze( + [json.loads(line) for line in trace_output.splitlines()], json.loads(final_output) + ) + assert all(row["status"] == "EXACT_RAW_VALUE_OBSERVED" for row in report["final_candidates"]) + assert report["ranking_reason"] == "UNRESOLVED"