contextattr 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,271 @@
1
+ """The input format of docs/interface.md §2: Prompt, Message, Unit, Group and Response, with their checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from collections.abc import Iterable, Mapping
7
+ from dataclasses import dataclass
8
+ from functools import cached_property
9
+ from types import MappingProxyType
10
+ from typing import Union
11
+
12
+ LABELS = ("positive", "negative")
13
+
14
+
15
+ def _need_str(value, what: str, *, empty: bool = True) -> None:
16
+ if not isinstance(value, str):
17
+ raise TypeError(f"{what} must be a string, not {type(value).__name__}")
18
+ if not empty and not value:
19
+ raise ValueError(f"{what} is empty")
20
+
21
+
22
+ def _need_opt_str(value, what: str) -> None:
23
+ if value is not None:
24
+ _need_str(value, what)
25
+
26
+
27
+ def _need_sequence(value, what: str) -> None:
28
+ if isinstance(value, (str, bytes, Mapping)) or not isinstance(value, Iterable):
29
+ raise TypeError(f"{what} must be a list, not {type(value).__name__}")
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Unit:
34
+ """A removable piece of a prompt: `text`, then `sep`, which goes with it when it is deleted and stays when it is
35
+ replaced. `replace` is this unit's replacement text, overriding the run's removal setting."""
36
+
37
+ id: str
38
+ text: str
39
+ sep: str = ""
40
+ tag: str | None = None
41
+ replace: str | None = None
42
+ group: str | None = None # the id of the Group holding it, set by the Group
43
+
44
+ def __post_init__(self) -> None:
45
+ _need_str(self.id, "unit id", empty=False)
46
+ _need_str(self.text, f"unit {self.id!r}: text", empty=False)
47
+ _need_str(self.sep, f"unit {self.id!r}: sep")
48
+ _need_opt_str(self.tag, f"unit {self.id!r}: tag")
49
+ _need_opt_str(self.replace, f"unit {self.id!r}: replace")
50
+ _need_opt_str(self.group, f"unit {self.id!r}: group")
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Group:
55
+ """Units that belong together, such as a document cut into sentences. `lead` comes before them and `sep` after;
56
+ both stay while any of the units shows (kept, or replaced) and go with the last one deleted. `tag` and `replace`
57
+ are given to each unit that has none of its own."""
58
+
59
+ id: str
60
+ units: tuple[Unit, ...]
61
+ lead: str = ""
62
+ sep: str = ""
63
+ tag: str | None = None
64
+ replace: str | None = None
65
+
66
+ def __post_init__(self) -> None:
67
+ _need_str(self.id, "group id", empty=False)
68
+ _need_str(self.lead, f"group {self.id!r}: lead")
69
+ _need_str(self.sep, f"group {self.id!r}: sep")
70
+ _need_opt_str(self.tag, f"group {self.id!r}: tag")
71
+ _need_opt_str(self.replace, f"group {self.id!r}: replace")
72
+ _need_sequence(self.units, f"group {self.id!r}: units")
73
+ units = []
74
+ for u in self.units:
75
+ if not isinstance(u, Unit):
76
+ raise TypeError(f"group {self.id!r}: units must be Unit objects, not {type(u).__name__}")
77
+ if u.group not in (None, self.id):
78
+ raise ValueError(f"group {self.id!r}: unit {u.id!r} already belongs to group {u.group!r}")
79
+ units.append(dataclasses.replace(
80
+ u, group=self.id,
81
+ tag=self.tag if u.tag is None else u.tag,
82
+ replace=self.replace if u.replace is None else u.replace))
83
+ if not units:
84
+ raise ValueError(f"group {self.id!r} has no units")
85
+ object.__setattr__(self, "units", tuple(units))
86
+
87
+
88
+ Piece = Union[str, Unit, Group]
89
+
90
+
91
+ def _pieces(content, what: str) -> tuple[Piece, ...]:
92
+ """A message's content or a text prompt: a string (all fixed), or a list of strings, units and groups."""
93
+ if isinstance(content, str):
94
+ return (content,)
95
+ _need_sequence(content, what)
96
+ out = []
97
+ for p in content:
98
+ if isinstance(p, Unit) and p.group is not None:
99
+ raise ValueError(f"{what}: unit {p.id!r} names group {p.group!r} but is not inside it")
100
+ if not isinstance(p, (str, Unit, Group)):
101
+ raise TypeError(f"{what}: a piece is a string, a Unit or a Group, not {type(p).__name__}")
102
+ out.append(p)
103
+ return tuple(out)
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class Message:
108
+ """One chat message: its role and its content, a sequence of fixed strings, units and groups."""
109
+
110
+ role: str
111
+ content: tuple[Piece, ...]
112
+
113
+ def __post_init__(self) -> None:
114
+ _need_str(self.role, "message role", empty=False)
115
+ object.__setattr__(self, "content", _pieces(self.content, f"{self.role} message"))
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class Response:
120
+ """A reply of the model: `label` is positive, negative or None; `span` [start, end) marks the characters that
121
+ matter."""
122
+
123
+ id: str
124
+ text: str
125
+ label: str | None = None
126
+ span: tuple[int, int] | None = None
127
+
128
+ def __post_init__(self) -> None:
129
+ _need_str(self.id, "response id", empty=False)
130
+ _need_str(self.text, f"response {self.id!r}: text", empty=False)
131
+ if self.label is not None and self.label not in LABELS:
132
+ raise ValueError(f"response {self.id!r}: label must be positive, negative or None, not {self.label!r}")
133
+ if self.span is not None:
134
+ _need_sequence(self.span, f"response {self.id!r}: span")
135
+ span = tuple(self.span)
136
+ if len(span) != 2 or not all(isinstance(x, int) and not isinstance(x, bool) for x in span):
137
+ raise TypeError(f"response {self.id!r}: span must be two integers [start, end)")
138
+ start, end = span
139
+ if not 0 <= start < end <= len(self.text):
140
+ raise ValueError(f"response {self.id!r}: span {list(span)} must satisfy 0 <= start < end <= "
141
+ f"{len(self.text)}, the length of its text")
142
+ object.__setattr__(self, "span", span)
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class Prompt:
147
+ """A prompt with its removable units: chat `messages` or a plain `text`, never both. Messages may be given as
148
+ dicts {"role", "content"} and responses as dicts; everything not marked as a unit is fixed."""
149
+
150
+ id: str
151
+ messages: tuple[Message, ...] | None = None
152
+ text: tuple[Piece, ...] | None = None
153
+ reference: str | None = None
154
+ responses: tuple[Response, ...] = ()
155
+
156
+ def __post_init__(self) -> None:
157
+ _need_str(self.id, "prompt id", empty=False)
158
+ where = f"prompt {self.id!r}"
159
+ if (self.messages is None) == (self.text is None):
160
+ raise ValueError(f"{where}: give either messages or text")
161
+ if self.messages is not None:
162
+ _need_sequence(self.messages, f"{where}: messages")
163
+ messages = []
164
+ for m in self.messages:
165
+ if isinstance(m, Mapping):
166
+ if set(m) != {"role", "content"}:
167
+ raise ValueError(f"{where}: a message has the fields role and content, not {sorted(m)}")
168
+ m = Message(m["role"], m["content"])
169
+ elif not isinstance(m, Message):
170
+ raise TypeError(f"{where}: a message is a Message or a dict, not {type(m).__name__}")
171
+ messages.append(m)
172
+ if not messages:
173
+ raise ValueError(f"{where}: messages is empty")
174
+ object.__setattr__(self, "messages", tuple(messages))
175
+ else:
176
+ object.__setattr__(self, "text", _pieces(self.text, f"{where}: text"))
177
+ _need_opt_str(self.reference, f"{where}: reference")
178
+ _need_sequence(self.responses, f"{where}: responses")
179
+ responses = tuple(r if isinstance(r, Response) else Response(**r) if isinstance(r, Mapping) else
180
+ _not_a_response(r, where) for r in self.responses)
181
+ ids = [r.id for r in responses]
182
+ if len(set(ids)) != len(ids):
183
+ raise ValueError(f"{where}: response ids must be unique: {ids}")
184
+ object.__setattr__(self, "responses", responses)
185
+
186
+ seen: set[str] = set()
187
+ for p in self._top_pieces():
188
+ for name in ([p.id, *(u.id for u in p.units)] if isinstance(p, Group) else
189
+ [p.id] if isinstance(p, Unit) else []):
190
+ if name in seen:
191
+ raise ValueError(f"{where}: the id {name!r} is used twice (unit and group ids must be unique)")
192
+ seen.add(name)
193
+ if not self.units:
194
+ raise ValueError(f"{where} has no units: mark at least one piece as a unit")
195
+
196
+ def _top_pieces(self):
197
+ if self.messages is not None:
198
+ for m in self.messages:
199
+ yield from m.content
200
+ else:
201
+ yield from self.text
202
+
203
+ @cached_property
204
+ def units(self) -> tuple[Unit, ...]:
205
+ """Every unit in order: message by message, piece by piece, a group's units in place."""
206
+ out: list[Unit] = []
207
+ for p in self._top_pieces():
208
+ if isinstance(p, Unit):
209
+ out.append(p)
210
+ elif isinstance(p, Group):
211
+ out.extend(p.units)
212
+ return tuple(out)
213
+
214
+ @property
215
+ def n(self) -> int:
216
+ """The number of units: the length of a mask."""
217
+ return len(self.units)
218
+
219
+ @property
220
+ def is_chat(self) -> bool:
221
+ return self.messages is not None
222
+
223
+ @cached_property
224
+ def unit_ids(self) -> tuple[str, ...]:
225
+ return tuple(u.id for u in self.units)
226
+
227
+ @cached_property
228
+ def _index(self) -> Mapping[str, int]:
229
+ return MappingProxyType({u.id: i for i, u in enumerate(self.units)})
230
+
231
+ @cached_property
232
+ def groups(self) -> Mapping[str, tuple[int, ...]]:
233
+ """Each group's id → the positions of its units in `units`."""
234
+ out: dict[str, list[int]] = {}
235
+ for i, u in enumerate(self.units):
236
+ if u.group is not None:
237
+ out.setdefault(u.group, []).append(i)
238
+ return MappingProxyType({g: tuple(ix) for g, ix in out.items()})
239
+
240
+ def index(self, unit_id: str) -> int:
241
+ """The position of a unit in `units`, and so in a mask."""
242
+ try:
243
+ return self._index[unit_id]
244
+ except KeyError:
245
+ raise KeyError(f"prompt {self.id!r} has no unit {unit_id!r}") from None
246
+
247
+ def mask(self, *, keep: Iterable[str] | str | None = None,
248
+ drop: Iterable[str] | str | None = None) -> tuple[bool, ...]:
249
+ """A mask from ids of units or groups: keep only `keep`, or keep every unit except `drop`."""
250
+ if (keep is None) == (drop is None):
251
+ raise ValueError("give either keep or drop")
252
+ names = keep if keep is not None else drop
253
+ chosen: set[int] = set()
254
+ for name in [names] if isinstance(names, str) else names:
255
+ if name in self.groups:
256
+ chosen.update(self.groups[name])
257
+ elif name in self._index:
258
+ chosen.add(self._index[name])
259
+ else:
260
+ raise KeyError(f"prompt {self.id!r} has no unit or group {name!r}")
261
+ return tuple((i in chosen) == (keep is not None) for i in range(self.n))
262
+
263
+ def render(self, mask=None, replace: str | None = None):
264
+ """The prompt as the model sees it with only the units `mask` keeps (render.py)."""
265
+ from .render import render
266
+
267
+ return render(self, mask, replace)
268
+
269
+
270
+ def _not_a_response(value, where: str):
271
+ raise TypeError(f"{where}: a response is a Response or a dict, not {type(value).__name__}")
contextattr/report.py ADDED
@@ -0,0 +1,120 @@
1
+ """Reports (docs/interface.md §8): one self-contained HTML page (no script, no file beside it) showing each prompt with
2
+ its units shaded by score where they stand, then the scores, the top interactions, the removal check and what the
3
+ explanation cost."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import html
8
+ import math
9
+
10
+ from .prompt import Group, Unit
11
+
12
+ CSS = """
13
+ :root { --bg: #ffffff; --fg: #1f2328; --muted: #57606a; --line: #d0d7de; --card: #f6f8fa;
14
+ --up: 214, 69, 65; --down: 47, 109, 214; }
15
+ @media (prefers-color-scheme: dark) {
16
+ :root { --bg: #0d1117; --fg: #e6edf3; --muted: #9198a1; --line: #30363d; --card: #161b22;
17
+ --up: 248, 81, 73; --down: 88, 166, 255; } }
18
+ * { box-sizing: border-box; }
19
+ body { margin: 0; padding: 24px 16px; background: var(--bg); color: var(--fg);
20
+ font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
21
+ main { max-width: 960px; margin: 0 auto; }
22
+ h1 { font-size: 22px; margin: 0 0 4px; } h2 { font-size: 18px; margin: 32px 0 6px; }
23
+ .meta, .legend { color: var(--muted); font-size: 13px; }
24
+ .prompt { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px;
25
+ white-space: pre-wrap; overflow-wrap: anywhere; margin: 10px 0; }
26
+ .role { display: block; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted);
27
+ margin-top: 6px; }
28
+ .u { border-radius: 3px; padding: 0 1px; }
29
+ table { border-collapse: collapse; width: 100%; font-size: 14px; margin: 8px 0; }
30
+ th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
31
+ td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
32
+ .wrap { overflow-x: auto; }
33
+ """
34
+
35
+
36
+ def page(explanations, title: str = "ContextAttr report") -> str:
37
+ """The HTML page of a list of explanations."""
38
+ parts = [f"<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' "
39
+ f"content='width=device-width, initial-scale=1'><title>{_e(title)}</title><style>{CSS}</style></head>"
40
+ f"<body><main><h1>{_e(title)}</h1><p class='legend'>A unit's shade shows its score: "
41
+ f"<span class='u' style='background: rgba(var(--up), .45)'>pushes the value up</span>, "
42
+ f"<span class='u' style='background: rgba(var(--down), .45)'>pushes it down</span>; hover for the "
43
+ f"number.</p>"]
44
+ for e in explanations:
45
+ parts.append(_section(e))
46
+ parts.append("</main></body></html>")
47
+ return "".join(parts)
48
+
49
+
50
+ def write(explanations, path) -> None:
51
+ with open(path, "w", encoding="utf-8") as f:
52
+ f.write(page(explanations))
53
+
54
+
55
+ def _section(e) -> str:
56
+ scores = dict(e.scores)
57
+ top = max([abs(v) for v in scores.values()] + [1e-300])
58
+ noise = e.noise["std"] if e.noise else None
59
+ out = [f"<h2>{_e(e.prompt_id or 'attribute')}</h2><p class='meta'>{_e(e.method)} · target {_e(e.target or '')} · "
60
+ f"{e.masks} masks · R² {_fmt(e.r2)} on {e.test_masks} held-out masks · noise {_fmt(noise)} · "
61
+ f"{e.requests.get('requests', 0)} requests</p>"]
62
+ if e.prompt is not None:
63
+ out.append(f"<div class='prompt'>{_prompt_html(e.prompt, scores, top)}</div>")
64
+ rows = sorted(scores.items(), key=lambda t: -t[1])
65
+ texts = dict(zip(e.unit_ids, e.unit_texts or [""] * len(e.unit_ids)))
66
+ out.append("<div class='wrap'><table><tr><th>unit</th><th>score</th><th>text</th></tr>")
67
+ for uid, v in rows:
68
+ out.append(f"<tr><td>{_e(uid)}</td><td class='num'>{v:+.4f}</td><td>{_e(_short(texts.get(uid, '')))}</td></tr>")
69
+ out.append("</table></div>")
70
+ inter = e.top_interactions(10)
71
+ if inter:
72
+ out.append("<div class='wrap'><table><tr><th>units together</th><th>score</th></tr>")
73
+ out += [f"<tr><td>{_e(' & '.join(s))}</td><td class='num'>{w:+.4f}</td></tr>" for s, w in inter]
74
+ out.append("</table></div>")
75
+ if e.removal:
76
+ out.append("<div class='wrap'><table><tr><th>removing the top r</th><th>units removed</th>"
77
+ "<th>the value moves by</th><th>Rem(r)</th></tr>")
78
+ for r in e.removal:
79
+ removed = ", ".join(e.unit_ids[i] for i in r["removed"])
80
+ out.append(f"<tr><td>{r['r']}</td><td>{_e(removed)}</td><td class='num'>{r['change']:+.4f}</td>"
81
+ f"<td class='num'>{_fmt(r['rem'])}</td></tr>")
82
+ out.append("</table></div>")
83
+ return "".join(out)
84
+
85
+
86
+ def _prompt_html(prompt, scores, top) -> str:
87
+ def unit(u: Unit) -> str:
88
+ v = scores.get(u.id, 0.0)
89
+ alpha = 0.6 * abs(v) / top
90
+ colour = "--up" if v >= 0 else "--down"
91
+ return (f"<span class='u' title='{_e(u.id)}: {v:+.4f}' style='background: rgba(var({colour}), "
92
+ f"{alpha:.3f})'>{_e(u.text)}</span>{_e(u.sep)}")
93
+
94
+ def pieces(content) -> str:
95
+ out = []
96
+ for p in content:
97
+ if isinstance(p, str):
98
+ out.append(_e(p))
99
+ elif isinstance(p, Unit):
100
+ out.append(unit(p))
101
+ elif isinstance(p, Group):
102
+ out.append(_e(p.lead) + "".join(unit(u) for u in p.units) + _e(p.sep))
103
+ return "".join(out)
104
+
105
+ if prompt.messages is None:
106
+ return pieces(prompt.text)
107
+ return "".join(f"<span class='role'>{_e(m.role)}</span>{pieces(m.content)}" for m in prompt.messages)
108
+
109
+
110
+ def _e(text) -> str:
111
+ return html.escape(str(text), quote=True)
112
+
113
+
114
+ def _short(text: str, width: int = 120) -> str:
115
+ text = text.replace("\n", " ")
116
+ return text if len(text) <= width else text[: width - 1] + "…"
117
+
118
+
119
+ def _fmt(x) -> str:
120
+ return "n/a" if x is None or (isinstance(x, float) and math.isnan(x)) else f"{x:.3g}"
@@ -0,0 +1,6 @@
1
+ """Responses and labels (docs/interface.md §4): checks that label a response, and replies sampled from the model."""
2
+
3
+ from .checks import Verdict, describe, normalise, reference, regex, verdict
4
+ from .sample import sample
5
+
6
+ __all__ = ["Verdict", "describe", "normalise", "reference", "regex", "sample", "verdict"]
@@ -0,0 +1,156 @@
1
+ """Checks: what labels a response (docs/interface.md §4). A check is any function check(prompt, response) giving a
2
+ Verdict, a label ("positive", "negative" or None), True for negative and False for positive, or a number (a judge's
3
+ score, a classifier's probability); `verdict` reads any of these; `describe` names a check in a run's settings."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import functools
8
+ import hashlib
9
+ import math
10
+ import numbers
11
+ import re
12
+ import string
13
+ import types
14
+ from dataclasses import dataclass
15
+
16
+ import numpy as np
17
+
18
+ from ..prompt import LABELS, Prompt
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Verdict:
23
+ """A check's answer: a label, or a number, and optionally the span [start, end) of the response that matters."""
24
+
25
+ label: str | None = None
26
+ score: float | None = None
27
+ span: tuple[int, int] | None = None
28
+
29
+ def __post_init__(self) -> None:
30
+ if self.label is not None and self.label not in LABELS:
31
+ raise ValueError(f"a verdict's label is positive, negative or None, not {self.label!r}")
32
+ if self.score is not None and not math.isfinite(self.score):
33
+ raise ValueError("a verdict's score must be finite")
34
+
35
+ @property
36
+ def value(self) -> float | None:
37
+ """What Sampled averages: 1 for negative, 0 for positive, else the score; None when there is neither."""
38
+ if self.score is not None:
39
+ return float(self.score)
40
+ return None if self.label is None else float(self.label == "negative")
41
+
42
+
43
+ def verdict(result) -> Verdict:
44
+ """Any check's answer as a Verdict."""
45
+ if isinstance(result, Verdict):
46
+ return result
47
+ if result is None or isinstance(result, str):
48
+ return Verdict(label=result)
49
+ if isinstance(result, bool):
50
+ return Verdict(label="negative" if result else "positive")
51
+ if isinstance(result, numbers.Real):
52
+ return Verdict(score=float(result))
53
+ raise TypeError(f"a check gives a Verdict, a label, a bool or a number, not {type(result).__name__}")
54
+
55
+
56
+ def regex(pattern: str, *, negative: bool = True, flags: int = re.IGNORECASE):
57
+ """A check by pattern: a response that matches it is negative (or positive, with negative=False) and the match is
58
+ its span; one that does not match is the other label."""
59
+ compiled = re.compile(pattern, flags)
60
+
61
+ def check(prompt: Prompt, response: str) -> Verdict:
62
+ m = compiled.search(response)
63
+ hit, miss = ("negative", "positive") if negative else ("positive", "negative")
64
+ if m is None:
65
+ return Verdict(label=miss)
66
+ return Verdict(label=hit, span=(m.start(), m.end()) if m.end() > m.start() else None)
67
+
68
+ shown = "" if flags == re.IGNORECASE else f", flags={re.RegexFlag(flags)!r}"
69
+ check.__name__ = f"regex({pattern!r}, negative={negative}{shown})"
70
+ check.named = True
71
+ return check
72
+
73
+
74
+ def reference(*, contains: bool = True):
75
+ """A check against the prompt's `reference`: positive when the response, normalised (lower case, no punctuation,
76
+ single spaces), contains the normalised reference (or equals it, with contains=False); negative otherwise."""
77
+
78
+ def check(prompt: Prompt, response: str) -> Verdict:
79
+ if prompt.reference is None:
80
+ raise ValueError(f"prompt {prompt.id!r} has no reference to check against")
81
+ ref, got = normalise(prompt.reference), normalise(response)
82
+ ok = (ref in got) if contains else (ref == got)
83
+ return Verdict(label="positive" if ok else "negative")
84
+
85
+ check.__name__ = f"reference(contains={contains})"
86
+ check.named = True
87
+ return check
88
+
89
+
90
+ def describe(check) -> str:
91
+ """A check's name in a run's settings and an explanation's target, so that a resumed run cannot mix two checks.
92
+ regex's and reference's names carry their arguments. Any other check (a function, a bound method, a callable
93
+ object) is named by its name and a digest of its code (bytecode, constants and names, not line numbers) and of the
94
+ values it holds: its closure's and defaults', or its object's attributes, with numbers, strings and patterns by
95
+ value and other objects (a client, a model) by type. A check that depends on anything else should carry it in its
96
+ __name__ and set `named = True`."""
97
+ if getattr(check, "named", False) is True:
98
+ return check.__name__
99
+ if isinstance(check, functools.partial):
100
+ return f"partial({describe(check.func)}, {_held(check.args)}, {_held(check.keywords)})"
101
+ fn = getattr(check, "__func__", check) # a bound method: its function, and its object
102
+ if isinstance(fn, types.FunctionType):
103
+ owner = getattr(check, "__self__", None)
104
+ state = [_code(fn.__code__), [_cell(c) for c in fn.__closure__ or ()], _held(fn.__defaults__),
105
+ _held(fn.__kwdefaults__), _held(getattr(owner, "__dict__", None))]
106
+ name = fn.__qualname__
107
+ else:
108
+ call = getattr(type(check).__call__, "__code__", None)
109
+ state = [_code(call) if call is not None else "", _held(getattr(check, "__dict__", None))]
110
+ name = type(check).__qualname__
111
+ return f"{name} [{hashlib.sha256(repr(state).encode()).hexdigest()[:12]}]"
112
+
113
+
114
+ def _code(code: types.CodeType) -> str:
115
+ def const(c):
116
+ if isinstance(c, types.CodeType):
117
+ return _code(c)
118
+ if isinstance(c, (tuple, frozenset)): # a set's order changes with the hash seed
119
+ parts = [const(x) for x in c]
120
+ return repr(sorted(parts) if isinstance(c, frozenset) else parts)
121
+ return repr(c)
122
+
123
+ return repr((code.co_code.hex(), [const(c) for c in code.co_consts], code.co_names, code.co_varnames))
124
+
125
+
126
+ def _cell(cell) -> str:
127
+ try:
128
+ return _held(cell.cell_contents)
129
+ except ValueError: # a cell not yet filled
130
+ return "<empty>"
131
+
132
+
133
+ def _held(value) -> str:
134
+ if value is None or isinstance(value, (str, bytes, numbers.Number)):
135
+ return repr(value)
136
+ if isinstance(value, re.Pattern):
137
+ return f"re.compile({value.pattern!r}, {int(value.flags)})"
138
+ if isinstance(value, np.ndarray):
139
+ digest = hashlib.sha256(np.ascontiguousarray(value).tobytes()).hexdigest()
140
+ return f"array({value.dtype}, {value.shape}, {digest})"
141
+ if isinstance(value, (tuple, list)):
142
+ return "[" + ", ".join(_held(v) for v in value) + "]"
143
+ if isinstance(value, (set, frozenset)):
144
+ return "{" + ", ".join(sorted(_held(v) for v in value)) + "}"
145
+ if isinstance(value, dict):
146
+ return "{" + ", ".join(sorted(f"{_held(k)}: {_held(v)}" for k, v in value.items())) + "}"
147
+ if isinstance(value, types.FunctionType): # a function it calls: by its code, not recursing
148
+ return value.__name__ if getattr(value, "named", False) is True else _code(value.__code__)
149
+ return f"<{type(value).__qualname__}>"
150
+
151
+
152
+ _PUNCTUATION = str.maketrans({c: " " for c in string.punctuation})
153
+
154
+
155
+ def normalise(text: str) -> str:
156
+ return " ".join(text.lower().translate(_PUNCTUATION).split())
@@ -0,0 +1,36 @@
1
+ """Responses sampled from the full prompt and labelled (docs/interface.md §4): `sample` adds k replies of the model to
2
+ a prompt's responses, each labelled by a check, so that scored targets (LogProb, Pooled, Contrast) can explain them."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import numpy as np
7
+
8
+ from ..cache import Cache
9
+ from ..prompt import Prompt, Response
10
+ from ..wrapper.contract import Sampling
11
+ from .checks import verdict
12
+
13
+ DEFAULT = Sampling(n=8, temperature=0.7, top_p=0.95, max_tokens=256, seed=0)
14
+
15
+
16
+ def sample(prompt: Prompt, model, sampling: Sampling = DEFAULT, check=None, *, cache: Cache | str | None = None,
17
+ replace: str | None = None) -> Prompt:
18
+ """The prompt with `sampling.n` replies to its full text added to its responses, as sample-0, sample-1, …, each
19
+ labelled by `check` (none: unlabelled). Empty replies are left out. The replies are cached like every answer."""
20
+ from ..value import Value # noqa: PLC0415 - value imports the targets, which import this package
21
+ from ..targets.sampled import Sampled
22
+
23
+ replies = Value(prompt, model, Sampled(lambda p, r: 0.0, samples=sampling.n, sampling=sampling), replace=replace,
24
+ cache=cache).replies(np.ones((1, prompt.n), dtype=bool))[0]
25
+ taken = {r.id for r in prompt.responses}
26
+ added = []
27
+ for k, text in enumerate(replies):
28
+ if not text.strip():
29
+ continue
30
+ rid = f"sample-{k}"
31
+ if rid in taken:
32
+ raise ValueError(f"prompt {prompt.id!r} already has a response {rid!r}")
33
+ v = verdict(check(prompt, text)) if check is not None else None
34
+ added.append(Response(rid, text, label=None if v is None else v.label, span=None if v is None else v.span))
35
+ return Prompt(prompt.id, messages=prompt.messages, text=prompt.text, reference=prompt.reference,
36
+ responses=(*prompt.responses, *added))