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/bench.py ADDED
@@ -0,0 +1,1068 @@
1
+ """Public benchmarks: rulesmith against the baselines a reader would reach for first.
2
+
3
+ Each run draws a task from a public dataset with a seed, scores every method on the same held-out
4
+ test split, and records accuracy with the judge calls and tokens each example cost. A method a
5
+ run has already recorded is skipped, so an interrupted benchmark resumes where it stopped.
6
+ """
7
+
8
+ import argparse
9
+ import csv
10
+ import hashlib
11
+ import json
12
+ import logging
13
+ import math
14
+ import os
15
+ import random
16
+ import time
17
+ import urllib.request
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from statistics import mean, stdev
22
+ from typing import Literal
23
+
24
+ # pyarrow must load before dspy: DSPy 3.3 swaps numpy for a lazy proxy that breaks a compiled
25
+ # extension importing numpy afterwards ("numpy._core.multiarray failed to import").
26
+ import pyarrow.parquet as parquet # isort: skip
27
+ import dspy
28
+ from dspy.utils.exceptions import AdapterParseError
29
+
30
+ from rulesmith import calibrate
31
+ from rulesmith.graph import Plan, State, Task
32
+ from rulesmith.optimize import (
33
+ GraphProposer,
34
+ Labels,
35
+ SearchConfig,
36
+ evaluate_plan,
37
+ optimize_plan,
38
+ text_adapter,
39
+ )
40
+ from rulesmith.rules import compile_rules
41
+ from rulesmith.runtime import DecisionProgram
42
+ from rulesmith.tuning import direct_question
43
+
44
+ Rows = dict[str, list[tuple[State, str]]]
45
+ COVERAGES = (0.8, 0.5)
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Dataset:
50
+ """A public dataset as rows of (input, label), pooled by the dataset's own splits. Training
51
+ and validation draw from its 'train' pool and the test split from its 'test' pool, so nothing
52
+ a search sees overlaps what it is scored on."""
53
+
54
+ name: str
55
+ description: str
56
+ rows: Callable[[], Rows]
57
+ describe: Callable[[str], str]
58
+ # Draw every label equally, for a dataset whose rare label is the one that matters.
59
+ balanced: bool = False
60
+ # The rules a person would write in a few minutes, where a field obviously decides most cases.
61
+ handwritten: str | None = None
62
+
63
+ def prevalence(self) -> dict[str, float]:
64
+ """Each label's share of the dataset's own training pool: the rate real traffic has."""
65
+ found = [label for _, label in self.rows()["train"]]
66
+ return {label: found.count(label) / len(found) for label in sorted(set(found))}
67
+
68
+ def labels(self) -> dict[str, str]:
69
+ """Every label the dataset trains on, with what the direct question says about it."""
70
+ found = sorted({label for _, label in self.rows()["train"]})
71
+ return {label: self.describe(label) for label in found}
72
+
73
+
74
+ def sample(dataset: Dataset, train: int, validation: int, test: int, seed: int) -> Task:
75
+ """A task of the requested sizes, the same for the same seed. Repeated inputs are dropped,
76
+ since a task holds each input once."""
77
+ pools = dataset.rows()
78
+ rng = random.Random(seed)
79
+ seen: set[str] = set()
80
+
81
+ def order(pool: str) -> list[tuple[State, str]]:
82
+ rows = list(pools[pool])
83
+ rng.shuffle(rows)
84
+ if not dataset.balanced:
85
+ return rows
86
+ by_label: dict[str, list] = {}
87
+ for row in rows:
88
+ by_label.setdefault(row[1], []).append(row)
89
+ # Take one of each label in turn, so any prefix holds every label about equally.
90
+ groups = [by_label[label] for label in sorted(by_label)]
91
+ return [
92
+ group[i] for i in range(max(map(len, groups))) for group in groups if i < len(group)
93
+ ]
94
+
95
+ def draw(pool: str, count: int) -> list[dict]:
96
+ drawn = []
97
+ for state, label in order(pool):
98
+ key = json.dumps(state, sort_keys=True)
99
+ if key not in seen:
100
+ seen.add(key)
101
+ drawn.append({"state": state, "label": label})
102
+ if len(drawn) == count:
103
+ return drawn
104
+ raise ValueError(f"the {pool} pool of {dataset.name} holds fewer than {count} inputs")
105
+
106
+ held = draw("test", test)
107
+ searched = draw("train", train + validation)
108
+ # A balanced draw alternates labels, so split it without handing one split every even row.
109
+ rng.shuffle(searched)
110
+ splits = {"train": searched[:train], "validation": searched[train:], "test": held}
111
+ described = dataset.labels()
112
+ return Task(
113
+ description=dataset.description,
114
+ labels=sorted(described),
115
+ descriptions=described,
116
+ **{
117
+ split: [{"id": f"{split}-{index}", **row} for index, row in enumerate(rows)]
118
+ for split, rows in splits.items()
119
+ },
120
+ )
121
+
122
+
123
+ def direct_plan(task: Task) -> Plan:
124
+ """The simplest thing a judge can do: one choice question over every label, no graph."""
125
+ return Plan.model_validate(
126
+ {
127
+ "nodes": {
128
+ "label": {
129
+ "type": "call",
130
+ "question": direct_question(task),
131
+ "inputs": {"observation": {"type": "state"}},
132
+ }
133
+ },
134
+ "outputs": {"label": {"type": "node", "node": "label"}},
135
+ }
136
+ )
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class Answer:
141
+ """One test example as a method answered it, with what the answer cost."""
142
+
143
+ correct: bool
144
+ # How sure the method was, to rank answers by; None for a method that cannot say.
145
+ confidence: float | None = None
146
+ calls: int = 0
147
+ input_tokens: int = 0
148
+ output_tokens: int = 0
149
+ invalid: bool = False
150
+
151
+
152
+ def graph_answers(records: list[dict]) -> list[Answer]:
153
+ return [
154
+ Answer(
155
+ correct=bool(record["correct"]),
156
+ # A graph that failed to answer is not sure at all.
157
+ confidence=0.0 if record.get("error") else record["confidence"],
158
+ calls=len(record.get("calls", {})),
159
+ input_tokens=record.get("input_tokens", 0),
160
+ output_tokens=record.get("output_tokens", 0),
161
+ invalid=bool(record.get("error")),
162
+ )
163
+ for record in records
164
+ ]
165
+
166
+
167
+ def selective(answers: list[Answer], coverage: float) -> float | None:
168
+ """Accuracy on the share of examples a method was surest of: what it scores when it may pass
169
+ the rest to a person. None for a method with no confidence to rank by."""
170
+ if any(answer.confidence is None for answer in answers):
171
+ return None
172
+ ranked = sorted(answers, key=lambda answer: -answer.confidence)
173
+ kept = ranked[: max(1, math.ceil(coverage * len(ranked)))]
174
+ return mean(answer.correct for answer in kept)
175
+
176
+
177
+ def result(method: str, seed: int, answers: list[Answer], seconds: float, **extra) -> dict:
178
+ count = len(answers)
179
+ return {
180
+ "method": method,
181
+ "seed": seed,
182
+ "accuracy": mean(answer.correct for answer in answers),
183
+ **{f"accuracy_at_{round(k * 100)}": selective(answers, k) for k in COVERAGES},
184
+ "calls_per_example": sum(answer.calls for answer in answers) / count,
185
+ "tokens_per_example": sum(answer.input_tokens for answer in answers) / count,
186
+ "output_tokens_per_example": sum(answer.output_tokens for answer in answers) / count,
187
+ "seconds_per_example": seconds / count,
188
+ "invalid_rate": mean(answer.invalid for answer in answers),
189
+ # Kept per example, so two methods can be compared on the very same examples.
190
+ "correct": [answer.correct for answer in answers],
191
+ **extra,
192
+ }
193
+
194
+
195
+ def record(method: str, seed: int, task: Task, plan: Plan, client, model: str, **extra) -> dict:
196
+ """A graph scored on the task's test split."""
197
+ started = time.perf_counter()
198
+ scored = evaluate_plan(plan, task.test, client, model, Labels(task))
199
+ seconds = time.perf_counter() - started
200
+ real = calibrate.at_prevalence(task, scored["examples"])
201
+ if real is not None:
202
+ extra = extra | {"at_prevalence": real}
203
+ return result(method, seed, graph_answers(scored["examples"]), seconds, **extra)
204
+
205
+
206
+ def majority(seed: int, task: Task) -> dict:
207
+ run = record("majority", seed, task, task.baseline_plan(), None, "none")
208
+ # One label for everything ranks nothing, so it has no coverage to trade.
209
+ return run | {f"accuracy_at_{round(k * 100)}": None for k in COVERAGES}
210
+
211
+
212
+ def text(state: State) -> str:
213
+ return state if isinstance(state, str) else json.dumps(state, sort_keys=True)
214
+
215
+
216
+ def tfidf(seed: int, task: Task) -> dict:
217
+ """Logistic regression on word and word-pair counts, trained on everything search sees."""
218
+ from sklearn.feature_extraction.text import TfidfVectorizer
219
+ from sklearn.linear_model import LogisticRegression
220
+ from sklearn.pipeline import make_pipeline
221
+
222
+ seen = task.train + task.validation
223
+ model = make_pipeline(
224
+ TfidfVectorizer(ngram_range=(1, 2), sublinear_tf=True),
225
+ LogisticRegression(max_iter=2000),
226
+ )
227
+ model.fit([text(e.state) for e in seen], [e.label for e in seen])
228
+ started = time.perf_counter()
229
+ odds = model.predict_proba([text(e.state) for e in task.test])
230
+ seconds = time.perf_counter() - started
231
+ classes = list(model.classes_)
232
+ answers = [
233
+ Answer(
234
+ correct=classes[max(range(len(row)), key=row.__getitem__)] == e.label,
235
+ confidence=float(max(row)),
236
+ )
237
+ for row, e in zip(odds, task.test, strict=True)
238
+ ]
239
+ return result("tfidf", seed, answers, seconds)
240
+
241
+
242
+ def search(
243
+ seed: int, task: Task, client, model: str, proposer, budget: int, call_cost: float
244
+ ) -> dict:
245
+ """rulesmith's search, seeded with the direct call, so the comparison that matters is
246
+ whether a graph improves on asking the judge once."""
247
+ # 50 validation examples reward a lucky candidate; keep the direct call unless the gain holds.
248
+ config = SearchConfig(
249
+ max_metric_calls=budget,
250
+ seed=seed,
251
+ call_cost=call_cost,
252
+ significance=SIGNIFICANCE,
253
+ mine=True,
254
+ )
255
+ best, report = optimize_plan(task, direct_plan(task), client, model, proposer, config)
256
+ # Measured, its rules are as sure as they were right on the examples search saw, rather than
257
+ # certain, which is what ranking answers by confidence needs.
258
+ best = calibrate.measure(best, task, client, model)
259
+ return record(
260
+ "search",
261
+ seed,
262
+ task,
263
+ best,
264
+ client,
265
+ model,
266
+ validation=report["validation_score"],
267
+ seed_validation=report["seed_validation_score"],
268
+ kept_seed=report["kept_seed"],
269
+ mined=report["mined"],
270
+ nodes=len(best.nodes),
271
+ graph=best.model_dump(mode="json"),
272
+ )
273
+
274
+
275
+ def dspy_examples(split) -> list[dspy.Example]:
276
+ return [dspy.Example(text=text(e.state), label=e.label).with_inputs("text") for e in split]
277
+
278
+
279
+ def dspy_answers(program: dspy.Module, task: Task, student: dspy.LM) -> tuple[list[Answer], float]:
280
+ """A DSPy program's answers on the test split. A reply that names no allowed label is an
281
+ invalid answer, counted wrong, since a typed judgment cannot give one."""
282
+ answers, started = [], time.perf_counter()
283
+ with dspy.context(lm=student, adapter=text_adapter()):
284
+ for example in dspy_examples(task.test):
285
+ before = len(student.history)
286
+ try:
287
+ label = program(text=example.text).label
288
+ except AdapterParseError:
289
+ label = None
290
+ # A reply naming no allowed label, or a failure the program reported as no answer.
291
+ invalid = label is None
292
+ usage = [entry.get("usage") or {} for entry in student.history[before:]]
293
+ answers.append(
294
+ Answer(
295
+ correct=label == example.label,
296
+ calls=len(usage),
297
+ input_tokens=sum(u.get("prompt_tokens", 0) for u in usage),
298
+ output_tokens=sum(u.get("completion_tokens", 0) for u in usage),
299
+ invalid=invalid,
300
+ )
301
+ )
302
+ return answers, time.perf_counter() - started
303
+
304
+
305
+ def typed_program(task: Task, judge_url: str, judge: str, timeout: float) -> dspy.Predict:
306
+ """DSPy's own typed classifier on the judge: one choice question over the labels, asked as
307
+ the direct call asks it, with the task description in the state for DSPy's GEPA to rewrite.
308
+ DSPy's decision types are experimental, so they are imported where they are used."""
309
+ from dspy.experimental import Choice, TypeSafe
310
+
311
+ options = tuple((label, task.descriptions[label]) for label in task.labels)
312
+ signature = dspy.make_signature(
313
+ {
314
+ "text": (str, dspy.InputField()),
315
+ "label": (Choice[options], dspy.OutputField(desc=task.description)),
316
+ },
317
+ task.description,
318
+ )
319
+ program = dspy.Predict(signature)
320
+ program.set_lm(TypeSafe(judge, api_key="local-no-auth", base_url=judge_url, timeout=timeout))
321
+ return program
322
+
323
+
324
+ def typed_answers(program: dspy.Module, task: Task) -> tuple[list[Answer], float]:
325
+ """A typed DSPy program's answers on the test split, each one judge call, as sure as the
326
+ judge said. The judge is asked afresh: DSPy's cache, which the fit relies on, would make a
327
+ row asked during search cost nothing here."""
328
+ program = program.deepcopy()
329
+ lm = next(predict for _, predict in program.named_predictors()).lm.copy(cache=False)
330
+ program.set_lm(lm)
331
+ answers, started = [], time.perf_counter()
332
+ for example in dspy_examples(task.test):
333
+ before = len(lm.history)
334
+ label = program(text=example.text).label
335
+ usage = [entry.get("usage") or {} for entry in lm.history[before:]]
336
+ answers.append(
337
+ Answer(
338
+ correct=label is not None and label.value == example.label,
339
+ confidence=None if label is None else label.confidence,
340
+ calls=len(usage),
341
+ input_tokens=sum(u.get("prompt_tokens", 0) for u in usage),
342
+ output_tokens=sum(u.get("completion_tokens", 0) for u in usage),
343
+ invalid=label is None,
344
+ )
345
+ )
346
+ return answers, time.perf_counter() - started
347
+
348
+
349
+ def typed_metric(gold, predicted, trace=None, pred_name=None, pred_trace=None):
350
+ answered = None if predicted.label is None else predicted.label.value
351
+ right = answered == gold.label
352
+ return dspy.Prediction(
353
+ score=float(right),
354
+ feedback="Correct." if right else f"Expected {gold.label}, answered {answered}.",
355
+ )
356
+
357
+
358
+ def fitted_weights(program: dspy.Module, task: Task) -> tuple[dspy.Module, dict]:
359
+ """The program with its option weights fitted by DSPy's ReAnchor on the training examples,
360
+ and what the fit did: the training and validation scores before and after, and whether any
361
+ weight was kept."""
362
+ from dspy.experimental import ReAnchor
363
+
364
+ optimizer = ReAnchor(typed_metric, num_threads=DSPY_THREADS)
365
+ fitted = optimizer.compile(
366
+ program, trainset=dspy_examples(task.train), valset=dspy_examples(task.validation)
367
+ )
368
+ report = optimizer.report
369
+ return fitted, {
370
+ "train_before": report["train_score_before"],
371
+ "train_after": report["train_score"],
372
+ "validation_before": report.get("val_score_before"),
373
+ "validation_after": report.get("val_score"),
374
+ "kept": any("skipped" not in row for row in report["fitted"]),
375
+ }
376
+
377
+
378
+ def dspy_typed(
379
+ seed: int,
380
+ task: Task,
381
+ judge_url: str,
382
+ judge: str,
383
+ reflection: dspy.LM,
384
+ budget: int,
385
+ timeout: float,
386
+ ) -> dict:
387
+ """The fair DSPy comparison: its typed classifier on the same judge as the graphs, the
388
+ instructions the judge reads rewritten by DSPy's GEPA on the same budget as search, then
389
+ the option weights fitted by ReAnchor. What a graph gets from being a graph is what is left
390
+ over this."""
391
+ program = Failsafe(typed_program(task, judge_url, judge, timeout))
392
+ optimizer = dspy.GEPA(
393
+ metric=typed_metric,
394
+ max_metric_calls=budget,
395
+ reflection_lm=reflection,
396
+ seed=seed,
397
+ num_threads=DSPY_THREADS,
398
+ )
399
+ tuned = optimizer.compile(
400
+ program, trainset=dspy_examples(task.train), valset=dspy_examples(task.validation)
401
+ )
402
+ fitted, reanchor = fitted_weights(tuned, task)
403
+ answers, seconds = typed_answers(fitted, task)
404
+ return result("dspy-typed", seed, answers, seconds, reanchor=reanchor)
405
+
406
+
407
+ class Failsafe(dspy.Module):
408
+ """A DSPy program whose failure on one example is an answer of None, scored zero, as DSPy's
409
+ GEPA means a failed example to be. Its integration instead crashes the whole optimization
410
+ when an example comes back missing, which ended runs over one dropped connection or one
411
+ error inside DSPy. Each failure is logged; GEPA scores copies of the program, so a count kept
412
+ here would miss theirs."""
413
+
414
+ def __init__(self, program: dspy.Module):
415
+ super().__init__()
416
+ self.program = program
417
+ self.failures = 0
418
+
419
+ def forward(self, text: str) -> dspy.Prediction:
420
+ try:
421
+ return self.program(text=text)
422
+ except Exception as error: # any failure: DSPy counts it at failure_score, not a crash
423
+ self.failures += 1
424
+ logging.getLogger(__name__).warning("DSPy example failed: %r", error)
425
+ return dspy.Prediction(label=None)
426
+
427
+
428
+ def dspy_baseline(
429
+ seed: int,
430
+ task: Task,
431
+ student: dspy.LM,
432
+ reflection: dspy.LM,
433
+ budget: int,
434
+ reasoning: bool = False,
435
+ ) -> dict:
436
+ """A standard DSPy classifier on a text-generating model, optimized with DSPy's own GEPA.
437
+ With `reasoning` it thinks step by step first, the stronger and costlier standard form."""
438
+ signature = dspy.make_signature(
439
+ {
440
+ "text": (str, dspy.InputField()),
441
+ "label": (Literal[tuple(task.labels)], dspy.OutputField()),
442
+ },
443
+ task.description,
444
+ )
445
+ program = Failsafe(dspy.ChainOfThought(signature) if reasoning else dspy.Predict(signature))
446
+
447
+ def metric(gold, predicted, trace=None, pred_name=None, pred_trace=None):
448
+ right = predicted.label == gold.label
449
+ return dspy.Prediction(
450
+ score=float(right),
451
+ feedback="Correct." if right else f"Expected {gold.label}, answered {predicted.label}.",
452
+ )
453
+
454
+ with dspy.context(lm=student, adapter=text_adapter()):
455
+ optimizer = dspy.GEPA(
456
+ metric=metric,
457
+ max_metric_calls=budget,
458
+ reflection_lm=reflection,
459
+ seed=seed,
460
+ num_threads=DSPY_THREADS,
461
+ )
462
+ tuned = optimizer.compile(
463
+ program, trainset=dspy_examples(task.train), valset=dspy_examples(task.validation)
464
+ )
465
+ answers, seconds = dspy_answers(tuned, task, student)
466
+ return result("dspy-cot" if reasoning else "dspy", seed, answers, seconds)
467
+
468
+
469
+ def mcnemar(first: list[bool], second: list[bool]) -> float:
470
+ """Exact two-sided McNemar p-value: could two methods scored on the same examples differ
471
+ this much by chance? Only the examples exactly one of them got right count."""
472
+ only_first = sum(a and not b for a, b in zip(first, second, strict=True))
473
+ only_second = sum(b and not a for a, b in zip(first, second, strict=True))
474
+ split = only_first + only_second
475
+ tail = sum(math.comb(split, k) for k in range(min(only_first, only_second) + 1)) / 2**split
476
+ return min(1.0, 2 * tail)
477
+
478
+
479
+ def summarize(runs: list[dict]) -> dict[str, dict]:
480
+ """Each method averaged over seeds, with the spread of its accuracy between seeds."""
481
+ methods: dict[str, list[dict]] = {}
482
+ for run in runs:
483
+ methods.setdefault(run["method"], []).append(run)
484
+
485
+ def averaged(done: list[dict], key: str) -> float | None:
486
+ values = [run.get(key) for run in done]
487
+ return None if any(value is None for value in values) else mean(values)
488
+
489
+ fields = (
490
+ "calls_per_example",
491
+ "tokens_per_example",
492
+ "output_tokens_per_example",
493
+ "seconds_per_example",
494
+ "invalid_rate",
495
+ *(f"accuracy_at_{round(k * 100)}" for k in COVERAGES),
496
+ )
497
+ return {
498
+ method: {
499
+ "seeds": len(done),
500
+ "accuracy": mean(run["accuracy"] for run in done),
501
+ "spread": stdev(run["accuracy"] for run in done) if len(done) > 1 else 0.0,
502
+ **{field: averaged(done, field) for field in fields},
503
+ }
504
+ for method, done in methods.items()
505
+ }
506
+
507
+
508
+ def against(runs: list[dict], method: str) -> dict[str, float]:
509
+ """McNemar p-values of `method` against every other, over the seeds both finished, their
510
+ test examples pooled."""
511
+ by_seed = {(run["method"], run["seed"]): run["correct"] for run in runs}
512
+ found = {}
513
+ for other in sorted({run["method"] for run in runs} - {method}):
514
+ seeds = sorted(s for m, s in by_seed if m == method and (other, s) in by_seed)
515
+ if seeds:
516
+ mine = [c for s in seeds for c in by_seed[method, s]]
517
+ theirs = [c for s in seeds for c in by_seed[other, s]]
518
+ found[other] = mcnemar(mine, theirs)
519
+ return found
520
+
521
+
522
+ def report(results: dict) -> str:
523
+ """The results as a Markdown table, best accuracy first, and whether search's differences
524
+ from each other method could be chance."""
525
+
526
+ def shown(value: float | None, digits: str = ".3f") -> str:
527
+ return "n/a" if value is None else format(value, digits)
528
+
529
+ rows = sorted(summarize(results["runs"]).items(), key=lambda item: -item[1]["accuracy"])
530
+ lines = [
531
+ f"{results['dataset']}, {results['test']} test examples per seed",
532
+ "",
533
+ "| Method | Accuracy | At 80% | At 50% | Seeds | Judge or LM calls | Input tokens "
534
+ "| Output tokens | Seconds | Invalid |",
535
+ "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
536
+ ]
537
+ for method, row in rows:
538
+ lines.append(
539
+ f"| {method} | {row['accuracy']:.3f} ± {row['spread']:.3f} | "
540
+ f"{shown(row['accuracy_at_80'])} | {shown(row['accuracy_at_50'])} | {row['seeds']} | "
541
+ f"{shown(row['calls_per_example'], '.2f')} | {shown(row['tokens_per_example'], '.0f')} "
542
+ f"| {shown(row['output_tokens_per_example'], '.0f')} | "
543
+ f"{shown(row['seconds_per_example'], '.2f')} | {shown(row['invalid_rate'], '.1%')} |"
544
+ )
545
+ compared = against(results["runs"], "search")
546
+ if compared:
547
+ lines += ["", "Search against each method, exact McNemar p over pooled test examples:", ""]
548
+ lines += [f"- {other}: p = {p:.3g}" for other, p in compared.items()]
549
+ return "\n".join(lines)
550
+
551
+
552
+ @dataclass(frozen=True)
553
+ class Models:
554
+ """What the model-backed methods run on. Unused by majority and tfidf."""
555
+
556
+ client: object = None
557
+ judge: str = "none"
558
+ proposer: object = None
559
+ student: dspy.LM | None = None
560
+ reflection: dspy.LM | None = None
561
+ budget: int = 0
562
+ call_cost: float = 0
563
+ judge_url: str = ""
564
+ timeout: float = 10
565
+
566
+
567
+ def run(
568
+ dataset: Dataset,
569
+ seeds: list[int],
570
+ methods: list[str],
571
+ sizes: dict[str, int],
572
+ output: Path,
573
+ models: dict[str, str],
574
+ resources: Models | None = None,
575
+ deadline: float = math.inf,
576
+ real_rate: bool = False,
577
+ ) -> list[dict]:
578
+ """Every method on every seed, written to `output` after each one finishes. Seeds go in
579
+ order, each finishing every method before the next begins, so a run cut short by `deadline`
580
+ (seconds since the epoch) loses whole seeds rather than whole methods. With `real_rate`, each
581
+ task carries the dataset's own label rates as its prevalence, so search and scoring weigh
582
+ each example as the real traffic it stands for."""
583
+ resources = resources or Models()
584
+ results = {"dataset": dataset.name, **sizes, "real_rate": real_rate, "runs": []}
585
+ if output.exists():
586
+ results = json.loads(output.read_text())
587
+ if {key: results[key] for key in sizes} != sizes:
588
+ raise ValueError(f"{output} holds runs of other sizes; write to a new file")
589
+ if results.get("real_rate", False) != real_rate:
590
+ raise ValueError(f"{output} holds runs at another rate (real_rate={not real_rate})")
591
+ done = {(run["method"], run["seed"]) for run in results["runs"]}
592
+ for seed in seeds:
593
+ task = sample(dataset, sizes["train"], sizes["validation"], sizes["test"], seed)
594
+ if real_rate:
595
+ task = Task.model_validate(task.model_dump() | {"prevalence": dataset.prevalence()})
596
+ for method in methods:
597
+ if (method, seed) in done:
598
+ continue
599
+ if time.time() > deadline:
600
+ return results["runs"]
601
+ # Per run, since a resumed file may be finished with other models than started it.
602
+ result = measure(method, seed, task, dataset, resources) | {"models": models}
603
+ results["runs"].append(result)
604
+ output.parent.mkdir(parents=True, exist_ok=True)
605
+ output.write_text(json.dumps(results, indent=2) + "\n")
606
+ return results["runs"]
607
+
608
+
609
+ def measure(method: str, seed: int, task: Task, dataset: Dataset, resources: Models) -> dict:
610
+ if method == "majority":
611
+ return majority(seed, task)
612
+ if method == "tfidf":
613
+ return tfidf(seed, task)
614
+ if method == "direct":
615
+ plan = direct_plan(task)
616
+ return record("direct", seed, task, plan, resources.client, resources.judge)
617
+ if method == "handwritten":
618
+ plan = calibrate.measure(
619
+ compile_rules(dataset.handwritten, task.criteria()),
620
+ task,
621
+ resources.client,
622
+ resources.judge,
623
+ )
624
+ return record("handwritten", seed, task, plan, resources.client, resources.judge)
625
+ if method == "search":
626
+ return search(
627
+ seed,
628
+ task,
629
+ resources.client,
630
+ resources.judge,
631
+ resources.proposer,
632
+ resources.budget,
633
+ resources.call_cost,
634
+ )
635
+ if method == "dspy-typed":
636
+ return dspy_typed(
637
+ seed,
638
+ task,
639
+ resources.judge_url,
640
+ resources.judge,
641
+ resources.reflection,
642
+ resources.budget,
643
+ resources.timeout,
644
+ )
645
+ return dspy_baseline(
646
+ seed,
647
+ task,
648
+ resources.student,
649
+ resources.reflection,
650
+ resources.budget,
651
+ reasoning=method == "dspy-cot",
652
+ )
653
+
654
+
655
+ def elo_against(reference: float, score: float) -> float | None:
656
+ """The rating that expects `score` against an opponent rated `reference`, by the logistic
657
+ Elo curve. A clean sweep either way has no finite answer."""
658
+ if not 0 < score < 1:
659
+ return None
660
+ return reference + 400 * math.log10(score / (1 - score))
661
+
662
+
663
+ def chess_reference(
664
+ task, graphs: list[Path], engine, games: int, rating: float, output: Path
665
+ ) -> list[dict]:
666
+ """Every graph against one engine over the same seeds, colors alternating by seed, written
667
+ to `output` after each graph. A graph already there is skipped."""
668
+ from rulesmith.chess import Games
669
+ from rulesmith.cli import read_plan
670
+
671
+ results = json.loads(output.read_text()) if output.exists() else {"runs": []}
672
+ done = {run["graph"] for run in results["runs"]}
673
+ cli = argparse.ArgumentParser()
674
+ for path in graphs:
675
+ if path.stem in done:
676
+ continue
677
+ program = DecisionProgram(read_plan(cli, path), None, "none")
678
+ scorer = Games(task, engine)
679
+ records = [scorer(program, seed).record for seed in range(games)]
680
+ points = [record["points"] for record in records]
681
+ score = mean(points)
682
+ results["runs"].append(
683
+ {
684
+ "graph": path.stem,
685
+ "games": games,
686
+ "won": points.count(1.0),
687
+ "drew": points.count(0.5),
688
+ "lost": points.count(0.0),
689
+ "score": score,
690
+ "material": mean(record["material"] for record in records),
691
+ # A graph that loses every game still differs in how long it holds out.
692
+ "plies": mean(record["plies"] for record in records),
693
+ "elo": elo_against(rating, score),
694
+ }
695
+ )
696
+ output.parent.mkdir(parents=True, exist_ok=True)
697
+ output.write_text(json.dumps(results, indent=2) + "\n")
698
+ return results["runs"]
699
+
700
+
701
+ def language_model(
702
+ model: str | None,
703
+ base_url: str | None,
704
+ options: str | None,
705
+ max_tokens: int,
706
+ timeout: float,
707
+ retries: int,
708
+ ) -> dspy.LM | None:
709
+ """A generative model, given its DSPy id. `options` is extra request JSON, such as switching
710
+ off a model's thinking so a one-word label does not cost a page of reasoning. A failed request
711
+ is retried: a local server under several runs' load drops connections, and DSPy's GEPA ends
712
+ the whole run on a single missing answer."""
713
+ if model is None:
714
+ return None
715
+ extra = {"extra_body": json.loads(options)} if options else {}
716
+ endpoint = {"api_base": base_url} if base_url else {}
717
+ return dspy.LM(
718
+ model,
719
+ cache=False,
720
+ num_retries=retries,
721
+ max_tokens=max_tokens,
722
+ timeout=timeout,
723
+ **endpoint,
724
+ **extra,
725
+ )
726
+
727
+
728
+ def judge_client(base_url: str, timeout: float):
729
+ """The local judge, waiting as long as a generative request may: every run of a benchmark
730
+ queues on the same judge, and the SDK's ten seconds ended runs that were only waiting."""
731
+ from typesafe_sdk import TypeSafeClient
732
+
733
+ return TypeSafeClient(base_url=base_url, api_key="local-no-auth", timeout=timeout)
734
+
735
+
736
+ def main() -> None:
737
+ root = argparse.ArgumentParser(description=__doc__)
738
+ commands = root.add_subparsers(dest="command", required=True)
739
+ bench = commands.add_parser("run", help="Run methods over seeds, resuming a results file")
740
+ bench.add_argument("dataset", choices=sorted(DATASETS))
741
+ bench.add_argument("--seeds", type=int, nargs="+", default=[0, 1, 2])
742
+ bench.add_argument("--methods", nargs="+", default=list(METHODS), choices=METHODS)
743
+ for split, default in (("train", 50), ("validation", 50), ("test", 300)):
744
+ bench.add_argument(f"--{split}", type=int, default=default)
745
+ bench.add_argument("--budget", type=int, default=150, help="Metric calls per search")
746
+ bench.add_argument(
747
+ "--call-cost",
748
+ type=float,
749
+ default=0.02,
750
+ help="Score search takes off per judge question: a whole call is worth two points",
751
+ )
752
+ bench.add_argument("--judge-base-url", default="http://127.0.0.1:8010")
753
+ bench.add_argument("--judge-model", default="default")
754
+ for role in ("proposer", "student", "reflection"):
755
+ bench.add_argument(f"--{role}-model")
756
+ bench.add_argument(f"--{role}-base-url")
757
+ bench.add_argument(f"--{role}-options", help="Extra request JSON for this model")
758
+ bench.add_argument(
759
+ "--max-tokens",
760
+ type=int,
761
+ default=16384,
762
+ help="Per reply; a proposal restating 77 labels with its reasoning needs room",
763
+ )
764
+ bench.add_argument(
765
+ "--student-max-tokens",
766
+ type=int,
767
+ default=2048,
768
+ help="Per DSPy reply: a label with its reasoning fits, and a local server reserves "
769
+ "memory for this many tokens in every reply it is generating at once",
770
+ )
771
+ bench.add_argument(
772
+ "--note", help="How the judge was served, e.g. RULING_ROTATIONS=1, kept with the results"
773
+ )
774
+ bench.add_argument(
775
+ "--timeout",
776
+ type=float,
777
+ default=3600,
778
+ help="Seconds a generative request may take; runs sharing one local model queue behind "
779
+ "each other",
780
+ )
781
+ bench.add_argument(
782
+ "--hours",
783
+ type=float,
784
+ help="Start nothing new after this many hours; finished results are kept either way",
785
+ )
786
+ bench.add_argument(
787
+ "--retries", type=int, default=10, help="Retries for a failed generative request"
788
+ )
789
+ bench.add_argument(
790
+ "--real-rate",
791
+ action="store_true",
792
+ help="Weigh each example as the real traffic it stands for, at the dataset's own label "
793
+ "rates, in search and in scoring; graph results report precision and recall there",
794
+ )
795
+ bench.add_argument("--output", type=Path, required=True)
796
+ games = commands.add_parser("chess", help="Play chess graphs against a fixed-strength engine")
797
+ games.add_argument("task", type=Path, help="A chess task; its move limit and openings apply")
798
+ games.add_argument("graphs", type=Path, nargs="+")
799
+ games.add_argument("--engine", required=True, help="Path to a UCI engine such as stockfish")
800
+ games.add_argument("--skill", type=int, default=0, help="Stockfish Skill Level, 0 to 20")
801
+ games.add_argument(
802
+ "--rating", type=float, default=1320, help="The engine's rating at that skill"
803
+ )
804
+ games.add_argument("--seconds", type=float, default=0.05, help="Engine time per move")
805
+ games.add_argument("--games", type=int, default=40)
806
+ games.add_argument("--output", type=Path, required=True)
807
+ shown = commands.add_parser("report", help="Print a results file as a Markdown table")
808
+ shown.add_argument("results", type=Path)
809
+ args = root.parse_args()
810
+ if args.command == "report":
811
+ print(report(json.loads(args.results.read_text())))
812
+ return
813
+ if args.command == "chess":
814
+ from rulesmith.chess import ChessTask, Engine
815
+
816
+ task = ChessTask.model_validate_json(args.task.read_text())
817
+ with Engine(args.engine, args.skill, args.seconds) as engine:
818
+ runs = chess_reference(task, args.graphs, engine, args.games, args.rating, args.output)
819
+ print(json.dumps(runs, indent=2))
820
+ return
821
+ lms = {
822
+ role: language_model(
823
+ getattr(args, f"{role}_model"),
824
+ getattr(args, f"{role}_base_url"),
825
+ getattr(args, f"{role}_options"),
826
+ args.student_max_tokens if role == "student" else args.max_tokens,
827
+ args.timeout,
828
+ args.retries,
829
+ )
830
+ for role in ("proposer", "student", "reflection")
831
+ }
832
+ needed = {"search": "proposer", "dspy": "student", "dspy-cot": "student"}
833
+ for method, role in needed.items():
834
+ if method in args.methods and lms[role] is None:
835
+ root.error(f"{method} needs --{role}-model")
836
+ if "dspy-typed" in args.methods and not (lms["reflection"] or lms["proposer"]):
837
+ root.error("dspy-typed needs --reflection-model or --proposer-model")
838
+ dataset = DATASETS[args.dataset]
839
+ if "handwritten" in args.methods and dataset.handwritten is None:
840
+ root.error(f"{dataset.name} has no hand-written rules to run")
841
+ sizes = {split: getattr(args, split) for split in ("train", "validation", "test")}
842
+ models = {"judge": args.judge_model, "note": args.note or "", "call_cost": args.call_cost} | {
843
+ role: getattr(args, f"{role}_model") for role in lms if getattr(args, f"{role}_model")
844
+ }
845
+ with judge_client(args.judge_base_url, args.timeout) as client:
846
+ resources = Models(
847
+ client=client,
848
+ judge=args.judge_model,
849
+ proposer=GraphProposer(lms["proposer"]) if lms["proposer"] else None,
850
+ student=lms["student"],
851
+ reflection=lms["reflection"] or lms["proposer"],
852
+ budget=args.budget,
853
+ call_cost=args.call_cost,
854
+ judge_url=args.judge_base_url,
855
+ timeout=args.timeout,
856
+ )
857
+ deadline = time.time() + args.hours * 3600 if args.hours else math.inf
858
+ run(
859
+ dataset,
860
+ args.seeds,
861
+ args.methods,
862
+ sizes,
863
+ args.output,
864
+ models,
865
+ resources,
866
+ deadline,
867
+ real_rate=args.real_rate,
868
+ )
869
+ print(report(json.loads(args.output.read_text())))
870
+
871
+
872
+ def cached(url: str, name: str, sha1: str | None = None) -> Path:
873
+ """A downloaded file, fetched once. Datasets stay out of the repo; their licenses allow
874
+ use, and fetching from the source keeps attribution where the publisher put it. With a
875
+ published checksum, a file that differs is refused rather than measured."""
876
+ path = Path(os.environ.get("RULESMITH_CACHE", Path.home() / ".cache" / "rulesmith")) / name
877
+ if not path.exists():
878
+ with urllib.request.urlopen(url, timeout=60) as response:
879
+ data = response.read()
880
+ if sha1 is not None and hashlib.sha1(data).hexdigest() != sha1:
881
+ raise ValueError(f"{url} does not match its published checksum {sha1}")
882
+ path.parent.mkdir(parents=True, exist_ok=True)
883
+ path.write_bytes(data)
884
+ return path
885
+
886
+
887
+ def banking77() -> Rows:
888
+ """PolyAI's Banking77 (CC BY 4.0): 13,083 banking queries across 77 intents."""
889
+ base = "https://raw.githubusercontent.com/PolyAI-LDN/task-specific-datasets/master/banking_data"
890
+ rows = {}
891
+ for split in ("train", "test"):
892
+ with cached(f"{base}/{split}.csv", f"banking77/{split}.csv").open(newline="") as file:
893
+ rows[split] = [(row["text"], row["category"]) for row in csv.DictReader(file)]
894
+ return rows
895
+
896
+
897
+ def hub(repo: str, revision: str, path: str) -> Path:
898
+ """A file from a Hugging Face dataset, pinned to the revision these results came from."""
899
+ from huggingface_hub import hf_hub_download
900
+
901
+ return Path(hf_hub_download(repo, path, repo_type="dataset", revision=revision))
902
+
903
+
904
+ def clothing() -> Rows:
905
+ """Women's E-Commerce Clothing Reviews (CC0): whether a reviewer recommends the item, from
906
+ their rating, age, the product's department and the review itself. The rating settles most
907
+ cases; a three-star review needs its text read."""
908
+ repo, revision = "saattrupdan/womens-clothing-ecommerce-reviews", CLOTHING_REVISION
909
+ files = {
910
+ "train": "data/train-00000-of-00001-44ddebcb331919e6.parquet",
911
+ "test": "data/test-00000-of-00001-90b9c3d0edf6fc3c.parquet",
912
+ }
913
+ rows = {}
914
+ for split, path in files.items():
915
+ table = parquet.read_table(hub(repo, revision, path)).to_pylist()
916
+ rows[split] = [
917
+ (
918
+ {
919
+ "rating": row["rating"],
920
+ "age": row["age"],
921
+ "helpful_votes": row["positive_feedback_count"],
922
+ "division": row["division_name"],
923
+ "department": row["department_name"],
924
+ "category": row["class_name"],
925
+ "review": row["review_text"],
926
+ },
927
+ "recommended" if row["recommended_ind"] else "not_recommended",
928
+ )
929
+ for row in table
930
+ ]
931
+ return rows
932
+
933
+
934
+ def trec() -> Rows:
935
+ """TREC question classification (Li and Roth), coarse: the kind of answer a question asks
936
+ for. A small, fast sanity check; its license is not stated, so it is for research use."""
937
+ rows = {}
938
+ for split in ("train", "test"):
939
+ with hub("SetFit/TREC-QC", TREC_REVISION, f"{split}.jsonl").open() as file:
940
+ lines = [json.loads(line) for line in file]
941
+ rows[split] = [(line["text"], line["label_coarse_text"]) for line in lines]
942
+ return rows
943
+
944
+
945
+ def jobs() -> Rows:
946
+ """Real or Fake Job Postings (CC0): whether a posting is a scam, from its structured fields
947
+ (company logo, screening questions, salary, employment type) and its text. The source has one
948
+ file, so every fifth posting is held out as the test pool."""
949
+ path = hub(JOBS_REPO, JOBS_REVISION, "fake_job_postings.csv")
950
+ with path.open(newline="") as file:
951
+ postings = list(csv.DictReader(file))
952
+ rows: Rows = {"train": [], "test": []}
953
+ for index, row in enumerate(postings):
954
+ state = {
955
+ "title": row["title"],
956
+ "location": row["location"],
957
+ "salary_range": row["salary_range"],
958
+ "employment_type": row["employment_type"],
959
+ "required_experience": row["required_experience"],
960
+ "required_education": row["required_education"],
961
+ "industry": row["industry"],
962
+ "function": row["function"],
963
+ "telecommuting": int(row["telecommuting"]),
964
+ "has_company_logo": int(row["has_company_logo"]),
965
+ "has_questions": int(row["has_questions"]),
966
+ "company_profile": row["company_profile"],
967
+ "description": row["description"],
968
+ }
969
+ label = "fraudulent" if row["fraudulent"] == "1" else "genuine"
970
+ rows["test" if index % 5 == 0 else "train"].append((state, label))
971
+ return rows
972
+
973
+
974
+ def news() -> Rows:
975
+ """Which channel a Mashable article ran in, from its title and sixteen numbers about its text
976
+ (length, links, images, sentiment): UCI's Online News Popularity (CC BY 4.0) as the
977
+ multimodal AutoML benchmark of Shi et al. (2021) prepared it. Chosen before any result, and
978
+ used for nothing while rulesmith was built, as a check on the other datasets."""
979
+ base = "https://automl-mm-bench.s3.amazonaws.com/news_channel"
980
+ rows = {}
981
+ for split, digest in NEWS_SHA1.items():
982
+ path = cached(f"{base}/{split}.csv", f"news_channel/{split}.csv", digest)
983
+ with path.open(newline="") as file:
984
+ table = [
985
+ {key.strip(): value.strip() for key, value in row.items()}
986
+ for row in csv.DictReader(file)
987
+ ]
988
+ rows[split] = [
989
+ (
990
+ {"title": row.pop("article_title")}
991
+ | {key: float(value) for key, value in row.items() if key != "channel"},
992
+ CHANNELS[row["channel"].removeprefix("data_channel_is_")],
993
+ )
994
+ for row in table
995
+ ]
996
+ return rows
997
+
998
+
999
+ def spelled(label: str) -> str:
1000
+ """A label described by its own words, the most a direct question gets for free."""
1001
+ return label.replace("_", " ")
1002
+
1003
+
1004
+ CLOTHING_REVISION = "4e788183125a6143d8bdba24e58f1efda2e4b48f"
1005
+ TREC_REVISION = "0a34640b3cac3affef6f0abdfb81e3d0ecffcf92"
1006
+ JOBS_REPO = "victor/real-or-fake-fake-jobposting-prediction"
1007
+ JOBS_REVISION = "a751f55136a26da3c36e26aa207a9e187ca24b45"
1008
+ SIGNIFICANCE = 0.1
1009
+ # DSPy evaluates eight examples at once by default; five datasets doing that at once swamp one
1010
+ # local model server, and a connection it drops ends the run inside DSPy's GEPA.
1011
+ DSPY_THREADS = 4
1012
+ NEWS_SHA1 = {
1013
+ "train": "ab226210b6a878b449d01f33a195014c65c22311",
1014
+ "test": "a71516784ce6e168bd9933e9ec50080f65cb05fd",
1015
+ }
1016
+ CHANNELS = {
1017
+ "bus": "business",
1018
+ "entertainment": "entertainment",
1019
+ "lifestyle": "lifestyle",
1020
+ "socmed": "social_media",
1021
+ "tech": "tech",
1022
+ "world": "world",
1023
+ }
1024
+ METHODS = ("majority", "tfidf", "direct", "handwritten", "search", "dspy", "dspy-cot", "dspy-typed")
1025
+ DATASETS: dict[str, Dataset] = {
1026
+ "banking77": Dataset(
1027
+ "banking77",
1028
+ "Which intent does this online banking customer's message express?",
1029
+ banking77,
1030
+ spelled,
1031
+ ),
1032
+ "clothing": Dataset(
1033
+ "clothing",
1034
+ "Would this shopper recommend the clothing item they reviewed?",
1035
+ clothing,
1036
+ spelled,
1037
+ handwritten="\n".join(
1038
+ [
1039
+ "read rating in [1, 5]",
1040
+ "when rating >= 4: recommended",
1041
+ "when rating < 3: not_recommended",
1042
+ 'always: ask "Would this shopper recommend the clothing item they reviewed?" '
1043
+ '[recommended: "They would recommend it."] '
1044
+ '[not_recommended: "They would not recommend it."]',
1045
+ ]
1046
+ ),
1047
+ ),
1048
+ "jobs": Dataset(
1049
+ "jobs",
1050
+ "Is this job posting a scam?",
1051
+ jobs,
1052
+ spelled,
1053
+ balanced=True,
1054
+ handwritten="\n".join(
1055
+ [
1056
+ "read has_company_logo in [0, 1]",
1057
+ "when has_company_logo >= 1: genuine",
1058
+ 'always: ask "Is this job posting a scam?" '
1059
+ '[fraudulent: "It is a scam."] [genuine: "It is a real job."]',
1060
+ ]
1061
+ ),
1062
+ ),
1063
+ "news": Dataset("news", "Which channel did this article run in?", news, spelled),
1064
+ "trec": Dataset("trec", "What kind of answer does this question ask for?", trec, spelled),
1065
+ }
1066
+
1067
+ if __name__ == "__main__":
1068
+ main()