Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions benchmarks/report_attribution.py
Original file line number Diff line number Diff line change
@@ -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()
18 changes: 18 additions & 0 deletions docs/v3-foundation-results.md
Original file line number Diff line number Diff line change
Expand Up @@ -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保留中の作業や追加の不正入力探索は再開していない。
6 changes: 5 additions & 1 deletion docs/v3-improvement-priorities.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,18 @@ 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の未整備 |

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等の新規対応の順序は決められない。
Expand Down
71 changes: 71 additions & 0 deletions docs/v3-report-attribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<!-- SPDX-License-Identifier: MIT -->
# 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`
54 changes: 54 additions & 0 deletions docs/v3-utf-structure-gap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<!-- SPDX-License-Identifier: MIT -->
# 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の判断範囲へ残す。
Loading
Loading