Skip to content

Commit 5198faf

Browse files
authored
Merge #3: modalities, interfaces, Jinja and turns, MCP schemas from the server, models chosen by evals
Modalities, interfaces, Jinja and turns, MCP schemas from the server, models chosen by evals
2 parents ce25fbf + a8f10a2 commit 5198faf

82 files changed

Lines changed: 8109 additions & 100 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,76 @@ Running PACT trees in production on Pydantic AI 2.54, through
1010
addition is checked by `pact check` (Rust) and held by the Python harness, and the whole suite
1111
runs offline: 4114 tests, 1445 Rust and 2669 adapter.
1212

13+
### A pinned model's settings, held to what it takes (1 November)
14+
15+
- **`model-can.ignores`, `ignores-while-thinking`, `thinks-unless-told`** on a catalogue row, read
16+
from the Pydantic AI code this repository pins and recorded for GPT-5.5, GPT-5.4, GPT-5.4 mini,
17+
Claude Opus 5 and Claude Sonnet 5. `pact check` refuses a `settings:` line the pinned model
18+
would throw away (`settings.rs`: `loader/a-setting-the-model-ignores`,
19+
`loader/a-setting-ignored-while-it-thinks`) — `temperature: 0.2` on GPT-5.5 with no
20+
`thinking: none`, `top-k:` on any OpenAI model, any sampling on Claude Opus 5. Bud Agent Flow's
21+
`choose-model` and its bind-time check hold candidates and bindings to the same reading
22+
(`ModelEntry.ignored_settings`).
23+
24+
### Choosing a model by evals: benchmarks, your own checks, cost (28 October)
25+
26+
- **`needs.accept-when:`** — the figures a model has to reach to be used for an agent, one
27+
comparison per line, all of which hold: `mmlu.score > 50`, `terminal-bench.score >= 40`,
28+
`refund-tone.score >= 0.8` (any metric of the suite by its new `name:`, a DeepEval G-Eval
29+
included), `pass-rate >= 90%`, `cost < 0.05 USD`, `input-price`, `output-price`,
30+
`context-window`, `first-words-within`. Each name has its own unit and is refused where it is
31+
written when it does not fit (`accept.rs`, ten rules); `spec/accept-when.yaml` holds the Rust
32+
checker and the Python resolver to one reading. Catalogue figures are held before anything runs
33+
(`ModelEntry.satisfies`), suite figures after the candidate runs its suite
34+
(`selection.held_after`; `CaseOutcome.scores` keeps every metric score).
35+
- **Named benchmarks.** `models/catalog.yaml` declares `benchmarks:` — seventeen ship, eleven
36+
measured by DeepEval (`runs: deepeval:MMLU`) and six by their own harness (`runs:
37+
host:terminal-bench`) — each with its aliases and scale; a row's score under a name no
38+
benchmark has is refused. `benchmarks.record` writes a figure with its provenance, and copies the
39+
distribution's row first so recording a score never drops where a model is served.
40+
- **In Bud Agent Flow:** `budflow choose-model --candidate URL#MODEL …` runs the suite on each
41+
candidate and chooses by `accept-when:` and `prefer:`; `budflow benchmark record|run` writes a
42+
figure; `budflow run` refuses a bound model whose catalogue figures miss a line.
43+
44+
### Modalities, interfaces, templates, turns and tool schemas from the server (27 October)
45+
46+
Designed in [`docs/96-MODALITIES-INTERFACES-TEMPLATES-TURNS.md`](docs/96-MODALITIES-INTERFACES-TEMPLATES-TURNS.md),
47+
which also lists what is still pending at run time.
48+
49+
- **Voice, documents, video, embeddings.** Two answer shapes, `documents` and `video`. Six model
50+
kinds (`language`, `decision`, `embedding`, `transcription`, `speech`, `reranking`), held per
51+
line by a new `model-kind:` attribute (`loader/the-wrong-kind-of-model`). `agent.voice:` says how
52+
an agent listens and speaks — the pipeline, who may interrupt, the transcriber, the speaker and
53+
its voice, the languages, how long a pause ends a turn, whether the sound is kept — each line
54+
held against the catalogue (`voice.rs`). A transcriber and speaker on this machine now keep a
55+
caller's voice here behind a hosted language model, needing only `llm` in `allow-egress:`.
56+
`knowledge.embedded-by:` and `reranked-by:` name the models a set of documents is indexed and
57+
ordered with, which makes meaning-search writable on an air-gapped box for the first time.
58+
- **`needs.scores:` can be satisfied.** A catalogue row may now record `benchmarks:` (it was
59+
refused, so every score requirement emptied the candidate set), and `speed:`.
60+
`needs.prefer: cheapest | fastest | best-on-the-evals` orders the models that pass.
61+
- **Interfaces.** `interface: yes`, `implements:` and `chosen-by:` on tools, skills and sets of
62+
documents: one `email` contract, Gmail and Outlook as its implementations, picked per person by
63+
the host or per run by a run input. An implementation says only how it reaches its server
64+
(`calls:`, `passes:`), may ask more of a person and never less, and is written out longhand
65+
from its interface by the loader (`interfaces.rs`, fourteen rules). `tool.category:` labels a
66+
tool for finding.
67+
- **Jinja.** `template-language: jinja` lets an agent's words choose and repeat, in the dialect
68+
Jinja2 and MiniJinja both render one way — no includes, macros or calls, the shared filters
69+
only, names written with `_` (`jinja.rs`; `holes.fill(language="jinja")` renders it sandboxed).
70+
Which texts are filled is now `template: yes` in the schema.
71+
- **Turns.** `agent.turns:`, `variant.turns:` and `case.turns:`, from `system`, `developer`,
72+
`user`, `agent` and `tool`, with tool calls and attachments; standing instructions only at the
73+
start, every call answered (`turns.rs`).
74+
- **Tool schemas from the server.** `pact-sync <server>` records what an MCP server offers into
75+
`resources/<server>.yaml` as `published:`; `tool.schemas-from: the-snapshot | the-server` sends
76+
the server's own arguments while which actions may be called, and what needs a person, stay
77+
written here. `pact check` holds `inspects:`, `same-request-key:`, `bind:` and `passes:` to the
78+
recorded arguments (`snapshots.rs`); `resource.if-it-changes:` says what a run does when the
79+
server changed, and a change an approval rests on always stops it
80+
(`mcp_bridge.when_it_changed`). `tool.unlisted-actions: each-call-asks-a-person` offers what the
81+
server adds behind a person.
82+
1383
### Tests over recorded runs (18 October)
1484

