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.
@@ -0,0 +1,3 @@
1
+ """contrastset: build and check contrast sets for interpretability experiments."""
2
+
3
+ __version__ = "0.1.0"
@@ -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}