codeccat 0.1.0__cp310-abi3-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codeccat/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """CodecCat public API with lazily loaded opt-in legacy R21 compatibility.
2
+
3
+ Default import/detect/decode never imports NumPy. Legacy symbols preserve the
4
+ previous API when the user explicitly installs codeccat[legacy].
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from .usable import CodecCat, detect, decode
9
+ from .vnext_decoding import Detection
10
+
11
+ __all__ = [
12
+ "CodecCat", "detect", "decode", "Detection",
13
+ "DetectionResult", "Detector", "LinearModel", "RuntimeBundle",
14
+ "RuntimeStack", "load_bundle", "save_bundle",
15
+ ]
16
+
17
+ _LEGACY = {
18
+ "DetectionResult": "api",
19
+ "Detector": "api",
20
+ "LinearModel": "model",
21
+ "RuntimeBundle": "model",
22
+ "RuntimeStack": "model",
23
+ "load_bundle": "model_io",
24
+ "save_bundle": "model_io",
25
+ }
26
+
27
+
28
+ def __getattr__(name: str):
29
+ mod = _LEGACY.get(name)
30
+ if mod is None:
31
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
32
+ try:
33
+ from importlib import import_module
34
+ module = import_module(f".{mod}", __name__)
35
+ except ModuleNotFoundError as exc:
36
+ if exc.name == "numpy":
37
+ raise ImportError(
38
+ f"{name} is a legacy CodecCat API; "
39
+ "install optional compatibility support with "
40
+ "'pip install codeccat[legacy]'"
41
+ ) from exc
42
+ raise
43
+ result = getattr(module, name)
44
+ globals()[name] = result
45
+ return result
codeccat/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
Binary file
codeccat/api.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .model import RuntimeBundle
6
+ from .runtime import Runtime
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class DetectionResult:
11
+ encoding: str | None
12
+ alternatives: tuple[str, ...]
13
+
14
+
15
+ class Detector:
16
+ """CodecCat runtime bound to one immutable fitted model bundle."""
17
+
18
+ def __init__(self, bundle: RuntimeBundle):
19
+ self._runtime = Runtime(bundle)
20
+
21
+ def rank(self, data: bytes) -> tuple[str, ...]:
22
+ if not isinstance(data, (bytes, bytearray, memoryview)):
23
+ raise TypeError("CodecCat expects a bytes-like input")
24
+ return self._runtime.rank(bytes(data))
25
+
26
+ def detect(self, data: bytes) -> DetectionResult:
27
+ ranked = self.rank(data)
28
+ return DetectionResult(
29
+ encoding=ranked[0] if ranked else None,
30
+ alternatives=ranked,
31
+ )
codeccat/cli.py ADDED
@@ -0,0 +1,59 @@
1
+ """CodecCat CLI: detect or strictly decode a local byte stream."""
2
+ from __future__ import annotations
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ import sys
7
+
8
+ MAX_BYTES = 16 * 1024 * 1024
9
+
10
+
11
+ def main(argv: list[str] | None = None) -> int:
12
+ p = argparse.ArgumentParser(description="Detect text encoding with CodecCat's bundled native model")
13
+ p.add_argument("file", nargs="?", default="-", help="input file or - for stdin")
14
+ p.add_argument("--decode", action="store_true", help="write strictly decoded text to stdout")
15
+ p.add_argument("--json", action="store_true", help="write structured detection metadata")
16
+ p.add_argument("--bom-policy", choices=("strip", "preserve"), default="strip")
17
+ args = p.parse_args(argv)
18
+ if args.json and args.decode:
19
+ p.error("--json and --decode are mutually exclusive")
20
+ try:
21
+ if args.file == "-":
22
+ raw = sys.stdin.buffer.read(MAX_BYTES + 1)
23
+ else:
24
+ with Path(args.file).open("rb") as stream:
25
+ raw = stream.read(MAX_BYTES + 1)
26
+ if len(raw) > MAX_BYTES:
27
+ p.error("input exceeds 16 MiB limit; never silently truncate a stream")
28
+ from .usable import detect
29
+ result = detect(raw, bom_policy=args.bom_policy)
30
+ except (OSError, RuntimeError, ValueError) as exc:
31
+ p.exit(2, f"codeccat: {exc}\n")
32
+ if args.decode:
33
+ if result.text is None:
34
+ print("codeccat: unable to strictly decode input", file=sys.stderr)
35
+ return 2
36
+ # Decoded data is emitted as UTF-8 bytes on all platforms.
37
+ # Windows terminal defaults (e.g., cp1252) must not corrupt text.
38
+ sys.stdout.buffer.write(result.text.encode("utf-8", "strict"))
39
+ elif args.json:
40
+ print(json.dumps({
41
+ "encoding": result.encoding,
42
+ "accepted": result.accepted,
43
+ "ambiguous": result.ambiguous,
44
+ "candidates": result.candidates,
45
+ "bom_present": result.bom_present,
46
+ "reason": result.reason,
47
+ }, ensure_ascii=False))
48
+ else:
49
+ if not result.accepted:
50
+ print("unknown")
51
+ return 2
52
+ print(result.encoding)
53
+ if result.ambiguous:
54
+ print("codeccat: encoding label is ambiguous", file=sys.stderr)
55
+ return 0
56
+
57
+
58
+ if __name__ == "__main__":
59
+ raise SystemExit(main())
codeccat/features.py ADDED
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+
7
+ S3_THRESHOLD = 0.02
8
+ BASE_WIDTH = 518
9
+ EXTRA_WIDTH = 368
10
+ FULL_WIDTH = BASE_WIDTH + EXTRA_WIDTH
11
+
12
+ _REDUCE_IDX = np.asarray([32, 127, 128], dtype=np.intp)
13
+
14
+
15
+ def hmt768(data: bytes) -> bytes:
16
+ if len(data) <= 768:
17
+ return data
18
+ middle = len(data) // 2
19
+ return data[:256] + data[middle - 128 : middle + 128] + data[-256:]
20
+
21
+
22
+ def hmt768_array(data: bytes) -> np.ndarray:
23
+ return np.frombuffer(hmt768(data), dtype=np.uint8)
24
+
25
+
26
+ def strict_utf8(data: bytes) -> bool:
27
+ try:
28
+ data[:4096].decode("utf-8", "strict")
29
+ return True
30
+ except UnicodeDecodeError:
31
+ return False
32
+
33
+
34
+ def has_utf8_bom(data: bytes) -> bool:
35
+ return data.startswith(b"\xef\xbb\xbf")
36
+
37
+
38
+ def route(data: bytes) -> str:
39
+ sample = data[:4096]
40
+ if strict_utf8(sample):
41
+ return "U"
42
+ if b"\x00" in sample:
43
+ return "N"
44
+ array = np.frombuffer(sample, dtype=np.uint8)
45
+ high_ratio = float(np.mean(array >= 128)) if len(array) else 0.0
46
+ return "RL" if high_ratio <= S3_THRESHOLD else "RH"
47
+
48
+
49
+ def replacement_rate(data: bytes) -> float:
50
+ if not data:
51
+ return 0.0
52
+ text = data.decode("utf-8", errors="replace")
53
+ return text.count("\ufffd") / max(1, len(text))
54
+
55
+
56
+ def byte_signals(data: bytes) -> tuple[bool, bool, bool, float, float, int]:
57
+ sample = data[:4096]
58
+ array = np.frombuffer(sample, dtype=np.uint8)
59
+ strict = strict_utf8(data)
60
+ bom = has_utf8_bom(data)
61
+ if len(array):
62
+ high_count = int(np.count_nonzero(array >= 128))
63
+ nul_count = int(np.count_nonzero(array == 0))
64
+ high = high_count / len(array)
65
+ nul = nul_count / len(array)
66
+ ascii_only = high_count == 0
67
+ else:
68
+ high = 0.0
69
+ nul = 0.0
70
+ ascii_only = True
71
+ return bom, strict, ascii_only, high, nul, len(array)
72
+
73
+
74
+ def baseline_from_array(array: np.ndarray) -> np.ndarray:
75
+ n = max(1, len(array))
76
+ counts = np.bincount(array, minlength=256)
77
+ out = np.empty(BASE_WIDTH, dtype=np.float32)
78
+ out[:256] = counts
79
+ out[:256] *= 1.0 / n
80
+
81
+ if len(array) >= 2:
82
+ bins = array[:-1] + array[1:]
83
+ bigram_counts = np.bincount(bins, minlength=256)
84
+ out[256:512] = bigram_counts
85
+ out[256:512] *= 1.0 / (len(array) - 1)
86
+ else:
87
+ out[256:512] = 0.0
88
+
89
+ if len(array):
90
+ inv = 1.0 / len(array)
91
+ aggregate = np.add.reduceat(counts, _REDUCE_IDX)
92
+ printable = float(aggregate[0] * inv)
93
+ high = float(aggregate[2] * inv)
94
+ nul = float(counts[0] * inv)
95
+ lf = float(counts[10] * inv)
96
+ cr = float(counts[13] * inv)
97
+ else:
98
+ high = nul = printable = lf = cr = 0.0
99
+
100
+ out[512:] = (
101
+ math.log2(n + 1) / 16.0,
102
+ high,
103
+ nul,
104
+ printable,
105
+ lf,
106
+ cr,
107
+ )
108
+ return out
109
+
110
+
111
+ def full_b_extra_from_array(array: np.ndarray, route_name: str) -> np.ndarray:
112
+ out = np.zeros(EXTRA_WIDTH, dtype=np.float32)
113
+ if route_name not in ("U", "RH"):
114
+ return out
115
+
116
+ if len(array) >= 2:
117
+ left = array[:-1]
118
+ right = array[1:]
119
+ x = left.astype(np.int16, copy=False)
120
+ y = right.astype(np.int16, copy=False)
121
+ diff = ((y - x) & 255).astype(np.intp, copy=False)
122
+ out[:256] = np.bincount(diff, minlength=256).astype(np.float32) / (len(array) - 1)
123
+
124
+ xb = (np.bitwise_xor(left, right) >> 2).astype(np.intp, copy=False)
125
+ out[256:320] = np.bincount(xb, minlength=64).astype(np.float32) / (len(array) - 1)
126
+
127
+ p = 320
128
+ for chunk in (array[:256], array[256:512], array[512:768]):
129
+ high = chunk[chunk >= 128]
130
+ if len(high):
131
+ idx = ((high.astype(np.uint16) - 128) >> 3).astype(np.intp, copy=False)
132
+ out[p : p + 16] = np.bincount(idx, minlength=16).astype(np.float32) / len(high)
133
+ p += 16
134
+ return out
135
+
136
+
137
+ def feature_vector(data: bytes) -> np.ndarray:
138
+ return baseline_from_array(hmt768_array(data))
139
+
140
+
141
+ def full_b_vector(data: bytes, route_name: str | None = None) -> np.ndarray:
142
+ route_name = route_name or route(data)
143
+ array = hmt768_array(data)
144
+ baseline = baseline_from_array(array)
145
+ extra = full_b_extra_from_array(array, route_name)
146
+ return np.concatenate([baseline, extra])
@@ -0,0 +1,78 @@
1
+ """Optional legacy stable23/ordered13 numeric ZIP model adapter.
2
+
3
+ The original format stores JSON metadata plus compressed NumPy arrays, never
4
+ pickle. Loading it requires only the opt-in NumPy dependency. Inference still
5
+ uses the Rust-native L2 scorer; sklearn is not imported.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import json
11
+ import zipfile
12
+ from pathlib import Path
13
+
14
+ SCHEMA_23 = "vnext-l2-tfidf-ordered23-sgd-huber-v1"
15
+ SCHEMA_13 = "vnext-l2-tfidf-ordered13-sgd-huber-v2"
16
+
17
+
18
+ def load_legacy_zip(path: str | Path):
19
+ try:
20
+ import numpy as np
21
+ except ImportError as exc:
22
+ raise ImportError(
23
+ "Legacy CodecCat .zip models require optional NumPy support; "
24
+ "install 'codeccat[legacy]' or export your model to .ccm."
25
+ ) from exc
26
+ from ._codeccat_native import NativeL2
27
+ from .vnext_strong_l2 import TrainableDetector
28
+
29
+ with zipfile.ZipFile(path, "r") as archive:
30
+ names = archive.namelist()
31
+ if sorted(names) != ["manifest.json", "weights.npz"]:
32
+ raise ValueError("unexpected or duplicated model archive entries")
33
+ manifest_info = archive.getinfo("manifest.json")
34
+ tensor_info = archive.getinfo("weights.npz")
35
+ if (manifest_info.file_size > 8_000_000 or
36
+ tensor_info.file_size > 200_000_000):
37
+ raise ValueError("legacy archive sections exceed maximum size")
38
+ manifest = json.loads(archive.read("manifest.json"))
39
+ schema = manifest.get("schema")
40
+ expected_range = {
41
+ SCHEMA_23: [2, 3],
42
+ SCHEMA_13: [1, 3],
43
+ }.get(schema)
44
+ if expected_range is None:
45
+ raise ValueError("unsupported legacy model schema")
46
+ if manifest.get("params") != {
47
+ "ngram_range": expected_range,
48
+ "sublinear_tf": True,
49
+ "lowercase": True,
50
+ "norm": "l2",
51
+ }:
52
+ raise ValueError("unsupported legacy feature transform")
53
+
54
+ with np.load(io.BytesIO(archive.read("weights.npz")),
55
+ allow_pickle=False) as tensors:
56
+ if set(tensors.files) != {"idf", "coef", "intercept"}:
57
+ raise ValueError("unexpected numeric tensor keys")
58
+ idf = tensors["idf"].copy()
59
+ coef = tensors["coef"].copy()
60
+ intercept = tensors["intercept"].copy()
61
+
62
+ vocab = manifest.get("vocabulary")
63
+ classes = manifest.get("classes")
64
+ fingerprint = manifest.get("fingerprint")
65
+ if (not isinstance(vocab, dict) or not vocab or
66
+ not isinstance(classes, list) or len(classes) < 2 or
67
+ not all(isinstance(c, str) for c in classes) or
68
+ not isinstance(fingerprint, str) or len(fingerprint) != 64 or
69
+ sorted(vocab.values()) != list(range(len(vocab))) or
70
+ idf.shape != (len(vocab),) or
71
+ coef.shape != (1 if len(classes) == 2 else len(classes), len(vocab)) or
72
+ intercept.shape != (coef.shape[0],) or
73
+ not np.isfinite(idf).all() or not np.isfinite(coef).all() or
74
+ not np.isfinite(intercept).all()):
75
+ raise ValueError("invalid legacy model metadata or weights")
76
+ native = NativeL2(vocab, idf.tolist(), coef.tolist(), intercept.tolist(),
77
+ expected_range[0], expected_range[1], False, True)
78
+ return TrainableDetector(native, fingerprint, classes=classes)
codeccat/model.py ADDED
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Mapping
5
+
6
+ import numpy as np
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class LinearModel:
11
+ classes: tuple[str, ...]
12
+ weight: np.ndarray
13
+ bias: np.ndarray
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class RuntimeStack:
18
+ route_models: Mapping[str, LinearModel]
19
+ calibrator: LinearModel | None
20
+ triad: LinearModel | None
21
+ utf8_sig: LinearModel | None
22
+ utf8_gb: LinearModel | None
23
+
24
+ def __post_init__(self) -> None:
25
+ missing = {"U", "N", "RL", "RH"} - set(self.route_models)
26
+ if missing:
27
+ raise ValueError(f"missing route models: {sorted(missing)}")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class RuntimeBundle:
32
+ baseline: RuntimeStack
33
+ full_b: RuntimeStack | None
34
+ rl_specialist: LinearModel | None
35
+ gate_thresholds: Mapping[str, float]
36
+ classes: tuple[str, ...]
37
+ families: tuple[str, ...]
38
+ r12_threshold: float = 0.65
39
+
40
+ def __post_init__(self) -> None:
41
+ if self.full_b is not None:
42
+ for route in ("U", "RH"):
43
+ if route not in self.full_b.route_models:
44
+ raise ValueError(f"full-B stack missing route: {route}")
45
+ for route in self.gate_thresholds:
46
+ if route not in ("U", "RH"):
47
+ raise ValueError(f"unsupported gate route: {route}")
48
+
49
+ @property
50
+ def route_models(self) -> Mapping[str, LinearModel]:
51
+ """Compatibility alias for the baseline route models."""
52
+ return self.baseline.route_models
53
+
54
+ @property
55
+ def calibrator(self) -> LinearModel | None:
56
+ return self.baseline.calibrator
57
+
58
+ @property
59
+ def triad(self) -> LinearModel | None:
60
+ return self.baseline.triad
61
+
62
+ @property
63
+ def utf8_sig(self) -> LinearModel | None:
64
+ return self.baseline.utf8_sig
65
+
66
+ @property
67
+ def utf8_gb(self) -> LinearModel | None:
68
+ return self.baseline.utf8_gb
codeccat/model_io.py ADDED
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+
8
+ from .model import LinearModel, RuntimeBundle, RuntimeStack
9
+
10
+
11
+ def _put_model(arrays: dict[str, np.ndarray], meta: dict, prefix: str, model: LinearModel | None) -> None:
12
+ if model is None:
13
+ meta[prefix] = None
14
+ return
15
+ meta[prefix] = {"classes": list(model.classes)}
16
+ arrays[f"{prefix}.weight"] = np.asarray(model.weight, dtype=np.float64)
17
+ arrays[f"{prefix}.bias"] = np.asarray(model.bias, dtype=np.float64)
18
+
19
+
20
+ def _get_model(data, meta: dict, prefix: str) -> LinearModel | None:
21
+ info = meta[prefix]
22
+ if info is None:
23
+ return None
24
+ return LinearModel(
25
+ classes=tuple(info["classes"]),
26
+ weight=np.asarray(data[f"{prefix}.weight"], dtype=np.float64),
27
+ bias=np.asarray(data[f"{prefix}.bias"], dtype=np.float64),
28
+ )
29
+
30
+
31
+ def _put_stack(arrays: dict[str, np.ndarray], meta: dict, prefix: str, stack: RuntimeStack | None) -> None:
32
+ if stack is None:
33
+ meta[prefix] = None
34
+ return
35
+ meta[prefix] = {"routes": list(stack.route_models)}
36
+ for route, model in stack.route_models.items():
37
+ _put_model(arrays, meta, f"{prefix}.route.{route}", model)
38
+ _put_model(arrays, meta, f"{prefix}.calibrator", stack.calibrator)
39
+ _put_model(arrays, meta, f"{prefix}.triad", stack.triad)
40
+ _put_model(arrays, meta, f"{prefix}.utf8_sig", stack.utf8_sig)
41
+ _put_model(arrays, meta, f"{prefix}.utf8_gb", stack.utf8_gb)
42
+
43
+
44
+ def _get_stack(data, meta: dict, prefix: str) -> RuntimeStack | None:
45
+ info = meta[prefix]
46
+ if info is None:
47
+ return None
48
+ route_models = {
49
+ route: _get_model(data, meta, f"{prefix}.route.{route}")
50
+ for route in info["routes"]
51
+ }
52
+ if any(model is None for model in route_models.values()):
53
+ raise ValueError(f"{prefix} route model cannot be null")
54
+ return RuntimeStack(
55
+ route_models=route_models, # type: ignore[arg-type]
56
+ calibrator=_get_model(data, meta, f"{prefix}.calibrator"),
57
+ triad=_get_model(data, meta, f"{prefix}.triad"),
58
+ utf8_sig=_get_model(data, meta, f"{prefix}.utf8_sig"),
59
+ utf8_gb=_get_model(data, meta, f"{prefix}.utf8_gb"),
60
+ )
61
+
62
+
63
+ def save_bundle(path: str | Path, bundle: RuntimeBundle) -> None:
64
+ arrays: dict[str, np.ndarray] = {}
65
+ meta = {
66
+ "schema": 2,
67
+ "classes": list(bundle.classes),
68
+ "families": list(bundle.families),
69
+ "gate_thresholds": {k: float(v) for k, v in bundle.gate_thresholds.items()},
70
+ "r12_threshold": float(bundle.r12_threshold),
71
+ }
72
+ _put_stack(arrays, meta, "baseline", bundle.baseline)
73
+ _put_stack(arrays, meta, "full_b", bundle.full_b)
74
+ _put_model(arrays, meta, "rl_specialist", bundle.rl_specialist)
75
+ arrays["__manifest__"] = np.asarray(json.dumps(meta, sort_keys=True))
76
+ np.savez_compressed(Path(path), **arrays)
77
+
78
+
79
+ def _load_schema1(data, meta: dict) -> RuntimeBundle:
80
+ route_models = {
81
+ route: _get_model(data, meta, f"route.{route}")
82
+ for route in meta["routes"]
83
+ }
84
+ if any(model is None for model in route_models.values()):
85
+ raise ValueError("route model cannot be null")
86
+ baseline = RuntimeStack(
87
+ route_models=route_models, # type: ignore[arg-type]
88
+ calibrator=_get_model(data, meta, "calibrator"),
89
+ triad=_get_model(data, meta, "triad"),
90
+ utf8_sig=_get_model(data, meta, "utf8_sig"),
91
+ utf8_gb=_get_model(data, meta, "utf8_gb"),
92
+ )
93
+ return RuntimeBundle(
94
+ baseline=baseline,
95
+ full_b=None,
96
+ rl_specialist=None,
97
+ gate_thresholds={},
98
+ classes=tuple(meta["classes"]),
99
+ families=tuple(meta["families"]),
100
+ )
101
+
102
+
103
+ def load_bundle(path: str | Path) -> RuntimeBundle:
104
+ with np.load(Path(path), allow_pickle=False) as data:
105
+ meta = json.loads(str(data["__manifest__"]))
106
+ schema = meta.get("schema")
107
+ if schema == 1:
108
+ return _load_schema1(data, meta)
109
+ if schema != 2:
110
+ raise ValueError(f"unsupported CodecCat model schema: {schema}")
111
+
112
+ baseline = _get_stack(data, meta, "baseline")
113
+ if baseline is None:
114
+ raise ValueError("baseline stack cannot be null")
115
+ return RuntimeBundle(
116
+ baseline=baseline,
117
+ full_b=_get_stack(data, meta, "full_b"),
118
+ rl_specialist=_get_model(data, meta, "rl_specialist"),
119
+ gate_thresholds={k: float(v) for k, v in meta.get("gate_thresholds", {}).items()},
120
+ classes=tuple(meta["classes"]),
121
+ families=tuple(meta["families"]),
122
+ r12_threshold=float(meta.get("r12_threshold", 0.65)),
123
+ )
Binary file
@@ -0,0 +1 @@
1
+ {"model_sha256": "16073306b5027bf93acc2883f57fbda0cb0cf2d77b9a7bb62cf1b07fcbb9748b", "schema": "codeccat-native-model-provenance-v1", "training_fingerprint": "936eb0331a0d0ac447b0f6c6135bec7cd397962544d053cda3220358dacba85c"}