1585
- **A suite says which runs it is a score of.** `population: past-runs` with `from-the-last:`

‎Cargo.lock‎

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ thiserror = "2"
4747
unicode-normalization = "0.1"
4848
sha2 = "0.10"
4949
yaml-rust2 = "0.11"
50+
# 96 §C. The checker for `template-language: jinja`. Pinned EXACTLY, and with
51+
# `unstable_machinery`, because what `holes.rs` reads is the compiled
52+
# instruction list — every filter, test, function and include a template uses —
53+
# and that list is the crate's unstable surface. Nothing renders here: the
54+
# document carries the template, and a runtime renders it.
55+
minijinja = { version = "=2.21.0", default-features = false, features = ["builtins", "unstable_machinery"] }
5056

5157
[workspace.lints.rust]
5258
unsafe_code = "forbid"

‎README.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,11 @@ the spec. Every one is checked by `pact check` and held by the harness. The deta
640640
| **Learning, scored by the host** | `learning.yaml` | a cycle is scored by the host's own runner from a kept baseline, and "held for a person" is its own outcome |
641641
| **REST services, imported** | `pact-import --as openapi api.yaml --out .` | one reviewed tool file per operation of an OpenAPI description; an operation that does not declare what it does is marked *Needs a look* and treated as a write that cannot be undone; the report names everything PACT cannot say |
642642
| **Blanks in an address** | `url: host/pets/{pet-id}?tag={tag}` | each blank is filled from the call's argument of that name; a blank no argument fills is refused |
643+
| **Voice, documents and embeddings** | `voice:`, `accepts: {message: audio}`, `embedded-by:` | the transcriber, speaker, voice and languages are held against the catalogue; a voice heard on this machine stays here behind a hosted model |
644+
| **Interfaces** | `tools/email.yaml` with `interface: yes`; `tools/gmail.yaml` with `implements: email` | one contract, several providers, picked per person or per run; an implementation may never ask less of a person than its interface |
645+
| **Jinja and turns** | `template-language: jinja`; `turns:` from `system`, `developer`, `user`, `agent`, `tool` | words that choose and repeat, in the dialect both ports render one way; worked conversations and multi-turn eval cases |
646+
| **Models chosen by evals** | `accept-when: [mmlu.score > 50, refund-tone.score >= 0.8, cost < 0.05 USD]` | a benchmark the catalogue records, a check of your own suite, what a case cost — every line held, before and after the candidate runs your cases (`budflow choose-model`) |
647+
| **Tool schemas from the server** | `pact-sync <server>`; `schemas-from: the-snapshot` | the server's own arguments, recorded and reviewed; which actions may be called stays written here |
643648
| **A social security number, hidden** | `anything that looks like a social security number` in `redaction.yaml` | a US one is hidden however it is parted; a bank account in lower case is a bank account too |
644649

