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 +94 -0
- lmfpd/_analysis.py +162 -0
- lmfpd/_challenge.py +38 -0
- lmfpd/_codec.py +30 -0
- lmfpd/_data.py +183 -0
- lmfpd/_features.py +142 -0
- lmfpd/_ranker.py +154 -0
- lmfpd/_sampling.py +468 -0
- lmfpd/_types.py +106 -0
- lmfpd/py.typed +0 -0
- lmfpd/templates.json +174 -0
- lmfpd-0.1.0.dist-info/METADATA +76 -0
- lmfpd-0.1.0.dist-info/RECORD +14 -0
- lmfpd-0.1.0.dist-info/WHEEL +4 -0
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)]
|