lmfpd 0.1.0__tar.gz

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-0.1.0/.gitignore ADDED
@@ -0,0 +1,27 @@
1
+ node_modules/
2
+ .DS_Store
3
+ dist/
4
+ target/
5
+ .venv/
6
+ web/public/data/
7
+ web/.generated/
8
+ *.tsbuildinfo
9
+ *.log
10
+ .vercel/
11
+ .wrangler/
12
+ .dev.vars
13
+ .dev.vars.*
14
+ .env
15
+ .env.*
16
+ *.lock/
17
+ data/.pending-enrollment/
18
+ data/.enrollments/
19
+ data/.training/
20
+ runs/
21
+ research/
22
+ docs/build/
23
+ docs/.react-router/
24
+ docs/.source/
25
+ # Python research tools can import historical product code.
26
+ __pycache__/
27
+ *.pyc
lmfpd-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.5
2
+ Name: lmfpd
3
+ Version: 0.1.0
4
+ Summary: Language-model fingerprint detection: offline scoring, challenges, and live detection.
5
+ Project-URL: Homepage, https://lm.ikale.io
6
+ Project-URL: Repository, https://github.com/Ikaleio/lm-detector
7
+ Project-URL: Issues, https://github.com/Ikaleio/lm-detector/issues
8
+ License-Expression: MIT
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: httpx[socks]>=0.28
11
+ Requires-Dist: numpy>=1.26
12
+ Requires-Dist: zstandard>=0.23
13
+ Description-Content-Type: text/markdown
14
+
15
+ # lmfpd (Python)
16
+
17
+ Language-model fingerprint detection: score saved replies against the lmfpd reference bank, generate challenge
18
+ prompts, and run one live detection round against an OpenAI- or Anthropic-compatible API.
19
+
20
+ Documentation: https://lm.ikale.io/docs/en/sdk
21
+
22
+ ## Install and build
23
+
24
+ ```sh
25
+ pip install lmfpd
26
+ uv build # from sdk/python; writes the sdist and wheel to dist/
27
+ ```
28
+
29
+ Python 3.10 or newer. Dependencies: `httpx` (with SOCKS support), `zstandard`, and `numpy`.
30
+
31
+ ## Loading reference data
32
+
33
+ `lmfpd.load(source=DEFAULT_DATA_URLS, cache=None, ttl=86400.0)` is the one entry point.
34
+
35
+ - **Local directory:** a single `source` that is not an `http://` or `https://` URL is a directory holding
36
+ `unified_bank.json` and `shared_detector.bin` (for example `projects/data`). It is read as is, with no cache and no
37
+ network.
38
+ - **URLs:** sources are tried in order: `https://lm.ikale.io/data/`, then the mainland-China mirror
39
+ `https://cdn.jsdmirror.com/npm/lmfpd-data@latest/`. Only `bundle.json` and the detector are downloaded (about
40
+ 3.6 MB compressed, 4.5 MB uncompressed). Files are not checksum-verified.
41
+ - **Cache:** one file, `cache`, defaulting to `<OS cache dir>/lmfpd/data.bin` (`$XDG_CACHE_HOME`,
42
+ `~/Library/Caches`, `%LOCALAPPDATA%`, or `~/.cache`). Its modification time is the last check against the sources.
43
+ - With a cache, `load()` returns it at once. When the last check is `ttl` seconds old or more (0 checks on every
44
+ load), a background thread
45
+ fetches `bundle.json` and, only if the detector changed, the new detector for the next load. Failures there are
46
+ ignored.
47
+ - Without a cache, or with a damaged one, `load()` downloads before returning and raises
48
+ `lmfpd: cannot download reference data from ...` when every source fails.
49
+
50
+ HTTP requests honor `HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY` (including `socks5h://`), and `NO_PROXY`.
51
+
52
+ ## API
53
+
54
+ Results are plain dicts with the same keys in every lmfpd SDK; `lmfpd.Analysis`, `Sample`, and `DetectResult` are
55
+ their `TypedDict`s. Invalid input raises `lmfpd.LmfpdError`.
56
+
57
+ - `load(source, cache, ttl) -> Detector`: load reference data from URLs (cached) or a local directory.
58
+ - `Detector.built_at`: the data build identifier.
59
+ - `Detector.analyze(outputs, strict=False) -> Analysis`: score one to three `{"text", "expected_count"}` replies.
60
+ - `Detector.detect(base_url, api_key, model, *, api="responses", effort="", service_tier="default", stream=True,
61
+ strict=False, count=3, parallel=3, timeout=120.0) -> DetectResult`: send `count` challenges and score the replies.
62
+ `api` is `responses`, `chat_completions`, or `messages`.
63
+ - `generate_challenges(count=3) -> list[Challenge]`: random challenge prompts.
64
+ - `parse_numbers(text) -> list[int]`: the numbers 1 to 355 of the longest run in a reply.
65
+ - `build_request(config, prompt) -> dict`: the URL, headers, and body `detect` sends for one prompt.
66
+ - `request_sample(config, challenge) -> Sample`: send one challenge and check the reply.
67
+
68
+ `config` dicts use the `detect` keyword names (`base_url`, `api_key`, `model`, `api`, ...).
69
+
70
+ ```python
71
+ import lmfpd
72
+
73
+ detector = lmfpd.load()
74
+ result = detector.detect("https://api.openai.com/v1", "sk-...", "gpt-5")
75
+ print(result["analysis"]["prediction"] if result["analysis"] else result["error"])
76
+ ```
lmfpd-0.1.0/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # lmfpd (Python)
2
+
3
+ Language-model fingerprint detection: score saved replies against the lmfpd reference bank, generate challenge
4
+ prompts, and run one live detection round against an OpenAI- or Anthropic-compatible API.
5
+
6
+ Documentation: https://lm.ikale.io/docs/en/sdk
7
+
8
+ ## Install and build
9
+
10
+ ```sh
11
+ pip install lmfpd
12
+ uv build # from sdk/python; writes the sdist and wheel to dist/
13
+ ```
14
+
15
+ Python 3.10 or newer. Dependencies: `httpx` (with SOCKS support), `zstandard`, and `numpy`.
16
+
17
+ ## Loading reference data
18
+
19
+ `lmfpd.load(source=DEFAULT_DATA_URLS, cache=None, ttl=86400.0)` is the one entry point.
20
+
21
+ - **Local directory:** a single `source` that is not an `http://` or `https://` URL is a directory holding
22
+ `unified_bank.json` and `shared_detector.bin` (for example `projects/data`). It is read as is, with no cache and no
23
+ network.
24
+ - **URLs:** sources are tried in order: `https://lm.ikale.io/data/`, then the mainland-China mirror
25
+ `https://cdn.jsdmirror.com/npm/lmfpd-data@latest/`. Only `bundle.json` and the detector are downloaded (about
26
+ 3.6 MB compressed, 4.5 MB uncompressed). Files are not checksum-verified.
27
+ - **Cache:** one file, `cache`, defaulting to `<OS cache dir>/lmfpd/data.bin` (`$XDG_CACHE_HOME`,
28
+ `~/Library/Caches`, `%LOCALAPPDATA%`, or `~/.cache`). Its modification time is the last check against the sources.
29
+ - With a cache, `load()` returns it at once. When the last check is `ttl` seconds old or more (0 checks on every
30
+ load), a background thread
31
+ fetches `bundle.json` and, only if the detector changed, the new detector for the next load. Failures there are
32
+ ignored.
33
+ - Without a cache, or with a damaged one, `load()` downloads before returning and raises
34
+ `lmfpd: cannot download reference data from ...` when every source fails.
35
+
36
+ HTTP requests honor `HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY` (including `socks5h://`), and `NO_PROXY`.
37
+
38
+ ## API
39
+
40
+ Results are plain dicts with the same keys in every lmfpd SDK; `lmfpd.Analysis`, `Sample`, and `DetectResult` are
41
+ their `TypedDict`s. Invalid input raises `lmfpd.LmfpdError`.
42
+
43
+ - `load(source, cache, ttl) -> Detector`: load reference data from URLs (cached) or a local directory.
44
+ - `Detector.built_at`: the data build identifier.
45
+ - `Detector.analyze(outputs, strict=False) -> Analysis`: score one to three `{"text", "expected_count"}` replies.
46
+ - `Detector.detect(base_url, api_key, model, *, api="responses", effort="", service_tier="default", stream=True,
47
+ strict=False, count=3, parallel=3, timeout=120.0) -> DetectResult`: send `count` challenges and score the replies.
48
+ `api` is `responses`, `chat_completions`, or `messages`.
49
+ - `generate_challenges(count=3) -> list[Challenge]`: random challenge prompts.
50
+ - `parse_numbers(text) -> list[int]`: the numbers 1 to 355 of the longest run in a reply.
51
+ - `build_request(config, prompt) -> dict`: the URL, headers, and body `detect` sends for one prompt.
52
+ - `request_sample(config, challenge) -> Sample`: send one challenge and check the reply.
53
+
54
+ `config` dicts use the `detect` keyword names (`base_url`, `api_key`, `model`, `api`, ...).
55
+
56
+ ```python
57
+ import lmfpd
58
+
59
+ detector = lmfpd.load()
60
+ result = detector.detect("https://api.openai.com/v1", "sk-...", "gpt-5")
61
+ print(result["analysis"]["prediction"] if result["analysis"] else result["error"])
62
+ ```
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "lmfpd"
3
+ version = "0.1.0"
4
+ description = "Language-model fingerprint detection: offline scoring, challenges, and live detection."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ dependencies = [
9
+ "httpx[socks]>=0.28",
10
+ "numpy>=1.26",
11
+ "zstandard>=0.23",
12
+ ]
13
+
14
+ [project.urls]
15
+ Homepage = "https://lm.ikale.io"
16
+ Repository = "https://github.com/Ikaleio/lm-detector"
17
+ Issues = "https://github.com/Ikaleio/lm-detector/issues"
18
+
19
+ [build-system]
20
+ requires = ["hatchling>=1.27"]
21
+ build-backend = "hatchling.build"
@@ -0,0 +1,143 @@
1
+ """Checks the Python SDK against sdk/conformance. Usage: conformance.py <conformance dir> <data dir>"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ import threading
9
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10
+ from importlib.resources import files
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import lmfpd
15
+
16
+ failures = 0
17
+
18
+
19
+ def dump(value: Any) -> str:
20
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
21
+
22
+
23
+ def is_number(value: Any) -> bool:
24
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
25
+
26
+
27
+ def compare(file: str, name: str, expected: Any, actual: Any, path: str = "$") -> None:
28
+ """Reports every mismatch. Numbers match within a relative 1e-9; everything else must be identical."""
29
+ global failures
30
+
31
+ def fail() -> None:
32
+ global failures
33
+ failures += 1
34
+ print(f"FAIL {file}:{name}: {path} expected {dump(expected)} got {dump(actual)}")
35
+
36
+ if is_number(expected) and is_number(actual):
37
+ if not abs(expected - actual) <= 1e-9 * max(1, abs(expected), abs(actual)):
38
+ fail()
39
+ elif isinstance(expected, list) and isinstance(actual, (list, tuple)):
40
+ if len(expected) != len(actual):
41
+ return fail()
42
+ for i, (item, other) in enumerate(zip(expected, actual)):
43
+ compare(file, name, item, other, f"{path}[{i}]")
44
+ elif isinstance(expected, dict) and isinstance(actual, dict):
45
+ if sorted(expected) != sorted(actual):
46
+ return fail()
47
+ for key in sorted(expected):
48
+ compare(file, name, expected[key], actual[key], f"{path}.{key}")
49
+ elif type(expected) is not type(actual) or expected != actual:
50
+ fail()
51
+
52
+
53
+ def serve(cases: list[dict[str, Any]]) -> ThreadingHTTPServer:
54
+ """Serves every POST under `/<case name>/` with that case's canned response."""
55
+
56
+ class Handler(BaseHTTPRequestHandler):
57
+ def do_POST(self) -> None:
58
+ self.rfile.read(int(self.headers.get("Content-Length") or 0))
59
+ item = next((c for c in cases if self.path.startswith(f"/{c['name']}/")), None)
60
+ if item is None:
61
+ return self.reply(404, "text/plain", "not found")
62
+ response = item["response"]
63
+ self.reply(response["status"], response["content_type"], response["body"])
64
+
65
+ def do_GET(self) -> None:
66
+ self.reply(404, "text/plain", "not found")
67
+
68
+ def reply(self, status: int, content_type: str, body: str) -> None:
69
+ data = body.encode("utf-8")
70
+ self.send_response(status)
71
+ self.send_header("Content-Type", content_type)
72
+ self.send_header("Content-Length", str(len(data)))
73
+ self.send_header("Connection", "close")
74
+ self.end_headers()
75
+ self.wfile.write(data)
76
+
77
+ def log_message(self, *args: Any) -> None:
78
+ pass
79
+
80
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
81
+ threading.Thread(target=server.serve_forever, daemon=True).start()
82
+ return server
83
+
84
+
85
+ def main() -> int:
86
+ if len(sys.argv) != 3:
87
+ print("Usage: conformance.py <conformance dir> <data dir>")
88
+ return 2
89
+ directory, data_dir = Path(sys.argv[1]), sys.argv[2]
90
+
91
+ def fixture(name: str) -> Any:
92
+ return json.loads((directory / name).read_text("utf-8"))
93
+
94
+ templates = files("lmfpd").joinpath("templates.json").read_bytes()
95
+ if templates != (directory / "templates.json").read_bytes():
96
+ compare("templates.json", "copy", "byte-equal to conformance/templates.json", "different")
97
+
98
+ parse = fixture("parse.json")
99
+ for i, item in enumerate(parse):
100
+ compare("parse.json", str(i), item["numbers"], lmfpd.parse_numbers(item["text"]))
101
+
102
+ requests = fixture("requests.json")
103
+ for item in requests:
104
+ try:
105
+ actual: Any = lmfpd.build_request(item["config"], item["prompt"])
106
+ except lmfpd.LmfpdError as error:
107
+ actual = {"error": str(error)}
108
+ compare("requests.json", item["name"], item["expected"], actual)
109
+
110
+ # The mock server is local; keep any proxy from the environment away from it.
111
+ for variable in ("NO_PROXY", "no_proxy"):
112
+ os.environ[variable] = ",".join(filter(None, ["127.0.0.1", os.environ.get(variable)]))
113
+ streams = fixture("streams.json")["cases"]
114
+ server = serve(streams)
115
+ try:
116
+ port = server.server_address[1]
117
+ for item in streams:
118
+ sample = dict(lmfpd.request_sample({
119
+ "base_url": f"http://127.0.0.1:{port}/{item['name']}", "api_key": "test-key", "model": "test-model",
120
+ "api": item["api"], "stream": item["stream"], "strict": item["strict"], "timeout": 10,
121
+ }, {"id": "conformance", "prompt": "conformance", "expected_count": item["expected_count"]}))
122
+ del sample["expected_count"]
123
+ compare("streams.json", item["name"], item["expected"], sample)
124
+ finally:
125
+ server.shutdown()
126
+ server.server_close()
127
+
128
+ analyze = fixture("analyze.json")
129
+ detector = lmfpd.load(data_dir)
130
+ if detector.built_at != analyze["data"]["built_at"]:
131
+ print("FAIL analyze.json: analyze.json was generated for another data build; run bun run sdk:generate")
132
+ return 1
133
+ for item in analyze["cases"]:
134
+ compare("analyze.json", item["name"], item["expected"], detector.analyze(item["outputs"], item["strict"]))
135
+
136
+ if failures:
137
+ return 1
138
+ print(f"ok analyze={len(analyze['cases'])} parse={len(parse)} requests={len(requests)} streams={len(streams)}")
139
+ return 0
140
+
141
+
142
+ if __name__ == "__main__":
143
+ sys.exit(main())
@@ -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
+ ]
@@ -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}
@@ -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
@@ -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)