645650
It also tightened what already existed: nobody is asked to approve a call that at-most-once
@@ -660,11 +665,11 @@ framework is an extra.
660665
|---|---|
661666
| **Design** | Thesis, 28 binding decisions, FRD (120 requirements), 60-row refusal ledger — complete |
662667
| **Research** | 14 source-grounded studies, ~15,750 lines, over 140 repos (~15 GB) + 57 papers |
663-
| **Code** | Loader, diagnostics, schema engine, CLI, harness, resolver, evals, SLO — **4435 tests (1476 Rust + 2959 adapter), clippy clean, TypeScript type-checked** |
668+
| **Code** | Loader, diagnostics, schema engine, CLI, harness, resolver, evals, SLO — **4505 tests (1489 Rust + 3016 adapter), clippy clean, TypeScript type-checked** |
664669
| **Adapters** | **All 7 named targets**, proven against one shared conformance suite |
665670

666671
```bash
667-
./scripts/test-all.sh # 4435 tests, Rust + 7 adapters, fully offline
672+
./scripts/test-all.sh # 4505 tests, Rust + 7 adapters, fully offline
668673
```
669674

670675
**What is still owed** is not hidden — it lives in

‎adapters/python/pyproject.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ pact-export = "pact_adapters.exporting:main"
9797
# field for — and the report is where the difference is said.
9898
pact-pydantic-ai = "pact_adapters.pydantic_ai_interop:main"
9999
pact-improve = "pact_adapters.optimising:main"
100+
# 96 §E. Records what an MCP server offers into `resources/<server>.yaml`, so a
101+
# server's change is a diff somebody reviews. `pact check` never connects.
102+
pact-sync = "pact_adapters.syncing:main"
100103
# The one table from PACT field to Pydantic AI construct (02P §4), written out
101104
# as a page a person reads.
102105
pact-mapping = "pact_adapters.pydantic_ai_registry:main"
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""Named benchmarks: where a model's `mmlu` or `terminal-bench` figure comes from (96 §B4).
2+
3+
An agent's `needs.accept-when: - mmlu.score > 50` is decided against the score
4+
a catalogue row records under `benchmarks:`. This module is the one way such a
5+
score gets there, and it always arrives with where it came from:
6+
7+
* `record` writes a figure somebody measured or read — a Terminal-Bench run
8+
on their own harness, a leaderboard — into the workspace's
9+
`models/catalog.yaml`, with `provenance:` saying so. That is the path for
10+
every `runs: host:<name>` benchmark, and for a published figure of any.
11+
* `run_deepeval` measures one of the benchmarks DeepEval ships
12+
(`runs: deepeval:MMLU`) against a model, asked through the same
13+
`(system, user) -> answer` seam a `graded-by:` judge uses, and answers the
14+
figure on the benchmark's own scale.
15+
16+
PACT runs nothing on its own: a runtime that serves models (Bud Agent Flow's
17+
`budflow benchmark`) calls these. And nothing is fetched by this module — a
18+
DeepEval benchmark downloads its own dataset, which is that library's act and
19+
says so when it cannot.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import datetime as _dt
25+
from collections.abc import Callable, Mapping
26+
from dataclasses import dataclass
27+
from pathlib import Path
28+
from typing import Any
29+
30+
import yaml
31+
32+
from .providers import AskModel
33+
from .selection import benchmarks_of
34+
35+
__all__ = ["Benchmark", "Unrecordable", "known", "record", "run_deepeval"]
36+
37+
38+
class Unrecordable(ValueError):
39+
"""A figure that cannot be written down as it stands, and why."""
40+
41+
42+
@dataclass(frozen=True)
43+
class Benchmark:
44+
"""One entry of a catalogue's `benchmarks:`."""
45+
46+
name: str
47+
description: str = ""
48+
scale: str = "percent"
49+
runs: str = ""
50+
with_: Mapping[str, Any] | None = None
51+
52+
@property
53+
def runner(self) -> str:
54+
"""`deepeval`, `host`, or `""` for a benchmark whose figures are only published."""
55+
return self.runs.split(":", 1)[0].strip() if ":" in self.runs else ""
56+
57+
58+
def known(document: Mapping[str, Any] | None = None) -> dict[str, Benchmark]:
59+
"""Every benchmark the distribution's catalogue and the workspace's own declare."""
60+
from .resolve import BUILTIN_CATALOGUE
61+
62+
out: dict[str, Benchmark] = {}
63+
layers = [yaml.safe_load(Path(BUILTIN_CATALOGUE).read_text(encoding="utf-8")) or {}]
64+
if document:
65+
layers.append(document.get("models") or {})
66+
for layer in layers:
67+
for key, entry in ((layer or {}).get("benchmarks") or {}).items():
68+
entry = entry if isinstance(entry, Mapping) else {}
69+
out[str(key)] = Benchmark(
70+
name=str(key),
71+
description=str(entry.get("description") or ""),
72+
scale=str(entry.get("scale") or "percent"),
73+
runs=str(entry.get("runs") or ""),
74+
with_=dict(entry.get("with") or {}),
75+
)
76+
return out
77+
78+
79+
def record(
80+
workspace: Path,
81+
model: str,
82+
benchmark: str,
83+
value: float,
84+
*,
85+
source: str,
86+
harness: str = "",
87+
recorded_by: str = "",
88+
as_of: "_dt.date | None" = None,
89+
document: Mapping[str, Any] | None = None,
90+
) -> Path:
91+
"""Write one figure into `<workspace>/models/catalog.yaml`, with its provenance.
92+
93+
The benchmark must be one the catalogue declares (by name or alias, written
94+
under its own key), the figure must be on its scale, and `source` must say
95+
where it came from — a figure nobody can trace is the one Y12 forbids. A
96+
catalogue with comments in it is not rewritten: a YAML round trip would drop
97+
them, and a person's comment is not this command's to lose. Instead the
98+
lines to paste are in the error.
99+
"""
100+
names = benchmarks_of(document or {})
101+
found = names.get(benchmark.strip().lower())
102+
if found is None:
103+
raise Unrecordable(
104+
f"no benchmark is called {benchmark!r}. Name one the catalogue declares under "
105+
f"`benchmarks:`, or add your own there first."
106+
)
107+
key, scale = found
108+
top = 100.0 if scale == "percent" else 1.0
109+
if not 0.0 <= float(value) <= top:
110+
raise Unrecordable(
111+
f"{key} is written out of {top:g}, so {value:g} is not a score on it"
112+
)
113+
if not source.strip():
114+
raise Unrecordable("say where the figure came from: a run you did, or a published table")
115+
figure: dict[str, Any] = {
116+
"value": f"{float(value):g}",
117+
"provenance": {
118+
"source": source.strip(),
119+
**({"harness": harness.strip()} if harness.strip() else {}),
120+
"as-of": (as_of or _dt.date.today()).isoformat(),
121+
**({"recorded-by": recorded_by.strip()} if recorded_by.strip() else {}),
122+
},
123+
}
124+
path = Path(workspace) / "models" / "catalog.yaml"
125+
text = path.read_text(encoding="utf-8") if path.exists() else ""
126+
if any(line.lstrip().startswith("#") for line in text.splitlines()):
127+
paste = yaml.safe_dump(
128+
{"models": {model: {"benchmarks": {key: figure}}}}, sort_keys=False, allow_unicode=True
129+
)
130+
raise Unrecordable(
131+
f"{path} has comments in it, and writing the figure would drop them. Add these "
132+
f"lines under the model's own row instead:\n{paste}"
133+
)
134+
data = yaml.safe_load(text) if text.strip() else {}
135+
data = data if isinstance(data, dict) else {}
136+
data.setdefault("version", 1)
137+
rows = data.setdefault("models", {})
138+
if model not in rows:
139+
# A workspace row REPLACES the distribution's row of the same name — the catalogue is
140+
# layered row by row (`resolve._layer`) — so a row holding only this figure would lose
141+
# where the model is served, its price and its window. The distribution's row is copied
142+
# first, and the reviewer reads all of it in the diff.
143+
rows[model] = _shipped_row(model)
144+
row = rows[model]
145+
row.setdefault("benchmarks", {})[key] = figure
146+
path.parent.mkdir(parents=True, exist_ok=True)
147+
path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8")
148+
return path
149+
150+
151+
def _shipped_row(model: str) -> dict[str, Any]:
152+
"""The distribution's own row for `model` (under its id or any alias), or a new empty one."""
153+
from .resolve import BUILTIN_CATALOGUE
154+
155+
shipped = (yaml.safe_load(Path(BUILTIN_CATALOGUE).read_text(encoding="utf-8")) or {}).get("models") or {}
156+
for name, row in shipped.items():
157+
if name == model or model in (row or {}).get("also-known-as", []):
158+
return dict(row or {})
159+
return {}
160+
161+
162+
def run_deepeval(
163+
benchmark: Benchmark, model: str, ask: Callable[[str, str], str]
164+
) -> float: # pragma: no cover - downloads a dataset and asks a live model
165+
"""Measure one of DeepEval's own benchmarks against `model`, on the benchmark's scale."""
166+
if benchmark.runner != "deepeval":
167+
raise Unrecordable(
168+
f"{benchmark.name} is run by `{benchmark.runs or 'nobody here'}`, not by DeepEval: "
169+
f"record its figure from wherever it was measured"
170+
)
171+
try:
172+
import deepeval.benchmarks as shipped
173+
except ImportError as missing:
174+
raise Unrecordable(f"DeepEval is not installed here ({missing}): `pip install deepeval`") from missing
175+
class_name = benchmark.runs.split(":", 1)[1].strip()
176+
cls = getattr(shipped, class_name, None)
177+
if cls is None:
178+
raise Unrecordable(f"DeepEval has no benchmark called {class_name!r}")
179+
result = cls(**dict(benchmark.with_ or {})).evaluate(model=AskModel(model, ask))
180+
accuracy = float(getattr(result, "overall_accuracy", result))
181+
return accuracy * 100.0 if benchmark.scale == "percent" else accuracy

0 commit comments

Comments
 (0)