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
1 change: 1 addition & 0 deletions .github/workflows/native-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ jobs:
env:
UCHARDET_FILTER_PROFILE: build/release/benchmark/uchardet-filter-profile
UCHARDET_STATIC_LIBRARY: build/release/src/libuchardet.a
UCHARDET_ENGINE_EXPERIMENT: '1'
run: |
uv run --no-project --python 3.11 python -m unittest discover -s corpus -p 'test_*.py'
uv run --no-project --python 3.11 python -m unittest discover -s corpus/sources -p 'test_*.py'
Expand Down
6 changes: 6 additions & 0 deletions benchmark/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ target_link_libraries(uchardet-output ${UCHARDET_LIBRARY})
add_executable(uchardet-conformance uchardet-conformance.cpp)
target_link_libraries(uchardet-conformance ${UCHARDET_LIBRARY})

if(TARGET libuchardet_experimental)
add_executable(uchardet-conformance-experimental EXCLUDE_FROM_ALL uchardet-conformance.cpp)
target_compile_definitions(uchardet-conformance-experimental PRIVATE UCHARDET_EXPERIMENTAL_INPUT_LIMIT=4096)
target_link_libraries(uchardet-conformance-experimental libuchardet_experimental)
endif()

option(BUILD_INTROSPECTION "Build internal diagnostic observer (static builds only)" OFF)
if(BUILD_INTROSPECTION)
if(BUILD_SHARED_LIBS)
Expand Down
8 changes: 8 additions & 0 deletions benchmark/uchardet-conformance.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,15 @@ int main(int argc, char** argv) {
for (int file = 3; file < argc; ++file) {
std::ifstream stream(argv[file], std::ios::binary);
if (!stream) throw std::runtime_error("cannot open input");
#ifdef UCHARDET_EXPERIMENTAL_INPUT_LIMIT
std::vector<char> bytes(UCHARDET_EXPERIMENTAL_INPUT_LIMIT + 1);
stream.read(bytes.data(), static_cast<std::streamsize>(bytes.size()));
bytes.resize(static_cast<size_t>(stream.gcount()));
if (bytes.size() > UCHARDET_EXPERIMENTAL_INPUT_LIMIT)
throw std::runtime_error("experimental input exceeds 4096 bytes");
#else
const std::vector<char> bytes((std::istreambuf_iterator<char>(stream)), std::istreambuf_iterator<char>());
#endif
if (stream.bad()) throw std::runtime_error("cannot read input");
if (!detector || mode == "fresh") detector.reset(uchardet_new());
else uchardet_reset(detector.get());
Expand Down
79 changes: 79 additions & 0 deletions models/experimental/ENGINE_PROBE.ja.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<!-- SPDX-License-Identifier: MIT -->
# 生成Frenchモデルを候補群へ接続する非デフォルトtarget

単一proberの内部スコアだけでは候補競合の結果を評価できないため、French cp1252の
1 slotだけを差し替えた別static libraryと候補観測実行fileをbuildする。
標準`libuchardet`・CLI・install・Python wheelのmodelを置き換える機能ではない。
通常のCMake設定では実験target自体が存在せず、明示した場合も`ALL`/installから除外する。

## 範囲

- `nsSBCSGroupProber`の`Windows_1252FrenchModel`参照だけを実験target内で置き換える。
- 同じgroupのISO-8859-1/15 French、他言語、UTF-8のlanguage model、ranking処理は維持する。
- したがってFrenchモデル全体の置換ではなく、1 slotのみ異なるhybrid engineの比較になる。
- 出力は既存conformance toolの候補数・順序・encoding・language・confidence bit列とdone観測。
- modelのencoding名が`cp1252`なら、その表記も出力差になる。exact名とcodec互換性を分ける。
- 評価入口は4 KiB以下、既定はone-shot/fresh。Python側と実験実行file側で上限を確認する。
- 大入力の停止調査、追加fuzz、P01依存のBOM試作を再開しない。

## Build

driverは現在Linuxのみ。CMake/C++ compiler以外の新しいdependencyは不要。

```sh
uv run --no-project python models/experimental/engine_probe.py \
--reference /disk/engine-reference
uv run --no-project python models/experimental/engine_probe.py \
--training /disk/frozen-training.json /disk/engine-generated
```

`--training`は既存identity/filtered training artifactを、そのhash・依存revision・runtimeを含め
検証してからheaderへ変換する。言語fr、codec cp1252以外は拒否する。
`--reference`はlegacy tableをコピーせず同じmodelへの参照を接続し、adapterだけで結果が
変わらないことを検査する対照。任意の既存出力directoryは上書きしない。

driverは標準buildで実験実行fileが生成されないことを確認した後、明示targetをbuildする。
両方の実行fileとheader、compiler/CMake情報、compile設定、source hashを記録する。
生model/headerは私的build artifactとして扱い、repositoryへ追加しない。

CMakeを直接使う場合は`BUILD_SHARED_LIBS=OFF`、`BUILD_BENCHMARK=ON`、
`UCHARDET_EXPERIMENTAL_MODEL_HEADER=/absolute/path/model.hpp`を明示し、
`uchardet-conformance-experimental`を指定してbuildする。
この低レベル経路は信頼済みC++ header用で、JSON検証を代行しない。

## 比較時の注意

`engine_probe.observe(binary, data, chunk=0)`は4 KiBを超える入力を実行前に拒否し、
各processの実行を10秒で打ち切る。timeoutを正解率・一致として数えない。
標準conformance実行file自体にはこの専用上限がないため、pilotでは必ずこの入口を使う。

trainingと評価dataの重複監査・manifest検証は評価driver側で行う。
低レベルbuild/observe helperを使っただけでcorpus独立性が保証されるわけではない。
モデルの較正・encoding accuracy・性能・権利のgateも別であり、接続成功は採用承認ではない。

CIは明示環境変数`UCHARDET_ENGINE_EXPERIMENT=1`でreference一致と人工modelの結果変化、
標準target不変、default build/installからの分離、入力上限を検証する。
人工modelはこのtestで作った小tableで、自然言語modelの品質を示さない。

## Paired corpus評価

```sh
uv run --no-project python models/experimental/engine_comparison.py \
/disk/identity-training.json /disk/filtered-training.json \
/disk/validation/manifest.json \
/disk/engine-identity /disk/engine-filtered /disk/comparison.json
```

既存paired control validatorでtrainingとの重複、manifest、同一Unicode文書の
full cp1252/UTF-8対を検証する。全入力が4 KiB以下でなければ評価全体を拒否し、
切り詰めたり大きな文書だけを集計から外したりしない。
固定training contractとbuild header・source・実行fileのhashを照合し、
両buildの標準target出力が一致しなければ結果を生成しない。

結果には候補一覧とconfidence bit列を保持する。集計ではcodec aliasを正規化した
先頭候補のexact codec、strict decode後の文字列一致、language一致、正解codecの
候補内存在を分離する。decode-equivalentはその入力だけの性質であり、encoding全体の
互換性・superset関係を意味しない。compatible/superset指標は未評価と明記する。
候補内に正解がないことだけでmodel欠落と断定せず、group内部の選抜も考慮する。
legacy modelのtraining overlapは不明であり、現行生成modelとの公平な独立学習比較を
保証するものではない。
121 changes: 121 additions & 0 deletions models/experimental/engine_comparison.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# SPDX-License-Identifier: MIT
"""Small paired-corpus candidate observations; no default model replacement."""
import argparse
import codecs
import json
from pathlib import Path

import engine_probe
import paired_controls
from model import canonical, digest, safe_path, write_idempotent
from sequence_contract import content_hash, emit_cpp


def score(record, data, encoding, language):
expected = codecs.lookup(encoding).name
candidates = record["candidates"]
names = []
for candidate in candidates:
try:
names.append(codecs.lookup(candidate["encoding"]).name)
except (LookupError, TypeError):
names.append(None)
ranks = [i + 1 for i, name in enumerate(names) if name == expected]
status = "no_candidate"
if candidates:
status = "unknown_codec"
if names[0] is not None:
try:
decoded = data.decode(names[0], errors="strict")
status = "equal" if decoded == data.decode(expected, errors="strict") else "different"
except UnicodeError:
status = "decode_error"
return dict(expected_codec=expected, top1_exact_codec=bool(names and names[0] == expected),
top1_decode_status=status, expected_candidate_ranks=ranks,
top1_language_match=bool(candidates and candidates[0]["language"] == language))


def verified_build(directory, training):
directory = Path(directory).resolve(strict=True)
provenance = json.loads((directory / "provenance.json").read_text(encoding="utf-8"))
if provenance["mode"] != "generated" or provenance["contract_hash"] != training["contract"]["content_hash"]:
raise ValueError("build does not match frozen training contract")
header = (directory / "model.hpp").read_bytes()
if header != emit_cpp(training["contract"]) or digest(header) != provenance["model_header_sha256"]:
raise ValueError("generated build header mismatch")
for path, sha in provenance["dependencies"].items():
if digest(safe_path(engine_probe.BASE, path).read_bytes()) != sha:
raise ValueError("build source revision mismatch")
binaries = {}
for name in ("uchardet-conformance", "uchardet-conformance-experimental"):
binary = directory / "build/benchmark" / name
if digest(binary.read_bytes()) != provenance["binaries"][name]:
raise ValueError("build executable hash mismatch")
binaries[name] = binary
return binaries, provenance


def compare(identity, filtered, manifest, root, split, identity_build, filtered_build):
records, pairs = paired_controls.select_pairs(identity, filtered, manifest, root, split)
if any(len(data) > engine_probe.LIMIT for _, _, data in records):
raise ValueError("paired full-engine evaluation requires every input <=4096 bytes")
inputs = (("identity", identity, identity_build), ("filtered", filtered, filtered_build))
builds = {name: verified_build(directory, training) for name, training, directory in inputs}
documents = []
for source, sample, data in records:
observed = {}
for name, _, _ in inputs:
binaries, _ = builds[name]
baseline = engine_probe.observe(binaries["uchardet-conformance"], data)
if "legacy" in observed and baseline != observed["legacy"]:
raise ValueError("normal target differs between generated builds")
observed["legacy"] = baseline
observed[name] = engine_probe.observe(binaries["uchardet-conformance-experimental"], data)
documents.append(dict(source=source, sample_id=sample["id"],
sample_sha256=sample["sha256"], sample_encoding=sample["encoding"],
byte_length=len(data), observations=observed,
scores={name: score(value, data, sample["encoding"], source["language"])
for name, value in observed.items()}))
for name, training, directory in inputs:
_, final = verified_build(directory, training)
if final != builds[name][1]:
raise ValueError("build changed during evaluation")
summary = {}
for encoding in ("cp1252", "utf-8"):
selected = [row for row in documents if row["sample_encoding"] == encoding]
summary[encoding] = {}
for name in ("legacy", "identity", "filtered"):
summary[encoding][name] = dict(
samples=len(selected), exact_codec=sum(d["scores"][name]["top1_exact_codec"] for d in selected),
language_match=sum(d["scores"][name]["top1_language_match"] for d in selected),
expected_candidate_present=sum(bool(d["scores"][name]["expected_candidate_ranks"]) for d in selected),
decode_status={status: sum(d["scores"][name]["top1_decode_status"] == status for d in selected)
for status in ("equal", "different", "decode_error", "unknown_codec", "no_candidate")},
)
result = dict(schema="small-paired-engine-comparison-v1", corpus_content_hash=manifest["content_hash"],
split=split, scope="one French cp1252 slot; all other engine/model code unchanged",
input_limit=engine_probe.LIMIT, feed="whole/fresh", pairs=pairs, documents=documents,
summary=summary, builds={name: provenance for name, (_, provenance) in builds.items()},
training_hashes={"identity": identity["content_hash"], "filtered": filtered["content_hash"]},
legacy_training_overlap="UNKNOWN", compatible_superset_metric="NOT_EVALUATED",
driver_dependencies={p.name: digest(p.read_bytes()) for p in
(Path(__file__), Path(engine_probe.__file__), Path(paired_controls.__file__))})
result["content_hash"] = content_hash(result)
return result


def main():
parser = argparse.ArgumentParser(description=__doc__)
for name in ("identity", "filtered", "manifest", "identity_build", "filtered_build", "output"):
parser.add_argument(name, type=Path)
parser.add_argument("--split", choices=("tuning", "validation"), default="validation")
args = parser.parse_args()
def load(path):
return json.loads(path.read_text(encoding="utf-8"))
result = compare(load(args.identity), load(args.filtered), load(args.manifest), args.manifest.parent,
args.split, args.identity_build, args.filtered_build)
write_idempotent(args.output, canonical(result))


if __name__ == "__main__":
main()
Loading
Loading