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 +27 -0
- lmfpd-0.1.0/PKG-INFO +76 -0
- lmfpd-0.1.0/README.md +62 -0
- lmfpd-0.1.0/pyproject.toml +21 -0
- lmfpd-0.1.0/scripts/conformance.py +143 -0
- lmfpd-0.1.0/src/lmfpd/__init__.py +94 -0
- lmfpd-0.1.0/src/lmfpd/_analysis.py +162 -0
- lmfpd-0.1.0/src/lmfpd/_challenge.py +38 -0
- lmfpd-0.1.0/src/lmfpd/_codec.py +30 -0
- lmfpd-0.1.0/src/lmfpd/_data.py +183 -0
- lmfpd-0.1.0/src/lmfpd/_features.py +142 -0
- lmfpd-0.1.0/src/lmfpd/_ranker.py +154 -0
- lmfpd-0.1.0/src/lmfpd/_sampling.py +468 -0
- lmfpd-0.1.0/src/lmfpd/_types.py +106 -0
- lmfpd-0.1.0/src/lmfpd/py.typed +0 -0
- lmfpd-0.1.0/src/lmfpd/templates.json +174 -0
- lmfpd-0.1.0/uv.lock +444 -0
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)
|