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/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])