lmfpd 0.1.0__py3-none-any.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.
lmfpd/__init__.py ADDED
@@ -0,0 +1,94 @@
1
+ """The public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from typing import Any, Mapping, Sequence
8
+
9
+ from . import _sampling
10
+ from ._analysis import SCORING_FAILED, Scorer
11
+ from ._challenge import generate_challenges
12
+ from ._data import DEFAULT_DATA_URLS, load_data
13
+ from ._features import parse_numbers
14
+ from ._types import Analysis, Challenge, DetectResult, LmfpdError, Output, Sample
15
+
16
+
17
+ class Detector:
18
+ """A loaded reference bank and detector. Create one with `load()`."""
19
+
20
+ __slots__ = ("_built_at", "_scorer")
21
+
22
+ def __init__(self, built_at: str, scorer: Scorer):
23
+ self._built_at = built_at
24
+ self._scorer = scorer
25
+
26
+ @property
27
+ def built_at(self) -> str:
28
+ """`built_at` of the reference bank, which identifies the data build."""
29
+ return self._built_at
30
+
31
+ def analyze(self, outputs: Sequence[Output], strict: bool = False) -> Analysis:
32
+ """Scores one to three saved replies. Three valid replies give a calibrated ranking; fewer give a partial
33
+ ranking unless `strict` is set."""
34
+ return self._scorer.analyze(outputs, strict)
35
+
36
+ def detect(self, base_url: str, api_key: str, model: str, *, api: str = "responses", effort: str = "",
37
+ service_tier: str = "default", stream: bool = True, strict: bool = False, count: int = 3,
38
+ parallel: int = 3, timeout: float = 120.0) -> DetectResult:
39
+ """Sends one round of challenges to the model API and scores the replies. Raises only on an invalid
40
+ config; request failures are reported in the samples and `error`."""
41
+ options = _sampling.detect_options({
42
+ "base_url": base_url, "api_key": api_key, "model": model, "api": api, "effort": effort,
43
+ "service_tier": service_tier, "stream": stream, "strict": strict, "count": count, "parallel": parallel,
44
+ "timeout": timeout,
45
+ })
46
+ challenges = generate_challenges(options.count)
47
+ with _sampling.client() as http, ThreadPoolExecutor(options.parallel) as pool:
48
+ samples = list(pool.map(lambda challenge: _sampling.sample(options, challenge, http), challenges))
49
+ accepted = sum(1 for s in samples if s["state"] in ("complete", "truncated"))
50
+ result: DetectResult = {"challenges": challenges, "samples": samples, "analysis": None, "error": None}
51
+ if not accepted or (options.strict and accepted != 3):
52
+ result["error"] = (f"Strict mode requires 3/3 successful samples; received {accepted}/3."
53
+ if options.strict else "No valid samples. This round cannot be scored.")
54
+ return result
55
+ outputs: list[Output] = [{"text": s["text"] if s["state"] in ("complete", "truncated") else "",
56
+ "expected_count": s["expected_count"]} for s in samples]
57
+ try:
58
+ analysis = self._scorer.score(outputs, options.strict)
59
+ except Exception:
60
+ result["error"] = SCORING_FAILED
61
+ return result
62
+ result["analysis"] = analysis
63
+ if not analysis["results"]:
64
+ result["error"] = analysis["evidence"]["reason"]
65
+ return result
66
+
67
+
68
+ def load(source: str | Sequence[str] = DEFAULT_DATA_URLS, cache: str | os.PathLike[str] | None = None,
69
+ ttl: float = 86400.0) -> Detector:
70
+ """Loads the reference data. A single source that is not an http(s) URL is a local directory with
71
+ `unified_bank.json` and `shared_detector.bin`, read as is. URL sources are tried in order behind the cache file
72
+ `cache` (default `<OS cache dir>/lmfpd/data.bin`): a cached copy is returned at once and, when last checked `ttl`
73
+ seconds ago or earlier (0 checks on every load), refreshed in the background for the next load. Without a cache it
74
+ downloads."""
75
+ return Detector(*load_data(source, cache, ttl))
76
+
77
+
78
+ def build_request(config: Mapping[str, Any], prompt: str) -> dict[str, Any]:
79
+ """The HTTP request `detect` sends for one prompt: `{"url", "headers", "body"}`. Raises LmfpdError on an invalid
80
+ config."""
81
+ return _sampling.request_parts(_sampling.detect_options(config), prompt)
82
+
83
+
84
+ def request_sample(config: Mapping[str, Any], challenge: Challenge) -> Sample:
85
+ """Sends one challenge and returns the checked reply. Request failures are reported in `error`."""
86
+ options = _sampling.detect_options(config)
87
+ with _sampling.client() as http:
88
+ return _sampling.sample(options, challenge, http)
89
+
90
+
91
+ __all__ = [
92
+ "DEFAULT_DATA_URLS", "Analysis", "Challenge", "DetectResult", "Detector", "LmfpdError", "Output", "Sample",
93
+ "build_request", "generate_challenges", "load", "parse_numbers", "request_sample",
94
+ ]
lmfpd/_analysis.py ADDED
@@ -0,0 +1,162 @@
1
+ """Analysis in the canonical shape (port of `analyzeSharedOutputs`, `englishAnalysis`, and the SDK preprocessing)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any, Sequence
7
+
8
+ import numpy as np
9
+
10
+ from ._features import parse_numbers, raw_out_of_range_count
11
+ from ._ranker import Ranker
12
+ from ._types import Analysis, LmfpdError, Output
13
+
14
+ SCORING_FAILED = "Scoring failed. Check that the reference bank and detector files are valid."
15
+ INVALID_OUTPUTS = ("Each input round must contain one to three outputs with text and a positive integer "
16
+ "expected_count.")
17
+ _MAX_SAFE_INTEGER = 2 ** 53 - 1
18
+
19
+ # Reference replies tend to have negative lag-1 autocorrelation and few steps of 20 or less. The limits are the
20
+ # 99.5% quantiles over reference three-answer groups; a reply is rejected only when both signals exceed them.
21
+ _AUTOCORRELATION_LIMIT = -0.23
22
+ _SMALL_STEP_LIMIT = 0.05
23
+
24
+
25
+ class Scorer:
26
+ """The ranker plus the bank metadata that names its models."""
27
+
28
+ def __init__(self, bank: dict[str, Any], detector: dict[str, Any]):
29
+ self.ranker = Ranker(detector)
30
+ self.models = [(m.get("id"), m.get("display_name"), m.get("family"), m.get("family_name"))
31
+ for m in bank["models"]]
32
+ self.base_sha256 = detector["base_sha256"]
33
+ calibration = detector.get("calibration")
34
+ self.calibration = None if self.ranker.tau is None else {
35
+ "method": calibration["method"], "run": calibration["calibration_run"], "tau": calibration["tau"],
36
+ "sha256": detector.get("calibration_sha256"),
37
+ }
38
+
39
+ def _results(self, ranking: list[float], probabilities: list[float] | None) -> tuple[list[dict], int]:
40
+ # Stable: ties keep the bank order.
41
+ order = sorted(range(len(ranking)), key=lambda i: -ranking[i])
42
+ results = []
43
+ for i in order:
44
+ model, display_name, family, family_name = self.models[i]
45
+ results.append({"model": model, "display_name": display_name, "family": family,
46
+ "family_name": family_name, "score": ranking[i],
47
+ "probability": None if probabilities is None else probabilities[i]})
48
+ return results, order[0]
49
+
50
+ def score(self, outputs: Sequence[Output], strict: bool) -> Analysis:
51
+ """Scores outputs as given, like `analyzeSharedOutputs` followed by `englishAnalysis`."""
52
+ parsed = [parse_numbers(o["text"]) for o in outputs]
53
+ diagnostics = []
54
+ for index, (output, numbers) in enumerate(zip(outputs, parsed)):
55
+ minimum = max(80, math.ceil((output["expected_count"] or 0) * 0.55))
56
+ diagnostics.append({"index": index, "parsed_numbers": len(numbers), "minimum_numbers": minimum,
57
+ "accepted": len(numbers) >= minimum,
58
+ "raw_out_of_range_count": raw_out_of_range_count(output["text"])})
59
+ accepted = [numbers for numbers, d in zip(parsed, diagnostics) if d["accepted"]]
60
+ used = len(accepted)
61
+ sequence = _sequence_shape(accepted) if used else None
62
+ analysis: dict[str, Any] = {
63
+ "decision": "scored", "prediction": "", "prediction_name": "", "family_prediction": "",
64
+ "family_prediction_name": "", "results": [], "ranking_score": None, "probability": None,
65
+ "probability_status": "unavailable", "probability_scope": None, "calibration": None,
66
+ "used_outputs": used, "diagnostics": diagnostics, "sequence": sequence, "method": "shared-detector-v2",
67
+ "model_version": {"base_sha256": self.base_sha256},
68
+ }
69
+ not_model = {"decision": "not_model", "prediction_name": "Not a model reply", "evidence": _evidence(
70
+ "Not like a language model reply",
71
+ "Consecutive numbers change differently from the language-model replies in the reference bank. "
72
+ "Program-generated, sorted or shuffled numbers often do this. No ranking was produced.",
73
+ "sequence-shape")}
74
+ if not strict and 0 < used < 3 and len(outputs) <= 3:
75
+ if not sequence["model_like"]:
76
+ return {**analysis, **not_model}
77
+ ranking = self.ranker.rank(accepted).tolist()
78
+ results, first = self._results(ranking, None)
79
+ _, _, family, family_name = self.models[first]
80
+ return {**analysis, "method": "shared-ranker-partial-v1", "decision": "partial",
81
+ "prediction": results[0]["model"], "prediction_name": results[0]["display_name"],
82
+ "family_prediction": family, "family_prediction_name": family_name, "results": results,
83
+ "ranking_score": ranking[first], "evidence": _evidence(
84
+ "Partial sample ranking",
85
+ f"Ranked {used} valid sample{'' if used == 1 else 's'}. "
86
+ "Confidence requires three valid samples.",
87
+ "partial-sample-ranking")}
88
+ if len(outputs) != 3 or used != 3:
89
+ return {**analysis, "decision": "unscorable", "prediction_name": "Not scored", "evidence": _evidence(
90
+ "Not enough valid samples",
91
+ f"Received {used}/3 valid samples. Three valid samples are required.",
92
+ "complete-three-answers")}
93
+ if not sequence["model_like"]:
94
+ return {**analysis, **not_model}
95
+ scores = self.ranker.rank(parsed)
96
+ calibrated_scores = self.ranker.calibrate(scores)
97
+ ranking = scores.tolist()
98
+ probabilities = None if calibrated_scores is None else calibrated_scores.tolist()
99
+ results, first = self._results(ranking, probabilities)
100
+ _, _, family, family_name = self.models[first]
101
+ calibrated = probabilities is not None
102
+ return {**analysis, "prediction": results[0]["model"], "prediction_name": results[0]["display_name"],
103
+ "family_prediction": family, "family_prediction_name": family_name, "results": results,
104
+ "ranking_score": ranking[first], "probability": results[0]["probability"],
105
+ "probability_status": "reference_calibrated" if calibrated else "unavailable",
106
+ "probability_scope": "reference-closed-set" if calibrated else None,
107
+ "calibration": dict(self.calibration) if calibrated else None,
108
+ "evidence": _evidence(
109
+ "Calibrated ranking",
110
+ "Confidence is a closed-set probability within the reference bank.",
111
+ "reference-calibrated-ranking") if calibrated else _evidence(
112
+ "Uncalibrated ranking",
113
+ "The confidence calibration does not match this detector. Confidence is unavailable.",
114
+ "uncalibrated-ranking")}
115
+
116
+ def analyze(self, outputs: Sequence[Output], strict: bool) -> Analysis:
117
+ """Scores saved replies like `fpd --input`: extra numbers are cut in relaxed mode, short replies count as
118
+ missing."""
119
+ if (not isinstance(outputs, (list, tuple)) or not 1 <= len(outputs) <= 3
120
+ or not all(_valid_output(item) for item in outputs)):
121
+ raise LmfpdError(INVALID_OUTPUTS)
122
+ prepared = []
123
+ for item in outputs:
124
+ text, expected = item["text"], item["expected_count"]
125
+ numbers = parse_numbers(text)
126
+ if not strict and len(numbers) > expected:
127
+ text = ", ".join(map(str, numbers[:expected]))
128
+ if len(numbers) < minimum_numbers(expected):
129
+ text = ""
130
+ prepared.append({"text": text, "expected_count": expected})
131
+ try:
132
+ return self.score(prepared, strict)
133
+ except Exception:
134
+ raise LmfpdError(SCORING_FAILED) from None
135
+
136
+
137
+ def minimum_numbers(expected: int) -> int:
138
+ return max(80, math.ceil(expected * 0.55))
139
+
140
+
141
+ def _valid_output(item: Any) -> bool:
142
+ if not isinstance(item, dict) or not isinstance(item.get("text"), str):
143
+ return False
144
+ expected = item.get("expected_count")
145
+ return isinstance(expected, int) and not isinstance(expected, bool) and 0 < expected <= _MAX_SAFE_INTEGER
146
+
147
+
148
+ def _evidence(label: str, reason: str, method: str) -> dict[str, Any]:
149
+ return {"insufficient": True, "label": label, "reason": reason, "threshold": None, "method": method}
150
+
151
+
152
+ def _sequence_shape(replies: Sequence[Sequence[int]]) -> dict[str, Any]:
153
+ autocorrelations, small_steps = [], []
154
+ for reply in replies:
155
+ n = np.asarray(reply, dtype=np.float64)
156
+ centered = n - n.mean()
157
+ variance = (centered * centered).mean()
158
+ autocorrelations.append((centered[1:] * centered[:-1]).mean() / variance if variance else 1.0)
159
+ small_steps.append(np.mean(np.abs(np.diff(n)) <= 20))
160
+ autocorrelation, small_step_share = float(np.median(autocorrelations)), float(np.median(small_steps))
161
+ return {"autocorrelation": autocorrelation, "small_step_share": small_step_share,
162
+ "model_like": autocorrelation <= _AUTOCORRELATION_LIMIT or small_step_share <= _SMALL_STEP_LIMIT}
lmfpd/_challenge.py ADDED
@@ -0,0 +1,38 @@
1
+ """Challenge prompts (port of shared/challenge-browser.js `generateChallenges`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import secrets
7
+ import uuid
8
+ from functools import lru_cache
9
+ from importlib.resources import files
10
+ from typing import Any, Sequence
11
+
12
+ from ._types import Challenge, LmfpdError
13
+
14
+
15
+ @lru_cache(maxsize=1)
16
+ def _languages() -> list[dict[str, Any]]:
17
+ return json.loads(files("lmfpd").joinpath("templates.json").read_text("utf-8"))["languages"]
18
+
19
+
20
+ def _choose(values: Sequence[Any]) -> Any:
21
+ return values[secrets.randbelow(len(values))]
22
+
23
+
24
+ def generate_challenges(count: int = 3) -> list[Challenge]:
25
+ """Random challenges in one of five languages, each asking for a distinct count from 292 to 332."""
26
+ if not isinstance(count, int) or isinstance(count, bool) or not 1 <= count <= 3:
27
+ raise LmfpdError("count must be an integer from 1 to 3.")
28
+ available = list(range(292, 333))
29
+ lengths = [available.pop(secrets.randbelow(len(available))) for _ in range(count)]
30
+ challenges: list[Challenge] = []
31
+ for index, length in enumerate(lengths):
32
+ language = _choose(_languages())
33
+ request = language["request"].replace("{action}", _choose(language["actions"])).replace("{count}", str(length))
34
+ prompt = language["space"].join([_choose(language["openings"]), request, language["rules"],
35
+ _choose(language["endings"]), _choose(language["separators"]),
36
+ language["start"]])
37
+ challenges.append({"id": f"probe-{index + 1}-{uuid.uuid4()}", "expected_count": length, "prompt": prompt})
38
+ return challenges
lmfpd/_codec.py ADDED
@@ -0,0 +1,30 @@
1
+ """Decoder for `shared_detector.bin` (port of shared/detector-codec.ts `decodeDetector`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import struct
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+
11
+
12
+ def decode_detector(data: bytes) -> dict[str, Any]:
13
+ """Layout: u32 LE header length, JSON header, padding to 4 bytes, then float32 values stored byte-plane by
14
+ byte-plane (all first bytes, then all second bytes, ...). Header arrays are `{"$f32": offset, "n": length}` and
15
+ decode to float64 NumPy views of one shared array."""
16
+ (header_length,) = struct.unpack_from("<I", data, 0)
17
+ start = (4 + header_length + 3) // 4 * 4
18
+ size = len(data) - start
19
+ if size < 0 or size % 4:
20
+ raise ValueError("Truncated detector data.")
21
+ planes = np.frombuffer(data, dtype=np.uint8, count=size, offset=start).reshape(4, size // 4)
22
+ floats = np.ascontiguousarray(planes.T).view("<f4").reshape(-1).astype(np.float64)
23
+
24
+ def expand(value: dict[str, Any]) -> Any:
25
+ if "$f32" in value:
26
+ offset = value["$f32"]
27
+ return floats[offset:offset + value["n"]]
28
+ return value
29
+
30
+ return json.loads(bytes(data[4:4 + header_length]).decode("utf-8"), object_hook=expand)
lmfpd/_data.py ADDED
@@ -0,0 +1,183 @@
1
+ """Reference data: a local directory, or the published data behind a stale-while-revalidate cache file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import secrets
9
+ import struct
10
+ import sys
11
+ import threading
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any, Sequence, cast
15
+
16
+ import httpx
17
+ import numpy as np
18
+ import zstandard
19
+
20
+ from ._analysis import Scorer
21
+ from ._codec import decode_detector
22
+ from ._types import LmfpdError
23
+
24
+ #: Data sources, tried in order. The mirror is JSDMirror, a jsDelivr mirror in mainland China, serving the
25
+ #: `lmfpd-data` npm package with the same layout as the site's `/data/`.
26
+ DEFAULT_DATA_URLS: tuple[str, ...] = (
27
+ "https://lm.ikale.io/data/",
28
+ "https://cdn.jsdmirror.com/npm/lmfpd-data@latest/",
29
+ )
30
+ # Bump when the bundle layout changes; older SDKs then refuse the data instead of misreading it.
31
+ _DATA_SCHEMA = "lmfpd-data-v1"
32
+ _DETECTOR_SCHEMA = "shared-detector-v2"
33
+ _DETECTOR = "shared_detector.bin"
34
+ _URL = re.compile(r"https?://", re.IGNORECASE)
35
+
36
+ Data = tuple[str, Scorer]
37
+
38
+
39
+ def default_cache() -> Path:
40
+ home = Path.home()
41
+ if os.environ.get("XDG_CACHE_HOME"):
42
+ root = Path(os.environ["XDG_CACHE_HOME"])
43
+ elif sys.platform == "darwin":
44
+ root = home / "Library" / "Caches"
45
+ elif sys.platform == "win32":
46
+ root = Path(os.environ.get("LOCALAPPDATA") or home / "AppData" / "Local")
47
+ else:
48
+ root = home / ".cache"
49
+ return root / "lmfpd" / "data.bin"
50
+
51
+
52
+ def _supports(bank: Any, detector: Any) -> bool:
53
+ models = bank.get("models") if isinstance(bank, dict) else None
54
+ if not isinstance(models, list) or not isinstance(detector, dict):
55
+ return False
56
+ ids, counts = detector.get("model_ids"), detector.get("response_counts")
57
+ if (detector.get("schema") != _DETECTOR_SCHEMA or bank.get("built_at") != detector.get("bank_built_at")
58
+ or not isinstance(ids, list) or not isinstance(counts, (list, np.ndarray))
59
+ or len(models) != len(ids) or len(counts) < len(ids)):
60
+ return False
61
+ for model, model_id, count in zip(models, ids, counts):
62
+ response_count = model.get("response_count") if isinstance(model, dict) else None
63
+ if (not isinstance(model, dict) or model.get("id") != model_id or isinstance(response_count, bool)
64
+ or response_count != count):
65
+ return False
66
+ return True
67
+
68
+
69
+ def _reference_data(bank: Any, detector: Any) -> Data:
70
+ """Scoring reads only `built_at` and the model names and counts from the bank. `bundle.json` repeats them at its
71
+ top level, so it can stand in for `unified_bank.json`."""
72
+ if not _supports(bank, detector):
73
+ raise LmfpdError("The reference bank does not match its detector.")
74
+ return bank["built_at"], Scorer(bank, detector)
75
+
76
+
77
+ def _read_dir(directory: str) -> Data:
78
+ """Reads `unified_bank.json` and `shared_detector.bin` from a directory such as `projects/data`."""
79
+ parsed = []
80
+ for name, parse in (("unified_bank.json", json.loads), (_DETECTOR, decode_detector)):
81
+ path = os.path.join(directory, name)
82
+ try:
83
+ parsed.append(parse(Path(path).read_bytes()))
84
+ except Exception:
85
+ raise LmfpdError(f"Cannot read {path}.") from None
86
+ return _reference_data(*parsed)
87
+
88
+
89
+ def _read_cache(path: Path) -> tuple[Data, str, float] | None:
90
+ """The cache file holds the u32 little-endian length of `bundle.json`, `bundle.json`, then `shared_detector.bin`.
91
+ Returns the data, its version, and the last check time; None when the cache is missing or damaged."""
92
+ try:
93
+ checked_at = path.stat().st_mtime
94
+ content = path.read_bytes()
95
+ (length,) = struct.unpack_from("<I", content, 0)
96
+ bundle = json.loads(content[4:4 + length])
97
+ data = _reference_data(bundle, decode_detector(content[4 + length:]))
98
+ return data, bundle["files"][_DETECTOR]["sha256"], checked_at
99
+ except Exception:
100
+ return None
101
+
102
+
103
+ def _write_cache(path: Path, bundle: bytes, detector: bytes) -> None:
104
+ """Best effort: a failed write only means the next load downloads again."""
105
+ staging = path.with_name(f"{path.name}.tmp-{secrets.token_hex(8)}")
106
+ try:
107
+ path.parent.mkdir(parents=True, exist_ok=True)
108
+ staging.write_bytes(struct.pack("<I", len(bundle)) + bundle + detector)
109
+ os.replace(staging, path)
110
+ except OSError:
111
+ try:
112
+ staging.unlink()
113
+ except OSError:
114
+ pass
115
+
116
+
117
+ def _download(client: httpx.Client, url: str, seconds: float) -> bytes:
118
+ deadline = time.monotonic() + seconds
119
+ with client.stream("GET", url, timeout=seconds) as response:
120
+ if not 200 <= response.status_code < 300:
121
+ raise LmfpdError(f"HTTP {response.status_code}")
122
+ body = bytearray()
123
+ for chunk in response.iter_bytes():
124
+ body += chunk
125
+ if time.monotonic() > deadline:
126
+ raise LmfpdError(f"Timed out after {seconds:g}s.")
127
+ return bytes(body)
128
+
129
+
130
+ def _refresh(sources: Sequence[str], cache: Path, cached_version: str | None = None) -> Data | None:
131
+ """Downloads the data from the first working source and caches it. Returns None when the source still serves
132
+ `cached_version`, after marking the cache as checked."""
133
+ failures = []
134
+ with httpx.Client(follow_redirects=True, trust_env=True) as client:
135
+ for source in sources:
136
+ try:
137
+ bundle_bytes = _download(client, f"{source}bundle.json", 10)
138
+ bundle = json.loads(bundle_bytes)
139
+ if bundle["schema"] != _DATA_SCHEMA or bundle["detector_schema"] != _DETECTOR_SCHEMA:
140
+ raise LmfpdError(f"data schema {bundle['schema']}/{bundle['detector_schema']} is not supported "
141
+ "by this SDK version. Upgrade lmfpd.")
142
+ # The detector's SHA-256 names its version.
143
+ file = bundle["files"][_DETECTOR]
144
+ if file["sha256"] == cached_version:
145
+ try:
146
+ os.utime(cache)
147
+ except OSError:
148
+ pass
149
+ return None
150
+ compressed = b"".join(_download(client, f"{source}chunks/{part}", 120) for part in file["parts"])
151
+ detector_bytes = zstandard.ZstdDecompressor().stream_reader(compressed,
152
+ read_across_frames=True).read()
153
+ data = _reference_data(bundle, decode_detector(detector_bytes))
154
+ _write_cache(cache, bundle_bytes, detector_bytes)
155
+ return data
156
+ except Exception as error:
157
+ failures.append(f"{source} ({str(error) or type(error).__name__})")
158
+ raise LmfpdError(f"lmfpd: cannot download reference data from {', '.join(failures)}")
159
+
160
+
161
+ def load_data(source: str | Sequence[str], cache: str | os.PathLike[str] | None, ttl: float) -> Data:
162
+ """A single source that is not an http(s) URL is a local directory, read as is. For URLs, answers from the cache
163
+ file when there is one (stale-while-revalidate): a cache last checked `ttl` seconds ago or earlier is refreshed
164
+ in the background for the next load. Without a cache it downloads."""
165
+ sources = [source] if isinstance(source, str) else list(source)
166
+ if len(sources) == 1 and not _URL.match(sources[0]):
167
+ return _read_dir(sources[0])
168
+ path = Path(cache) if cache is not None else default_cache()
169
+ cached = _read_cache(path)
170
+ if cached is None:
171
+ # Without a cached version a successful refresh always returns data.
172
+ return cast(Data, _refresh(sources, path))
173
+ data, version, checked_at = cached
174
+ if time.time() - checked_at >= ttl:
175
+ threading.Thread(target=_refresh_quietly, args=(sources, path, version), daemon=True).start()
176
+ return data
177
+
178
+
179
+ def _refresh_quietly(sources: Sequence[str], cache: Path, version: str) -> None:
180
+ try:
181
+ _refresh(sources, cache, version)
182
+ except Exception:
183
+ pass
lmfpd/_features.py ADDED
@@ -0,0 +1,142 @@
1
+ """Number parsing and feature blocks (port of shared/fingerprint-core.js and shared/positional.ts)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from functools import lru_cache
7
+ from typing import Sequence
8
+
9
+ import numpy as np
10
+ from numpy.lib.stride_tricks import sliding_window_view
11
+
12
+ VALUE_MIN = 1
13
+ VALUE_MAX = 355
14
+ DIMENSION = VALUE_MAX - VALUE_MIN + 1
15
+ ALPHA = 0.5
16
+
17
+ # ASCII digits only: `\d` is Unicode in Python `re`.
18
+ _DIGITS = re.compile(r"[0-9]+")
19
+ _SIGNED = re.compile(r"-?[0-9]+")
20
+
21
+
22
+ def _value(digits: str) -> int:
23
+ """The numeric value of a digit run, or -1 when it has more than three significant digits."""
24
+ if len(digits) > 3:
25
+ digits = digits.lstrip("0")
26
+ if len(digits) > 3:
27
+ return -1
28
+ return int(digits) if digits else 0
29
+
30
+
31
+ @lru_cache(maxsize=4096)
32
+ def _has_letter(separator: str) -> bool:
33
+ # Replies reuse a few separators, so the per-character test is cached.
34
+ return any(c.isalpha() for c in separator)
35
+
36
+
37
+ def parse_numbers(text: str) -> list[int]:
38
+ """The numbers 1 to 355 of the longest run in a reply; letters between numbers split runs."""
39
+ text = str(text)
40
+ best: list[int] = []
41
+ current: list[int] = []
42
+ previous_end = 0
43
+ for match in _DIGITS.finditer(text):
44
+ start = match.start()
45
+ if current and start > previous_end and _has_letter(text[previous_end:start]):
46
+ if len(current) > len(best):
47
+ best = current
48
+ current = []
49
+ value = _value(match.group())
50
+ if VALUE_MIN <= value <= VALUE_MAX:
51
+ current.append(value)
52
+ previous_end = match.end()
53
+ return current if len(current) > len(best) else best
54
+
55
+
56
+ def raw_out_of_range_count(text: str) -> int:
57
+ count = 0
58
+ for match in _SIGNED.finditer(text):
59
+ digits = match.group()
60
+ if digits[0] == "-":
61
+ count += 1
62
+ else:
63
+ value = _value(digits)
64
+ if value == 0 or value < 0 or value > VALUE_MAX:
65
+ count += 1
66
+ return count
67
+
68
+
69
+ Vector = np.ndarray
70
+
71
+
72
+ def hellinger_feature(numbers: np.ndarray) -> Vector:
73
+ """Smoothed Hellinger embedding of the value counts of `numbers` (an int array of values 1 to 355)."""
74
+ counts = np.bincount(numbers - VALUE_MIN, minlength=DIMENSION)
75
+ return np.sqrt((counts + ALPHA) / (len(numbers) + ALPHA * DIMENSION))
76
+
77
+
78
+ def ordered_block_feature(numbers: np.ndarray) -> Vector:
79
+ """Value histograms of the four consecutive quarters, then the last-digit histogram, each Hellinger-scaled."""
80
+ base, remainder = divmod(len(numbers), 4)
81
+ bins = np.minimum(15, np.floor((numbers - 1) / 355 * 16).astype(np.intp))
82
+ pieces = []
83
+ start = 0
84
+ for index in range(4):
85
+ size = base + (1 if index < remainder else 0)
86
+ counts = np.bincount(bins[start:start + size], minlength=16) + 0.5
87
+ pieces.append(np.sqrt(counts / counts.sum()))
88
+ start += size
89
+ digits = np.bincount(numbers % 10, minlength=10) + 0.5
90
+ pieces.append(np.sqrt(digits / digits.sum()))
91
+ return np.concatenate(pieces)
92
+
93
+
94
+ # Positional features. Block order is part of the detector format.
95
+ POSITIONAL_WEIGHTS = (0.6, 0.15, 0.125, 0.125)
96
+ _CENTRES = 1 + np.arange(24) * 354 / 23
97
+ _LAGS = (1, 2, 3, 5, 8, 13, 21, 34)
98
+
99
+
100
+ def _position_kernel(x: np.ndarray) -> Vector:
101
+ """Per feature (24 value kernels, 10 last-digit indicators), its mean product with the first three Legendre
102
+ polynomials of the position."""
103
+ n = len(x)
104
+ t = (np.arange(n) + 0.5) * 2 / n - 1
105
+ basis = np.stack([t, 0.5 * (3 * t * t - 1), 0.5 * (5 * t ** 3 - 3 * t)], axis=1)
106
+ kernels = np.exp(-0.5 * ((x[:, None] - _CENTRES) / 20) ** 2)
107
+ digits = (x[:, None] % 10 == np.arange(10)).astype(np.float64)
108
+ return (np.concatenate([kernels, digits], axis=1).T @ basis / n).reshape(-1)
109
+
110
+
111
+ def _centred_transitions(symbols: np.ndarray, bins: int) -> Vector:
112
+ joint = np.bincount(symbols[:-1] * bins + symbols[1:], minlength=bins * bins) + 0.5
113
+ joint = (joint / (len(symbols) - 1 + 0.5 * bins * bins)).reshape(bins, bins)
114
+ return (joint - np.outer(joint.sum(axis=1), joint.sum(axis=0))).reshape(-1)
115
+
116
+
117
+ def _sequence_features(x: np.ndarray) -> Vector:
118
+ n = len(x)
119
+ m = x.mean()
120
+ s = (x - m) / max(np.sqrt(((x - m) ** 2).mean()), 1)
121
+ out = []
122
+ for lag in _LAGS:
123
+ d = x[lag:] - x[:-lag]
124
+ magnitude = np.abs(d)
125
+ out += (np.mean(s[lag:] * s[:-lag]), magnitude.mean() / 355, np.mean(d > 0), np.mean(d == 0),
126
+ np.mean(magnitude <= 5), np.mean(magnitude <= 20))
127
+ transitions = [_centred_transitions(np.minimum(7, (x - 1) * 8 // 355), 8), _centred_transitions(x % 10, 10)]
128
+ difference = np.diff(x)
129
+ z_step = np.abs(np.diff(s))
130
+ out += (np.mean(np.sign(difference[1:]) == np.sign(difference[:-1])), np.mean(difference[1:] == difference[:-1]),
131
+ np.mean(x[:-1] % 10 == x[1:] % 10), np.mean(z_step > 1), np.mean(z_step > 2))
132
+ buckets = np.minimum(15, (x - 1) * 16 // 355)
133
+ for width in (4, 8, 16):
134
+ windows = np.sort(sliding_window_view(buckets, width), axis=1)
135
+ distinct = 1 + np.count_nonzero(np.diff(windows, axis=1), axis=1)
136
+ out.append(np.mean(distinct / width))
137
+ return np.concatenate([np.array(out[:48]), *transitions, np.array(out[48:])])
138
+
139
+
140
+ def positional_features(numbers: np.ndarray) -> list[Vector]:
141
+ return [hellinger_feature(numbers), ordered_block_feature(numbers), _position_kernel(numbers),
142
+ _sequence_features(numbers)]