rulesmith 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.
- rulesmith/__init__.py +1 -0
- rulesmith/ablate.py +99 -0
- rulesmith/arena.py +172 -0
- rulesmith/bench.py +1068 -0
- rulesmith/calibrate.py +457 -0
- rulesmith/chat_judge.py +205 -0
- rulesmith/chess.py +526 -0
- rulesmith/clef.py +66 -0
- rulesmith/cli.py +1234 -0
- rulesmith/diagram.py +226 -0
- rulesmith/doom.py +550 -0
- rulesmith/extract.py +77 -0
- rulesmith/grade.py +85 -0
- rulesmith/graph.py +975 -0
- rulesmith/label.py +67 -0
- rulesmith/level.py +389 -0
- rulesmith/maps.py +96 -0
- rulesmith/mine.py +313 -0
- rulesmith/optimize.py +931 -0
- rulesmith/rules.py +1017 -0
- rulesmith/runtime.py +711 -0
- rulesmith/serve.py +68 -0
- rulesmith/tuning.py +134 -0
- rulesmith-0.1.0.dist-info/METADATA +131 -0
- rulesmith-0.1.0.dist-info/RECORD +28 -0
- rulesmith-0.1.0.dist-info/WHEEL +4 -0
- rulesmith-0.1.0.dist-info/entry_points.txt +2 -0
- rulesmith-0.1.0.dist-info/licenses/LICENSE +21 -0
rulesmith/graph.py
ADDED
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
"""A typed, finite DAG language for model calls and deterministic data flow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from graphlib import CycleError, TopologicalSorter
|
|
9
|
+
from typing import Annotated, Literal, Self
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue, StringConstraints, model_validator
|
|
12
|
+
|
|
13
|
+
Text = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
|
14
|
+
# A name a graph and the command line both write, so it is a plain word in both.
|
|
15
|
+
Name = Annotated[str, StringConstraints(pattern=r"^[A-Za-z_]\w*$")]
|
|
16
|
+
State = str | dict[str, JsonValue] | list[JsonValue]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StrictModel(BaseModel):
|
|
20
|
+
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Choice(StrictModel):
|
|
24
|
+
type: Literal["choice"] = "choice"
|
|
25
|
+
instructions: Text
|
|
26
|
+
criteria: dict[Text, Text] = Field(min_length=2, max_length=255)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Score(StrictModel):
|
|
30
|
+
type: Literal["score"] = "score"
|
|
31
|
+
instructions: Text
|
|
32
|
+
criteria: list[Text] = Field(min_length=2, max_length=10)
|
|
33
|
+
|
|
34
|
+
@model_validator(mode="after")
|
|
35
|
+
def distinct_levels(self) -> Self:
|
|
36
|
+
if len(set(self.criteria)) != len(self.criteria):
|
|
37
|
+
raise ValueError("score criteria must be distinct")
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Noul(StrictModel):
|
|
42
|
+
type: Literal["noul"] = "noul"
|
|
43
|
+
instructions: Text
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
Question = Annotated[Choice | Score | Noul, Field(discriminator="type")]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class StateInput(StrictModel):
|
|
50
|
+
type: Literal["state"] = "state"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LiteralInput(StrictModel):
|
|
54
|
+
type: Literal["literal"] = "literal"
|
|
55
|
+
value: JsonValue
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class NodeInput(StrictModel):
|
|
59
|
+
type: Literal["node"] = "node"
|
|
60
|
+
node: Text
|
|
61
|
+
view: Literal["value", "answer"] = "value"
|
|
62
|
+
optional: bool = False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
Source = Annotated[StateInput | LiteralInput | NodeInput, Field(discriminator="type")]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Compare(StrictModel):
|
|
69
|
+
type: Literal["compare"] = "compare"
|
|
70
|
+
node: Text
|
|
71
|
+
op: Literal["eq", "lt", "ge"]
|
|
72
|
+
value: str | float
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Relation(StrictModel):
|
|
76
|
+
"""One value compared with another, where Compare holds a node against a constant."""
|
|
77
|
+
|
|
78
|
+
type: Literal["relation"] = "relation"
|
|
79
|
+
node: Text
|
|
80
|
+
op: Literal["eq", "lt", "ge"]
|
|
81
|
+
other: Text
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Present(StrictModel):
|
|
85
|
+
type: Literal["present"] = "present"
|
|
86
|
+
node: Text
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class All(StrictModel):
|
|
90
|
+
type: Literal["all"] = "all"
|
|
91
|
+
conditions: list[Condition] = Field(min_length=1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Any(StrictModel):
|
|
95
|
+
type: Literal["any"] = "any"
|
|
96
|
+
conditions: list[Condition] = Field(min_length=1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Not(StrictModel):
|
|
100
|
+
type: Literal["not"] = "not"
|
|
101
|
+
condition: Condition
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
Condition = Annotated[Compare | Relation | Present | All | Any | Not, Field(discriminator="type")]
|
|
105
|
+
All.model_rebuild()
|
|
106
|
+
Any.model_rebuild()
|
|
107
|
+
Not.model_rebuild()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def reachable(dependencies: dict[str, set[str]], outputs) -> set[str]:
|
|
111
|
+
"""The nodes an output depends on, following references as far as they go."""
|
|
112
|
+
used: set[str] = set()
|
|
113
|
+
pending = [source.node for source in outputs if isinstance(source, NodeInput)]
|
|
114
|
+
while pending:
|
|
115
|
+
key = pending.pop()
|
|
116
|
+
if key not in used and key in dependencies:
|
|
117
|
+
used.add(key)
|
|
118
|
+
pending.extend(dependencies[key])
|
|
119
|
+
return used
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def references(value: JsonValue) -> set[str]:
|
|
123
|
+
"""References in graph syntax; literal payloads and question text are opaque."""
|
|
124
|
+
if isinstance(value, list):
|
|
125
|
+
return set().union(*(references(v) for v in value))
|
|
126
|
+
if not isinstance(value, dict):
|
|
127
|
+
return set()
|
|
128
|
+
named: set[str] = set()
|
|
129
|
+
kind = value.get("type")
|
|
130
|
+
if kind in ("node", "compare", "relation", "present") and isinstance(value.get("node"), str):
|
|
131
|
+
named.add(value["node"])
|
|
132
|
+
if kind == "relation" and isinstance(value.get("other"), str):
|
|
133
|
+
named.add(value["other"])
|
|
134
|
+
if kind in ("switch", "threshold", "confidence", "quantify") and isinstance(
|
|
135
|
+
value.get("source"), str
|
|
136
|
+
):
|
|
137
|
+
named.add(value["source"])
|
|
138
|
+
if kind in ("coalesce", "vote") and isinstance(value.get("sources"), list):
|
|
139
|
+
named.update(source for source in value["sources"] if isinstance(source, str))
|
|
140
|
+
if kind == "sum" and isinstance(value.get("terms"), list):
|
|
141
|
+
named.update(
|
|
142
|
+
term["node"]
|
|
143
|
+
for term in value["terms"]
|
|
144
|
+
if isinstance(term, dict) and isinstance(term.get("node"), str)
|
|
145
|
+
)
|
|
146
|
+
for key in ("when", "condition", "conditions"):
|
|
147
|
+
named.update(references(value.get(key)))
|
|
148
|
+
if kind == "read" and isinstance(value.get("source"), str):
|
|
149
|
+
named.add(value["source"])
|
|
150
|
+
if kind in ("call", "merge", "extract") and isinstance(value.get("inputs"), dict):
|
|
151
|
+
for source in value["inputs"].values():
|
|
152
|
+
named.update(references(source))
|
|
153
|
+
if kind == "graph":
|
|
154
|
+
named.update(references(value.get("input")))
|
|
155
|
+
return named
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def check_items(condition: Condition, fields: dict[str, ItemField]) -> None:
|
|
159
|
+
"""A test on a list's items names only fields the items declare, compared as their kind."""
|
|
160
|
+
domains = {
|
|
161
|
+
name: frozenset(field.categories) if field.categories else field.bounds
|
|
162
|
+
for name, field in fields.items()
|
|
163
|
+
}
|
|
164
|
+
for leaf in predicates(condition):
|
|
165
|
+
named = [leaf.node] + ([leaf.other] if isinstance(leaf, Relation) else [])
|
|
166
|
+
for name in named:
|
|
167
|
+
if name not in domains:
|
|
168
|
+
raise ValueError(f"the items have no field {name!r}; they have {sorted(domains)}")
|
|
169
|
+
if isinstance(leaf, Present):
|
|
170
|
+
continue
|
|
171
|
+
domain = domains[leaf.node]
|
|
172
|
+
if isinstance(leaf, Relation):
|
|
173
|
+
if type(domain) is not type(domains[leaf.other]):
|
|
174
|
+
raise ValueError("a relation compares two values of the same kind")
|
|
175
|
+
if leaf.op != "eq" and not isinstance(domain, tuple):
|
|
176
|
+
raise ValueError("an ordering relation requires numeric fields")
|
|
177
|
+
elif isinstance(leaf.value, str):
|
|
178
|
+
if leaf.op != "eq" or not isinstance(domain, frozenset) or leaf.value not in domain:
|
|
179
|
+
raise ValueError(f"the field {leaf.node} is never {leaf.value!r}")
|
|
180
|
+
elif not isinstance(domain, tuple):
|
|
181
|
+
raise ValueError(f"numeric comparison requires a numeric field, and {leaf.node} is not")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def truth(condition: Condition, known: dict[str, bool]) -> bool | None:
|
|
185
|
+
"""A condition's value given what is known of its tests, or None while it still depends
|
|
186
|
+
on one that is not known, each test keyed by its JSON."""
|
|
187
|
+
if isinstance(condition, Not):
|
|
188
|
+
inner = truth(condition.condition, known)
|
|
189
|
+
return None if inner is None else not inner
|
|
190
|
+
if isinstance(condition, (All, Any)):
|
|
191
|
+
truths = [truth(child, known) for child in condition.conditions]
|
|
192
|
+
# True settles an any, and false settles an all.
|
|
193
|
+
deciding = isinstance(condition, Any)
|
|
194
|
+
if deciding in truths:
|
|
195
|
+
return deciding
|
|
196
|
+
return None if None in truths else not deciding
|
|
197
|
+
return known.get(condition.model_dump_json())
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def predicates(condition: Condition | None) -> list[Compare | Relation | Present]:
|
|
201
|
+
if condition is None:
|
|
202
|
+
return []
|
|
203
|
+
if isinstance(condition, (Compare, Relation, Present)):
|
|
204
|
+
return [condition]
|
|
205
|
+
if isinstance(condition, Not):
|
|
206
|
+
return predicates(condition.condition)
|
|
207
|
+
return [leaf for child in condition.conditions for leaf in predicates(child)]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def tested(condition: Condition | None) -> set[str]:
|
|
211
|
+
"""The nodes a condition reads."""
|
|
212
|
+
named = set()
|
|
213
|
+
for leaf in predicates(condition):
|
|
214
|
+
named.add(leaf.node)
|
|
215
|
+
if isinstance(leaf, Relation):
|
|
216
|
+
named.add(leaf.other)
|
|
217
|
+
return named
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class Guarded(StrictModel):
|
|
221
|
+
when: Condition | None = None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class Measured(StrictModel):
|
|
225
|
+
"""How a rule did on labeled examples: how many it decided, and how many of those rightly.
|
|
226
|
+
Measured by rulesmith calibrate, it says how sure the rule's decisions are and whether new
|
|
227
|
+
inputs still reach it about as often as the ones it was measured on."""
|
|
228
|
+
|
|
229
|
+
right: int = Field(ge=0)
|
|
230
|
+
decided: int = Field(ge=0)
|
|
231
|
+
examples: int = Field(ge=1)
|
|
232
|
+
|
|
233
|
+
@model_validator(mode="after")
|
|
234
|
+
def nested(self) -> Self:
|
|
235
|
+
if not self.right <= self.decided <= self.examples:
|
|
236
|
+
raise ValueError(
|
|
237
|
+
"a rule is right at most as often as it decides, and decides at most every example"
|
|
238
|
+
)
|
|
239
|
+
return self
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def precision(self) -> float:
|
|
243
|
+
"""How often the rule is right when it decides, smoothed toward even odds by Laplace's
|
|
244
|
+
rule of succession, so a rule seen deciding twice does not claim certainty."""
|
|
245
|
+
return (self.right + 1) / (self.decided + 2)
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def share(self) -> float:
|
|
249
|
+
return self.decided / self.examples
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class Decides(Guarded):
|
|
253
|
+
"""A node a rule list can end on, the way a rule answers without asking."""
|
|
254
|
+
|
|
255
|
+
measured: Measured | None = None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class Call(Guarded):
|
|
259
|
+
type: Literal["call"] = "call"
|
|
260
|
+
question: Question
|
|
261
|
+
inputs: dict[Text, Source] = Field(min_length=1)
|
|
262
|
+
# What the judge reads before the input: what to look for, how the labels differ, the cases
|
|
263
|
+
# that are easy to confuse. It goes in the state rather than the question because that is
|
|
264
|
+
# where a small judge takes guidance from; the same text in the question changes nothing.
|
|
265
|
+
briefing: Text | None = None
|
|
266
|
+
temperature: float = Field(
|
|
267
|
+
default=1,
|
|
268
|
+
gt=0,
|
|
269
|
+
description="Divides the judge's log-odds before they are read: above 1 tempers an "
|
|
270
|
+
"overconfident judge, below 1 sharpens a timid one. Fitted by rulesmith calibrate",
|
|
271
|
+
)
|
|
272
|
+
judge: Name | None = Field(
|
|
273
|
+
default=None,
|
|
274
|
+
description="A named judge to ask instead of the graph's own, such as a larger model for "
|
|
275
|
+
"the cases the small one is unsure of; where the graph runs, each name is given a model",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class Switch(Guarded):
|
|
280
|
+
type: Literal["switch"] = "switch"
|
|
281
|
+
source: Text
|
|
282
|
+
cases: dict[Text, Text] = Field(min_length=1)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class Threshold(Decides):
|
|
286
|
+
type: Literal["threshold"] = "threshold"
|
|
287
|
+
source: Text
|
|
288
|
+
cutoff: float
|
|
289
|
+
below: Text
|
|
290
|
+
above: Text
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class Merge(Guarded):
|
|
294
|
+
type: Literal["merge"] = "merge"
|
|
295
|
+
inputs: dict[Text, Source] = Field(min_length=1)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class Coalesce(Guarded):
|
|
299
|
+
type: Literal["coalesce"] = "coalesce"
|
|
300
|
+
sources: list[Text] = Field(min_length=1)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class Value(Decides):
|
|
304
|
+
type: Literal["value"] = "value"
|
|
305
|
+
value: JsonValue
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class Vote(Guarded):
|
|
309
|
+
"""Several judgments settled into one value. `majority` takes the value most of them gave,
|
|
310
|
+
breaking a tie toward the earlier source; `agree` reports whether they all said the same,
|
|
311
|
+
which is how a second opinion verifies a first."""
|
|
312
|
+
|
|
313
|
+
type: Literal["vote"] = "vote"
|
|
314
|
+
sources: list[Text] = Field(min_length=2)
|
|
315
|
+
rule: Literal["majority", "agree"] = "majority"
|
|
316
|
+
same: Text = "yes"
|
|
317
|
+
different: Text = "no"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class Confidence(Guarded):
|
|
321
|
+
"""How sure a judgment was, as a number a rule can test. A choice or a score answer carries
|
|
322
|
+
its own confidence; a probability answers with the probability itself, so it has none to
|
|
323
|
+
report and this is absent for one."""
|
|
324
|
+
|
|
325
|
+
type: Literal["confidence"] = "confidence"
|
|
326
|
+
source: Text
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class Term(StrictModel):
|
|
330
|
+
node: Text
|
|
331
|
+
weight: float = 1
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class Sum(Guarded):
|
|
335
|
+
"""A weighted sum of numeric values plus a constant: a margin, a total, a scaled
|
|
336
|
+
difference. It is skipped when any term is, since a missing term is not a zero."""
|
|
337
|
+
|
|
338
|
+
type: Literal["sum"] = "sum"
|
|
339
|
+
terms: list[Term] = Field(min_length=1)
|
|
340
|
+
constant: float = 0
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class Extract(Guarded):
|
|
344
|
+
"""A generative model reading its inputs into fields: each field is a read of this node, with
|
|
345
|
+
the domain the model must answer in. A field it cannot fill, or fills outside that domain, is
|
|
346
|
+
absent, like an input's missing path."""
|
|
347
|
+
|
|
348
|
+
type: Literal["extract"] = "extract"
|
|
349
|
+
instructions: Text
|
|
350
|
+
inputs: dict[Text, Source] = Field(min_length=1)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class ItemField(StrictModel):
|
|
354
|
+
"""A field of each item in a list, with its domain, as a reading declares one."""
|
|
355
|
+
|
|
356
|
+
bounds: tuple[float, float] | None = None
|
|
357
|
+
categories: list[Text] | None = Field(default=None, min_length=1)
|
|
358
|
+
|
|
359
|
+
@model_validator(mode="after")
|
|
360
|
+
def one_domain(self) -> Self:
|
|
361
|
+
if (self.bounds is None) == (self.categories is None):
|
|
362
|
+
raise ValueError("an item's field declares exactly one of bounds or categories")
|
|
363
|
+
if self.bounds is not None and self.bounds[0] > self.bounds[1]:
|
|
364
|
+
raise ValueError("an item's field bounds must be increasing")
|
|
365
|
+
return self
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class Read(Guarded):
|
|
369
|
+
"""A value taken from the original input by path, with its declared domain; with `source`,
|
|
370
|
+
a field of that extraction instead."""
|
|
371
|
+
|
|
372
|
+
type: Literal["read"] = "read"
|
|
373
|
+
source: Name | None = None
|
|
374
|
+
path: list[str | int] = Field(min_length=1)
|
|
375
|
+
bounds: tuple[float, float] | None = None
|
|
376
|
+
categories: list[Text] | None = Field(default=None, min_length=1)
|
|
377
|
+
each: dict[Name, ItemField] | None = Field(
|
|
378
|
+
default=None,
|
|
379
|
+
min_length=1,
|
|
380
|
+
description="A list, read as its items' fields: an item that is not an object, or a "
|
|
381
|
+
"field missing or outside its domain, leaves that field absent for that item",
|
|
382
|
+
)
|
|
383
|
+
measure: Literal["value", "length"] = Field(
|
|
384
|
+
default="value",
|
|
385
|
+
description="The value itself, or how long it is when it is a text or a list: an empty "
|
|
386
|
+
"field or a one-line answer is often as telling as what it says, and costs nothing",
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
@model_validator(mode="after")
|
|
390
|
+
def one_domain(self) -> Self:
|
|
391
|
+
if sum(domain is not None for domain in (self.bounds, self.categories, self.each)) != 1:
|
|
392
|
+
raise ValueError("a read declares exactly one of bounds, categories or each")
|
|
393
|
+
if self.measure == "length" and self.bounds is None:
|
|
394
|
+
raise ValueError("a length is a number, so its read declares bounds")
|
|
395
|
+
if self.bounds is not None and self.bounds[0] > self.bounds[1]:
|
|
396
|
+
raise ValueError("read bounds must be increasing")
|
|
397
|
+
if self.source is not None and (len(self.path) != 1 or self.measure != "value"):
|
|
398
|
+
raise ValueError("a field of an extraction is read by its name alone")
|
|
399
|
+
return self
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class Quantify(Guarded):
|
|
403
|
+
"""A test over a list's items: `any` holds when some item meets `where`, `all` when every
|
|
404
|
+
item does, and either is absent otherwise, as a named condition is; `count` is how many
|
|
405
|
+
do. `where` tests the items' fields by name, and a field an item lacks fails its test."""
|
|
406
|
+
|
|
407
|
+
type: Literal["quantify"] = "quantify"
|
|
408
|
+
source: Text
|
|
409
|
+
rule: Literal["any", "all", "count"]
|
|
410
|
+
where: Condition
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
class Graph(Guarded):
|
|
414
|
+
"""Another plan, run on the one value this node is handed and answering with one of its
|
|
415
|
+
outputs: a unit of a larger map. The value may be the whole input, a node's value, or an
|
|
416
|
+
object a merge built, so the unit's own reads stay relative to what it receives. A parent's
|
|
417
|
+
rules test the unit's answer only against the values its graph can give, so the boundary is
|
|
418
|
+
typed like any other edge."""
|
|
419
|
+
|
|
420
|
+
type: Literal["graph"] = "graph"
|
|
421
|
+
plan: Plan | None = None
|
|
422
|
+
path: str | None = Field(
|
|
423
|
+
default=None,
|
|
424
|
+
description="A file holding the unit's graph, .json or .rules, relative to the file "
|
|
425
|
+
"naming it; rulesmith.maps.load embeds it, and a plan is what validation needs",
|
|
426
|
+
)
|
|
427
|
+
input: Source = Field(default_factory=StateInput)
|
|
428
|
+
output: Text = "label"
|
|
429
|
+
|
|
430
|
+
@model_validator(mode="after")
|
|
431
|
+
def answers_with_an_output_it_has(self) -> Self:
|
|
432
|
+
if self.plan is None:
|
|
433
|
+
raise ValueError(
|
|
434
|
+
f"a unit named by path ({self.path!r}) must be loaded first: "
|
|
435
|
+
"rulesmith.maps.load reads the map and embeds its units"
|
|
436
|
+
)
|
|
437
|
+
if self.path is not None:
|
|
438
|
+
raise ValueError("a unit is embedded as a plan or named by path, not both")
|
|
439
|
+
if self.output not in self.plan.outputs:
|
|
440
|
+
raise ValueError(f"the graph has no output {self.output!r} to answer with")
|
|
441
|
+
return self
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
Node = Annotated[
|
|
445
|
+
Call
|
|
446
|
+
| Switch
|
|
447
|
+
| Threshold
|
|
448
|
+
| Merge
|
|
449
|
+
| Coalesce
|
|
450
|
+
| Value
|
|
451
|
+
| Read
|
|
452
|
+
| Extract
|
|
453
|
+
| Quantify
|
|
454
|
+
| Confidence
|
|
455
|
+
| Vote
|
|
456
|
+
| Sum
|
|
457
|
+
| Graph,
|
|
458
|
+
Field(discriminator="type"),
|
|
459
|
+
]
|
|
460
|
+
Domain = frozenset[str] | tuple[float, float] | None
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def literal_domain(value: JsonValue) -> Domain:
|
|
464
|
+
if isinstance(value, str):
|
|
465
|
+
return frozenset([value])
|
|
466
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
467
|
+
return (value, value)
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class Plan(StrictModel):
|
|
472
|
+
nodes: dict[Text, Node]
|
|
473
|
+
outputs: dict[Text, Source] = Field(min_length=1)
|
|
474
|
+
|
|
475
|
+
def dependencies(self) -> dict[str, set[str]]:
|
|
476
|
+
result = {}
|
|
477
|
+
for key, node in self.nodes.items():
|
|
478
|
+
refs = tested(node.when)
|
|
479
|
+
if isinstance(node, (Call, Merge, Extract)):
|
|
480
|
+
refs.update(s.node for s in node.inputs.values() if isinstance(s, NodeInput))
|
|
481
|
+
elif isinstance(node, Read) and node.source is not None:
|
|
482
|
+
refs.add(node.source)
|
|
483
|
+
elif isinstance(node, (Switch, Threshold, Confidence, Quantify)):
|
|
484
|
+
refs.add(node.source)
|
|
485
|
+
elif isinstance(node, (Coalesce, Vote)):
|
|
486
|
+
refs.update(node.sources)
|
|
487
|
+
elif isinstance(node, Sum):
|
|
488
|
+
refs.update(term.node for term in node.terms)
|
|
489
|
+
elif isinstance(node, Graph) and isinstance(node.input, NodeInput):
|
|
490
|
+
refs.add(node.input.node)
|
|
491
|
+
result[key] = refs
|
|
492
|
+
return result
|
|
493
|
+
|
|
494
|
+
def contributing(self) -> set[str]:
|
|
495
|
+
"""Every node an output depends on, directly or through another node."""
|
|
496
|
+
return reachable(self.dependencies(), self.outputs.values())
|
|
497
|
+
|
|
498
|
+
def judges(self) -> set[str]:
|
|
499
|
+
"""The named judges this graph asks, in it or in a unit of it."""
|
|
500
|
+
return {
|
|
501
|
+
node.judge for node in self.nodes.values() if isinstance(node, Call) and node.judge
|
|
502
|
+
}.union(*(node.plan.judges() for node in self.nodes.values() if isinstance(node, Graph)))
|
|
503
|
+
|
|
504
|
+
def extracts(self) -> bool:
|
|
505
|
+
"""Whether running this graph asks an extractor, in it or in a unit of it."""
|
|
506
|
+
return any(
|
|
507
|
+
isinstance(node, Extract) or (isinstance(node, Graph) and node.plan.extracts())
|
|
508
|
+
for node in self.nodes.values()
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def fields(self, extraction: str) -> dict[str, Read]:
|
|
512
|
+
"""The fields read of an extraction, by name, in the order the graph declares them."""
|
|
513
|
+
return {
|
|
514
|
+
str(node.path[0]): node
|
|
515
|
+
for node in self.nodes.values()
|
|
516
|
+
if isinstance(node, Read) and node.source == extraction
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
def asks_own_judge(self) -> bool:
|
|
520
|
+
"""Whether running this graph asks the judge it runs with, in it or in a unit of it; a
|
|
521
|
+
judgment asked of a named judge goes to that one instead."""
|
|
522
|
+
return any(
|
|
523
|
+
(isinstance(node, Call) and node.judge is None)
|
|
524
|
+
or (isinstance(node, Graph) and node.plan.asks_own_judge())
|
|
525
|
+
for node in self.nodes.values()
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
def answers(self, output: str) -> bool:
|
|
529
|
+
"""Whether every input gets a value for this output, an optional input counting as
|
|
530
|
+
given. The check is static and errs toward no: a read is taken as possibly absent, and
|
|
531
|
+
the tests in conditions as independent of one another, except that a judgment's choice
|
|
532
|
+
is one of its labels. So a rule list ending on an unconditional rule, a judgment, or a
|
|
533
|
+
rule for whatever the rules above it left out always answers."""
|
|
534
|
+
sure: dict[str, bool] = {}
|
|
535
|
+
domains = self.domains()
|
|
536
|
+
|
|
537
|
+
def given(source: Source) -> bool:
|
|
538
|
+
if isinstance(source, NodeInput):
|
|
539
|
+
return source.optional or answers(source.node)
|
|
540
|
+
return True
|
|
541
|
+
|
|
542
|
+
def answers(key: str) -> bool:
|
|
543
|
+
if key not in sure:
|
|
544
|
+
node = self.nodes[key]
|
|
545
|
+
sure[key] = made(node) and (node.when is None or covered([node.when]))
|
|
546
|
+
return sure[key]
|
|
547
|
+
|
|
548
|
+
def covered(conditions: list[Condition]) -> bool:
|
|
549
|
+
"""Whether one of these conditions holds whatever the tests in them come to."""
|
|
550
|
+
leaves = {leaf.model_dump_json(): leaf for c in conditions for leaf in predicates(c)}
|
|
551
|
+
chosen: dict[str, list[str]] = {}
|
|
552
|
+
unknowns = []
|
|
553
|
+
for key, leaf in leaves.items():
|
|
554
|
+
if isinstance(domains.get(leaf.node), frozenset) and (
|
|
555
|
+
isinstance(leaf, Present) or (isinstance(leaf, Compare) and leaf.op == "eq")
|
|
556
|
+
):
|
|
557
|
+
chosen.setdefault(leaf.node, []).append(key)
|
|
558
|
+
elif isinstance(leaf, Present) and answers(leaf.node):
|
|
559
|
+
unknowns.append([{key: True}])
|
|
560
|
+
else:
|
|
561
|
+
unknowns.append([{key: True}, {key: False}])
|
|
562
|
+
for node, keys in chosen.items():
|
|
563
|
+
# One outcome per label the choice can give, and one for no answer at all.
|
|
564
|
+
outcomes = [
|
|
565
|
+
{
|
|
566
|
+
key: isinstance(leaves[key], Present) or leaves[key].value == label
|
|
567
|
+
for key in keys
|
|
568
|
+
}
|
|
569
|
+
for label in domains[node]
|
|
570
|
+
]
|
|
571
|
+
if not answers(node):
|
|
572
|
+
outcomes.append(dict.fromkeys(keys, False))
|
|
573
|
+
unknowns.append(outcomes)
|
|
574
|
+
|
|
575
|
+
def holds(known: dict[str, bool], rest: list) -> bool:
|
|
576
|
+
truths = [truth(condition, known) for condition in conditions]
|
|
577
|
+
if True in truths:
|
|
578
|
+
return True
|
|
579
|
+
if None not in truths:
|
|
580
|
+
return False
|
|
581
|
+
return all(holds(known | outcome, rest[1:]) for outcome in rest[0])
|
|
582
|
+
|
|
583
|
+
return holds({}, unknowns)
|
|
584
|
+
|
|
585
|
+
def made(node: Node) -> bool:
|
|
586
|
+
"""Whether the node has a value whenever its own condition lets it run."""
|
|
587
|
+
if isinstance(node, Read):
|
|
588
|
+
return False
|
|
589
|
+
if isinstance(node, Value):
|
|
590
|
+
return True
|
|
591
|
+
if isinstance(node, (Call, Merge, Extract)):
|
|
592
|
+
return all(given(source) for source in node.inputs.values())
|
|
593
|
+
if isinstance(node, Coalesce) or (isinstance(node, Vote) and node.rule == "majority"):
|
|
594
|
+
runs = [self.nodes[source] for source in node.sources]
|
|
595
|
+
guards = [source.when for source in runs if made(source)]
|
|
596
|
+
return None in guards or (bool(guards) and covered(guards))
|
|
597
|
+
if isinstance(node, Vote):
|
|
598
|
+
return all(answers(source) for source in node.sources)
|
|
599
|
+
if isinstance(node, Sum):
|
|
600
|
+
return all(answers(term.node) for term in node.terms)
|
|
601
|
+
if isinstance(node, Quantify):
|
|
602
|
+
return node.rule == "count" and answers(node.source)
|
|
603
|
+
if isinstance(node, Graph):
|
|
604
|
+
return given(node.input) and node.plan.answers(node.output)
|
|
605
|
+
# A threshold, a switch, or a confidence, which validation keeps to choices and
|
|
606
|
+
# scores, the kinds of judgment that report one.
|
|
607
|
+
return answers(node.source)
|
|
608
|
+
|
|
609
|
+
return given(self.outputs[output])
|
|
610
|
+
|
|
611
|
+
def unanswered(self) -> list[str]:
|
|
612
|
+
"""The required outputs some input could leave without a value."""
|
|
613
|
+
return [
|
|
614
|
+
name
|
|
615
|
+
for name, source in self.outputs.items()
|
|
616
|
+
if not (isinstance(source, NodeInput) and source.optional) and not self.answers(name)
|
|
617
|
+
]
|
|
618
|
+
|
|
619
|
+
def order(self) -> tuple[str, ...]:
|
|
620
|
+
dependencies = self.dependencies()
|
|
621
|
+
return tuple(
|
|
622
|
+
TopologicalSorter(
|
|
623
|
+
{k: sorted(dependencies[k]) for k in sorted(self.nodes)}
|
|
624
|
+
).static_order()
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
def domains(self) -> dict[str, Domain]:
|
|
628
|
+
"""Infer finite label sets and numeric bounds without running model calls."""
|
|
629
|
+
result: dict[str, Domain] = {}
|
|
630
|
+
for key in self.order():
|
|
631
|
+
node = self.nodes[key]
|
|
632
|
+
domain = None
|
|
633
|
+
if isinstance(node, Call):
|
|
634
|
+
q = node.question
|
|
635
|
+
domain = (
|
|
636
|
+
frozenset(q.criteria)
|
|
637
|
+
if isinstance(q, Choice)
|
|
638
|
+
else (0, len(q.criteria) - 1 if isinstance(q, Score) else 1)
|
|
639
|
+
)
|
|
640
|
+
elif isinstance(node, Switch):
|
|
641
|
+
domain = frozenset(node.cases.values())
|
|
642
|
+
elif isinstance(node, Threshold):
|
|
643
|
+
domain = frozenset([node.below, node.above])
|
|
644
|
+
elif isinstance(node, Value):
|
|
645
|
+
domain = literal_domain(node.value)
|
|
646
|
+
elif isinstance(node, Read):
|
|
647
|
+
domain = frozenset(node.categories) if node.categories else node.bounds
|
|
648
|
+
elif isinstance(node, Quantify) and node.rule == "count":
|
|
649
|
+
domain = (0, math.inf)
|
|
650
|
+
elif isinstance(node, Confidence):
|
|
651
|
+
domain = (0.0, 1.0)
|
|
652
|
+
elif isinstance(node, Graph):
|
|
653
|
+
# The unit answers with what its own graph can put on that output.
|
|
654
|
+
answer = node.plan.outputs[node.output]
|
|
655
|
+
if isinstance(answer, LiteralInput):
|
|
656
|
+
domain = literal_domain(answer.value)
|
|
657
|
+
elif answer.view == "value" and not answer.optional:
|
|
658
|
+
domain = node.plan.domains()[answer.node]
|
|
659
|
+
elif isinstance(node, Sum):
|
|
660
|
+
ranges = [result[term.node] for term in node.terms]
|
|
661
|
+
if all(isinstance(r, tuple) for r in ranges):
|
|
662
|
+
# Each term spans weight * low to weight * high, whichever way the sign runs.
|
|
663
|
+
ends = [
|
|
664
|
+
sorted((term.weight * low, term.weight * high))
|
|
665
|
+
for term, (low, high) in zip(node.terms, ranges, strict=True)
|
|
666
|
+
]
|
|
667
|
+
domain = (
|
|
668
|
+
node.constant + sum(low for low, _ in ends),
|
|
669
|
+
node.constant + sum(high for _, high in ends),
|
|
670
|
+
)
|
|
671
|
+
elif isinstance(node, Vote):
|
|
672
|
+
if node.rule == "agree":
|
|
673
|
+
domain = frozenset([node.same, node.different])
|
|
674
|
+
else:
|
|
675
|
+
voted = [result[source] for source in node.sources]
|
|
676
|
+
if all(isinstance(d, frozenset) for d in voted):
|
|
677
|
+
domain = frozenset().union(*voted)
|
|
678
|
+
elif isinstance(node, Coalesce):
|
|
679
|
+
inputs = [result[source] for source in node.sources]
|
|
680
|
+
if all(isinstance(d, frozenset) for d in inputs):
|
|
681
|
+
domain = frozenset().union(*inputs)
|
|
682
|
+
elif all(isinstance(d, tuple) for d in inputs):
|
|
683
|
+
domain = (min(d[0] for d in inputs), max(d[1] for d in inputs))
|
|
684
|
+
result[key] = domain
|
|
685
|
+
return result
|
|
686
|
+
|
|
687
|
+
@model_validator(mode="after")
|
|
688
|
+
def typed_references(self) -> Self:
|
|
689
|
+
dependencies = self.dependencies()
|
|
690
|
+
sources = list(self.outputs.values())
|
|
691
|
+
for node in self.nodes.values():
|
|
692
|
+
if isinstance(node, (Call, Merge, Extract)):
|
|
693
|
+
sources.extend(node.inputs.values())
|
|
694
|
+
elif isinstance(node, Graph):
|
|
695
|
+
sources.append(node.input)
|
|
696
|
+
references = {r for parents in dependencies.values() for r in parents}
|
|
697
|
+
references.update(s.node for s in sources if isinstance(s, NodeInput))
|
|
698
|
+
unknown = references - self.nodes.keys()
|
|
699
|
+
if unknown:
|
|
700
|
+
raise ValueError(f"unknown nodes: {sorted(unknown)}")
|
|
701
|
+
for source in sources:
|
|
702
|
+
if (
|
|
703
|
+
isinstance(source, NodeInput)
|
|
704
|
+
and source.view == "answer"
|
|
705
|
+
and not isinstance(self.nodes[source.node], Call)
|
|
706
|
+
):
|
|
707
|
+
raise ValueError("answer view requires a call node")
|
|
708
|
+
for key, node in self.nodes.items():
|
|
709
|
+
if isinstance(node, Quantify):
|
|
710
|
+
listed = self.nodes[node.source]
|
|
711
|
+
if not isinstance(listed, Read) or listed.each is None:
|
|
712
|
+
raise ValueError(
|
|
713
|
+
f"{key!r} counts items of {node.source!r}, which is not a list"
|
|
714
|
+
)
|
|
715
|
+
check_items(node.where, listed.each)
|
|
716
|
+
if isinstance(node, Read) and node.source is not None:
|
|
717
|
+
if not isinstance(self.nodes.get(node.source), Extract):
|
|
718
|
+
raise ValueError(
|
|
719
|
+
f"read {key!r} takes a field of {node.source!r}, not an extraction"
|
|
720
|
+
)
|
|
721
|
+
try:
|
|
722
|
+
self.order()
|
|
723
|
+
except CycleError as error:
|
|
724
|
+
raise ValueError(f"graph contains a cycle: {error.args[1]}") from error
|
|
725
|
+
|
|
726
|
+
if self.contributing() != self.nodes.keys():
|
|
727
|
+
raise ValueError("every node must contribute to a graph output")
|
|
728
|
+
# A vote counts labels. A probability or a rubric position is a float, and two floats
|
|
729
|
+
# agreeing exactly is a coincidence; a merged object cannot be counted at all.
|
|
730
|
+
domains = self.domains()
|
|
731
|
+
for key, node in self.nodes.items():
|
|
732
|
+
if isinstance(node, Vote) and not all(
|
|
733
|
+
isinstance(domains[source], frozenset) for source in node.sources
|
|
734
|
+
):
|
|
735
|
+
raise ValueError(
|
|
736
|
+
f"vote {key!r} counts labels, so every source needs a categorical value"
|
|
737
|
+
)
|
|
738
|
+
if isinstance(node, Sum) and not all(
|
|
739
|
+
isinstance(domains[term.node], tuple) for term in node.terms
|
|
740
|
+
):
|
|
741
|
+
raise ValueError(f"sum {key!r} adds numbers, so every term needs a numeric value")
|
|
742
|
+
# A reading has no confidence and a probability answers with its own, so a
|
|
743
|
+
# confidence of either is absent on every input and its rules never fire.
|
|
744
|
+
if isinstance(node, Confidence):
|
|
745
|
+
source = self.nodes[node.source]
|
|
746
|
+
if not isinstance(source, Call) or isinstance(source.question, Noul):
|
|
747
|
+
raise ValueError(
|
|
748
|
+
f"confidence {key!r} needs a choice or score judgment as its source"
|
|
749
|
+
)
|
|
750
|
+
for node in self.nodes.values():
|
|
751
|
+
if isinstance(node, Switch):
|
|
752
|
+
domain = domains[node.source]
|
|
753
|
+
if not isinstance(domain, frozenset) or set(node.cases) != domain:
|
|
754
|
+
raise ValueError("switch cases must exactly cover the source categories")
|
|
755
|
+
elif isinstance(node, Threshold):
|
|
756
|
+
domain = domains[node.source]
|
|
757
|
+
if not isinstance(domain, tuple):
|
|
758
|
+
raise ValueError("threshold requires a numeric source")
|
|
759
|
+
if not domain[0] <= node.cutoff <= domain[1]:
|
|
760
|
+
raise ValueError(f"cutoff must be within source bounds {domain}")
|
|
761
|
+
for condition in predicates(node.when):
|
|
762
|
+
if isinstance(condition, Present):
|
|
763
|
+
continue
|
|
764
|
+
if isinstance(condition, Relation):
|
|
765
|
+
pair = domains[condition.node], domains[condition.other]
|
|
766
|
+
kinds = {type(domain) for domain in pair}
|
|
767
|
+
if len(kinds) != 1 or None in pair:
|
|
768
|
+
raise ValueError("a relation compares two values of the same kind")
|
|
769
|
+
if condition.op != "eq" and kinds != {tuple}:
|
|
770
|
+
raise ValueError("an ordering relation requires numeric sources")
|
|
771
|
+
continue
|
|
772
|
+
domain = domains[condition.node]
|
|
773
|
+
if isinstance(condition.value, str) and condition.op == "eq":
|
|
774
|
+
if not isinstance(domain, frozenset) or condition.value not in domain:
|
|
775
|
+
can = (
|
|
776
|
+
f"one of {sorted(domain)}"
|
|
777
|
+
if isinstance(domain, frozenset)
|
|
778
|
+
else "a number"
|
|
779
|
+
)
|
|
780
|
+
raise ValueError(
|
|
781
|
+
f"the condition {condition.node} == {condition.value!r} tests a value "
|
|
782
|
+
f"{condition.node} never has; it is {can}"
|
|
783
|
+
)
|
|
784
|
+
elif not isinstance(domain, tuple) or isinstance(condition.value, str):
|
|
785
|
+
raise ValueError("numeric comparison requires a numeric source and value")
|
|
786
|
+
return self
|
|
787
|
+
|
|
788
|
+
@classmethod
|
|
789
|
+
def constant(cls, label: str) -> Plan:
|
|
790
|
+
"""A call-free graph that always outputs one label."""
|
|
791
|
+
return cls(nodes={}, outputs={"label": LiteralInput(value=label)})
|
|
792
|
+
|
|
793
|
+
def check(self, labels: list[str], max_nodes: int, max_depth: int) -> None:
|
|
794
|
+
"""Keep classification targets and search bounds outside mutable candidates."""
|
|
795
|
+
if len(self.nodes) > max_nodes:
|
|
796
|
+
raise ValueError(f"node count exceeds {max_nodes}")
|
|
797
|
+
dependencies, depths = self.dependencies(), {}
|
|
798
|
+
for key in self.order():
|
|
799
|
+
depths[key] = 1 + max((depths[p] for p in dependencies[key]), default=0)
|
|
800
|
+
if max(depths.values(), default=0) > max_depth:
|
|
801
|
+
raise ValueError(f"graph depth exceeds {max_depth}")
|
|
802
|
+
label = self.outputs.get("label")
|
|
803
|
+
domain = None
|
|
804
|
+
if isinstance(label, LiteralInput):
|
|
805
|
+
domain = literal_domain(label.value)
|
|
806
|
+
elif isinstance(label, NodeInput) and label.view == "value" and not label.optional:
|
|
807
|
+
domain = self.domains()[label.node]
|
|
808
|
+
if not isinstance(domain, frozenset) or not domain <= set(labels):
|
|
809
|
+
raise ValueError(
|
|
810
|
+
"label output must have a required categorical value within task labels"
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
# A unit holds a plan and a plan may hold units, so each finishes the other's schema.
|
|
815
|
+
Graph.model_rebuild()
|
|
816
|
+
Plan.model_rebuild()
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
class Example(StrictModel):
|
|
820
|
+
id: Text
|
|
821
|
+
state: State
|
|
822
|
+
# Absent only when a grader, not a label, says whether an answer is right.
|
|
823
|
+
label: Text | None = None
|
|
824
|
+
accepted: list[Text] = Field(
|
|
825
|
+
default_factory=list, description="Other labels that are also right for this example"
|
|
826
|
+
)
|
|
827
|
+
group: Text | None = Field(
|
|
828
|
+
default=None,
|
|
829
|
+
description="Examples that are not independent, a ticket and its follow-up, share a "
|
|
830
|
+
"group, which keeps them in one split",
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
@model_validator(mode="after")
|
|
834
|
+
def accepted_beside_a_label(self) -> Self:
|
|
835
|
+
if self.accepted and self.label is None:
|
|
836
|
+
raise ValueError(f"example {self.id} accepts other labels but has none of its own")
|
|
837
|
+
if self.label in self.accepted:
|
|
838
|
+
raise ValueError(f"example {self.id} lists its own label as another accepted one")
|
|
839
|
+
return self
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
class Abstain(StrictModel):
|
|
843
|
+
"""A label that passes an example to a person, at a cost between nothing and a mistake."""
|
|
844
|
+
|
|
845
|
+
label: Text
|
|
846
|
+
cost: float = Field(gt=0, lt=1, description="As a share of the dearest mistake")
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
class Task(StrictModel):
|
|
850
|
+
description: Text
|
|
851
|
+
labels: list[Text] = Field(min_length=2)
|
|
852
|
+
train: list[Example] = Field(min_length=1)
|
|
853
|
+
validation: list[Example] = Field(min_length=1)
|
|
854
|
+
test: list[Example] = Field(min_length=1)
|
|
855
|
+
calibration: list[Example] = Field(
|
|
856
|
+
default_factory=list,
|
|
857
|
+
description="Examples search never sees, for calibrate to fit on; without them it fits "
|
|
858
|
+
"on the training and validation examples, which search chose the graph by",
|
|
859
|
+
)
|
|
860
|
+
costs: dict[Text, dict[Text, Annotated[float, Field(ge=0)]]] = Field(
|
|
861
|
+
default_factory=dict,
|
|
862
|
+
description="costs[expected][answered]: what a wrong answer costs; 1 where unlisted",
|
|
863
|
+
)
|
|
864
|
+
descriptions: dict[Text, Text] = Field(
|
|
865
|
+
default_factory=dict,
|
|
866
|
+
description="What each label means, for a question over them all; its words where unlisted",
|
|
867
|
+
)
|
|
868
|
+
abstain: Abstain | None = None
|
|
869
|
+
prevalence: dict[Text, Annotated[float, Field(gt=0)]] | None = Field(
|
|
870
|
+
default=None,
|
|
871
|
+
description="Each label's share of real traffic, where the examples were sampled at "
|
|
872
|
+
"other rates: a rare label sampled to half the examples makes its mistakes look common",
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
def examples(self) -> list[Example]:
|
|
876
|
+
return self.train + self.validation + self.calibration + self.test
|
|
877
|
+
|
|
878
|
+
def fitting(self) -> list[Example]:
|
|
879
|
+
"""The labeled examples calibrate fits lines and counts on: the calibration split when
|
|
880
|
+
the task has one, else the training examples search saw, which flatter a searched graph."""
|
|
881
|
+
return [e for e in (self.calibration or self.train) if e.label is not None]
|
|
882
|
+
|
|
883
|
+
def fitted_on(self) -> str:
|
|
884
|
+
return "calibration" if self.calibration else "train+validation"
|
|
885
|
+
|
|
886
|
+
def weights(self) -> dict[str, float]:
|
|
887
|
+
"""How much an example of each label stands for in real traffic: its prevalence over
|
|
888
|
+
its share of the labeled examples. Every label weighs 1 without a prevalence."""
|
|
889
|
+
if self.prevalence is None:
|
|
890
|
+
return dict.fromkeys(self.labels, 1.0)
|
|
891
|
+
labeled = [e.label for e in self.examples() if e.label]
|
|
892
|
+
return {
|
|
893
|
+
label: self.prevalence[label] / (labeled.count(label) / len(labeled))
|
|
894
|
+
if label in labeled
|
|
895
|
+
else 1.0
|
|
896
|
+
for label in self.labels
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
@model_validator(mode="after")
|
|
900
|
+
def disjoint_splits(self) -> Self:
|
|
901
|
+
if len(set(self.labels)) != len(self.labels):
|
|
902
|
+
raise ValueError("task labels must be distinct")
|
|
903
|
+
ids, states, groups = set(), set(), {}
|
|
904
|
+
splits = {"train": self.train, "validation": self.validation}
|
|
905
|
+
splits |= {"calibration": self.calibration, "test": self.test}
|
|
906
|
+
for split, examples in splits.items():
|
|
907
|
+
for example in examples:
|
|
908
|
+
for label in [example.label, *example.accepted]:
|
|
909
|
+
if label is not None and label not in self.labels:
|
|
910
|
+
raise ValueError(f"unknown label in example {example.id}: {label}")
|
|
911
|
+
state = json.dumps(example.state, sort_keys=True, ensure_ascii=False)
|
|
912
|
+
if example.id in ids:
|
|
913
|
+
raise ValueError(f"duplicate example id: {example.id}")
|
|
914
|
+
if state in states:
|
|
915
|
+
raise ValueError(f"duplicate state in example {example.id}")
|
|
916
|
+
if example.group is not None and groups.setdefault(example.group, split) != split:
|
|
917
|
+
raise ValueError(
|
|
918
|
+
f"group {example.group!r} is in both {groups[example.group]} and {split}; "
|
|
919
|
+
"examples that belong together are held out together"
|
|
920
|
+
)
|
|
921
|
+
ids.add(example.id)
|
|
922
|
+
states.add(state)
|
|
923
|
+
for expected, row in self.costs.items():
|
|
924
|
+
for answered in [expected, *row]:
|
|
925
|
+
if answered not in self.labels:
|
|
926
|
+
raise ValueError(f"costs name a label the task does not have: {answered}")
|
|
927
|
+
if expected in row:
|
|
928
|
+
raise ValueError(f"costs give {expected!r} a cost for answering itself")
|
|
929
|
+
unknown = self.descriptions.keys() - set(self.labels)
|
|
930
|
+
if unknown:
|
|
931
|
+
raise ValueError(f"descriptions name labels the task does not have: {sorted(unknown)}")
|
|
932
|
+
if self.worst == 0:
|
|
933
|
+
raise ValueError("costs make every mistake free, so no answer is worse than another")
|
|
934
|
+
if self.prevalence is not None:
|
|
935
|
+
if set(self.prevalence) != set(self.labels):
|
|
936
|
+
raise ValueError("prevalence must give a share for every label and no other")
|
|
937
|
+
if abs(sum(self.prevalence.values()) - 1) > 1e-6:
|
|
938
|
+
raise ValueError("prevalence shares must sum to 1")
|
|
939
|
+
if self.abstain is not None:
|
|
940
|
+
passed = self.abstain.label
|
|
941
|
+
if passed not in self.labels:
|
|
942
|
+
raise ValueError(f"the abstain label {passed!r} must be one of the task's labels")
|
|
943
|
+
if passed in self.costs or any(passed in row for row in self.costs.values()):
|
|
944
|
+
raise ValueError("the abstain label's cost is set by abstain, not by costs")
|
|
945
|
+
for example in self.examples():
|
|
946
|
+
if passed in (example.label, *example.accepted):
|
|
947
|
+
raise ValueError(f"example {example.id} expects {passed!r}, which no person is")
|
|
948
|
+
return self
|
|
949
|
+
|
|
950
|
+
def cost(self, expected: str, answered: str) -> float:
|
|
951
|
+
if expected == answered:
|
|
952
|
+
return 0.0
|
|
953
|
+
if self.abstain is not None and answered == self.abstain.label:
|
|
954
|
+
return self.abstain.cost * self.worst
|
|
955
|
+
return self.costs.get(expected, {}).get(answered, 1.0)
|
|
956
|
+
|
|
957
|
+
def criteria(self) -> dict[str, str]:
|
|
958
|
+
"""Every label with what it means: a choice over the whole task, in the task's order."""
|
|
959
|
+
return {
|
|
960
|
+
label: self.descriptions.get(label, label.replace("_", " ")) for label in self.labels
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
@property
|
|
964
|
+
def worst(self) -> float:
|
|
965
|
+
"""The dearest mistake the task can make, which scores zero. Passing an example to a
|
|
966
|
+
person is not a mistake, and nothing expects it, so it takes no part."""
|
|
967
|
+
passed = self.abstain.label if self.abstain is not None else None
|
|
968
|
+
answers = [label for label in self.labels if label != passed]
|
|
969
|
+
return max(self.costs.get(e, {}).get(a, 1.0) for e in answers for a in answers if e != a)
|
|
970
|
+
|
|
971
|
+
def baseline_plan(self) -> Plan:
|
|
972
|
+
"""A call-free majority-label seed, so search can start from the task alone. Graded
|
|
973
|
+
tasks may label nothing, and then the seed is the first label allowed."""
|
|
974
|
+
counts = Counter(e.label for e in self.train if e.label is not None)
|
|
975
|
+
return Plan.constant(counts.most_common(1)[0][0] if counts else self.labels[0])
|