contrastset 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.
- contrastset/__init__.py +3 -0
- contrastset/adapters/__init__.py +14 -0
- contrastset/adapters/anthropic.py +63 -0
- contrastset/adapters/base.py +78 -0
- contrastset/adapters/fake.py +60 -0
- contrastset/adapters/hf.py +457 -0
- contrastset/adapters/ollama.py +93 -0
- contrastset/adapters/openai.py +76 -0
- contrastset/checks/__init__.py +145 -0
- contrastset/checks/model.py +133 -0
- contrastset/checks/surface.py +199 -0
- contrastset/cli.py +358 -0
- contrastset/generate.py +544 -0
- contrastset/grade.py +633 -0
- contrastset/jsonl.py +46 -0
- contrastset/py.typed +0 -0
- contrastset/records.py +642 -0
- contrastset/templates/README.md +24 -0
- contrastset/templates/qwen3-nothink.txt +7 -0
- contrastset-0.1.0.dist-info/METADATA +365 -0
- contrastset-0.1.0.dist-info/RECORD +24 -0
- contrastset-0.1.0.dist-info/WHEEL +4 -0
- contrastset-0.1.0.dist-info/entry_points.txt +3 -0
- contrastset-0.1.0.dist-info/licenses/LICENSE +21 -0
contrastset/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Adapters: the only way contrastset talks to a model.
|
|
2
|
+
|
|
3
|
+
Both the subject model and the generator model are reached through the same tiny
|
|
4
|
+
`Adapter` interface, so any backend that can turn a prompt into text works.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from contrastset.adapters.anthropic import AnthropicAdapter
|
|
8
|
+
from contrastset.adapters.base import Adapter, AdapterError, Scorer, make_adapter
|
|
9
|
+
from contrastset.adapters.fake import FakeAdapter
|
|
10
|
+
from contrastset.adapters.hf import HFAdapter, Score
|
|
11
|
+
from contrastset.adapters.ollama import OllamaAdapter
|
|
12
|
+
from contrastset.adapters.openai import OpenAIAdapter
|
|
13
|
+
|
|
14
|
+
__all__ = ["Adapter", "AnthropicAdapter", "AdapterError", "FakeAdapter", "HFAdapter", "Score", "Scorer", "OllamaAdapter", "OpenAIAdapter", "make_adapter"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Claude models through Anthropic's official SDK (`pip install contrastset[anthropic]`).
|
|
2
|
+
|
|
3
|
+
Meant for the generator model, not the subject: the API exposes no raw completions
|
|
4
|
+
and no internals. Two things differ from the other adapters and are recorded in
|
|
5
|
+
describe(), so provenance says it:
|
|
6
|
+
- no temperature: the Messages API rejects temperature/top_p/top_k since
|
|
7
|
+
2026-10-21, so `temperature` is ignored;
|
|
8
|
+
- no seed: the API has none, so generator output is not reproducible from the
|
|
9
|
+
seed. The pairs file keeps every generated text; that file is the record.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from contrastset.adapters.base import AdapterError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AnthropicAdapter:
|
|
20
|
+
def __init__(self, model: str, *, effort: str = "low", system: str | None = None,
|
|
21
|
+
client: Any = None) -> None:
|
|
22
|
+
if client is None:
|
|
23
|
+
try:
|
|
24
|
+
import anthropic
|
|
25
|
+
except ImportError as exc:
|
|
26
|
+
raise AdapterError("the anthropic adapter needs `pip install contrastset[anthropic]`") from exc
|
|
27
|
+
client = anthropic.Anthropic() # credentials from ANTHROPIC_API_KEY or an `ant auth login` profile
|
|
28
|
+
self.model = model
|
|
29
|
+
self.effort = effort # how much the model thinks before answering: low | medium | high | xhigh | max
|
|
30
|
+
self.system = system
|
|
31
|
+
self._client = client
|
|
32
|
+
|
|
33
|
+
def complete(
|
|
34
|
+
self,
|
|
35
|
+
prompt: str,
|
|
36
|
+
*,
|
|
37
|
+
n: int = 1,
|
|
38
|
+
temperature: float = 0.0, # ignored, see module docstring
|
|
39
|
+
seed: int | None = None, # ignored, see module docstring
|
|
40
|
+
max_tokens: int = 4096,
|
|
41
|
+
) -> list[str]:
|
|
42
|
+
out = []
|
|
43
|
+
for _ in range(n):
|
|
44
|
+
kwargs: dict[str, Any] = {
|
|
45
|
+
"model": self.model,
|
|
46
|
+
"max_tokens": max_tokens,
|
|
47
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
48
|
+
"output_config": {"effort": self.effort},
|
|
49
|
+
}
|
|
50
|
+
if self.system:
|
|
51
|
+
kwargs["system"] = self.system
|
|
52
|
+
try:
|
|
53
|
+
response = self._client.messages.create(**kwargs)
|
|
54
|
+
except Exception as exc: # SDK errors carry status and message; surface them as-is
|
|
55
|
+
raise AdapterError(f"anthropic {self.model!r}: {exc}") from exc
|
|
56
|
+
if response.stop_reason == "refusal":
|
|
57
|
+
raise AdapterError(f"anthropic {self.model!r} declined the request: {response.stop_details}")
|
|
58
|
+
out.append("".join(block.text for block in response.content if block.type == "text"))
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
def describe(self) -> dict[str, Any]:
|
|
62
|
+
return {"adapter": "anthropic", "model": self.model, "effort": self.effort,
|
|
63
|
+
"temperature_supported": False, "seed_supported": False}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The adapter interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AdapterError(RuntimeError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# The contrastset sampling order. Where an adapter samples itself (hf), it follows
|
|
13
|
+
# this exactly; where a server samples (ollama), the adapter records the server's
|
|
14
|
+
# settings and version, since the server's order is the server's. Matches
|
|
15
|
+
# llama.cpp, so HF and Ollama grades are comparable. Truncating before the
|
|
16
|
+
# temperature matters for top-p: after a temperature below 1 the distribution is
|
|
17
|
+
# sharper and the same top-p keeps fewer tokens.
|
|
18
|
+
SAMPLING_ORDER = "top_k -> top_p (both on untempered probabilities) -> temperature -> draw"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class Adapter(Protocol):
|
|
23
|
+
"""Turns a prompt into n completions.
|
|
24
|
+
|
|
25
|
+
Seeding contract: completion i is sampled with seed `seed + i`, one request per
|
|
26
|
+
completion. That makes every sample individually reproducible and works with
|
|
27
|
+
servers that ignore the OpenAI `n` parameter (Ollama does). With temperature 0
|
|
28
|
+
(greedy decoding: always take the most likely next token) the seed is irrelevant.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def complete(
|
|
32
|
+
self,
|
|
33
|
+
prompt: str,
|
|
34
|
+
*,
|
|
35
|
+
n: int = 1,
|
|
36
|
+
temperature: float = 0.0,
|
|
37
|
+
seed: int | None = None,
|
|
38
|
+
max_tokens: int = 64,
|
|
39
|
+
) -> list[str]: ...
|
|
40
|
+
|
|
41
|
+
def describe(self) -> dict[str, Any]:
|
|
42
|
+
"""Identify the model for provenance: adapter kind, model name, endpoint, and
|
|
43
|
+
whatever the server reports about the exact weights (digest, quantisation)."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@runtime_checkable
|
|
48
|
+
class Scorer(Protocol):
|
|
49
|
+
"""An adapter that can also score text: log P(continuation | prompt). Needs model
|
|
50
|
+
internals (logits), so only in-process adapters (hf) implement it."""
|
|
51
|
+
|
|
52
|
+
def score(self, prompt: str, continuation: str) -> Any: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sample_seed(seed: int | None, i: int) -> int | None:
|
|
56
|
+
return None if seed is None else seed + i
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def make_adapter(kind: str, model: str, base_url: str | None = None, **options: Any) -> Adapter:
|
|
60
|
+
"""Build an adapter by name: "ollama", "openai" (raw completions), "openai-chat" or
|
|
61
|
+
"anthropic" or "hf"."""
|
|
62
|
+
from contrastset.adapters.anthropic import AnthropicAdapter
|
|
63
|
+
from contrastset.adapters.hf import HFAdapter
|
|
64
|
+
from contrastset.adapters.ollama import OllamaAdapter
|
|
65
|
+
from contrastset.adapters.openai import OpenAIAdapter
|
|
66
|
+
|
|
67
|
+
if kind == "ollama":
|
|
68
|
+
return OllamaAdapter(model, base_url=base_url or OllamaAdapter.default_url, **options)
|
|
69
|
+
if kind in ("openai", "openai-chat"):
|
|
70
|
+
if base_url is None:
|
|
71
|
+
raise AdapterError(f"adapter {kind!r} needs a base URL, e.g. http://localhost:8000/v1")
|
|
72
|
+
mode: Literal["completions", "chat"] = "chat" if kind == "openai-chat" else "completions"
|
|
73
|
+
return OpenAIAdapter(model, base_url=base_url, mode=mode, **options)
|
|
74
|
+
if kind == "anthropic":
|
|
75
|
+
return AnthropicAdapter(model, **options)
|
|
76
|
+
if kind == "hf":
|
|
77
|
+
return HFAdapter(model, **options)
|
|
78
|
+
raise AdapterError(f"unknown adapter {kind!r}; expected ollama, openai, openai-chat, anthropic or hf")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""A deterministic stand-in model for tests. No network, no weights."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from contrastset.adapters.base import sample_seed
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeAdapter:
|
|
12
|
+
"""Answers from a script.
|
|
13
|
+
|
|
14
|
+
`script` maps a substring of the prompt to the answers the fake model gives;
|
|
15
|
+
the first key found in the prompt wins. Greedy calls (temperature 0) return the
|
|
16
|
+
first answer; sampled calls return answers[seed % len(answers)], so a script like
|
|
17
|
+
["Germany", "Germany", "France"] yields a known share of each answer. Alternatively
|
|
18
|
+
pass a function (prompt, temperature, seed) -> str. Every call is logged in `calls`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
script: dict[str, list[str]] | Callable[[str, float, int | None], str],
|
|
24
|
+
*,
|
|
25
|
+
default: str = "I don't know.",
|
|
26
|
+
name: str = "fake",
|
|
27
|
+
) -> None:
|
|
28
|
+
self.script = script
|
|
29
|
+
self.default = default
|
|
30
|
+
self.name = name
|
|
31
|
+
self.calls: list[dict[str, Any]] = []
|
|
32
|
+
|
|
33
|
+
def _one(self, prompt: str, temperature: float, seed: int | None) -> str:
|
|
34
|
+
if callable(self.script):
|
|
35
|
+
return self.script(prompt, temperature, seed)
|
|
36
|
+
for key, answers in self.script.items():
|
|
37
|
+
if key in prompt:
|
|
38
|
+
if temperature == 0 or seed is None:
|
|
39
|
+
return answers[0]
|
|
40
|
+
return answers[seed % len(answers)]
|
|
41
|
+
return self.default
|
|
42
|
+
|
|
43
|
+
def complete(
|
|
44
|
+
self,
|
|
45
|
+
prompt: str,
|
|
46
|
+
*,
|
|
47
|
+
n: int = 1,
|
|
48
|
+
temperature: float = 0.0,
|
|
49
|
+
seed: int | None = None,
|
|
50
|
+
max_tokens: int = 64,
|
|
51
|
+
) -> list[str]:
|
|
52
|
+
out = []
|
|
53
|
+
for i in range(n):
|
|
54
|
+
s = sample_seed(seed, i)
|
|
55
|
+
self.calls.append({"prompt": prompt, "temperature": temperature, "seed": s, "max_tokens": max_tokens})
|
|
56
|
+
out.append(self._one(prompt, temperature, s))
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
def describe(self) -> dict[str, Any]:
|
|
60
|
+
return {"adapter": "fake", "model": self.name}
|