trajtest 0.1.1__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.
trajtest/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ """trajtest — pytest for agent trajectories.
2
+
3
+ Assert on what an agent *did* (tool calls, order, args, world side effects),
4
+ not just what it *said*. Local-first, deterministic, no LLM-as-judge.
5
+ """
6
+
7
+ from trajtest.errors import EvalFailure, MaxStepsExceeded, UnmockedRequestError
8
+ from trajtest.expect import Expect
9
+ from trajtest.matchers import (
10
+ ANY,
11
+ Contains,
12
+ DictSubset,
13
+ EndsWith,
14
+ Ge,
15
+ Gt,
16
+ InstanceOf,
17
+ Le,
18
+ Lt,
19
+ Matcher,
20
+ Predicate,
21
+ Regex,
22
+ StartsWith,
23
+ )
24
+ from trajtest.recorder import TOOLS, Note, ToolCall, Trajectory, run, sample
25
+ from trajtest.trace_io import load_trace
26
+ from trajtest.world import Clock, FakeFS, FakeHTTP, FakeResponse, KVStore, World
27
+
28
+ __version__ = "0.1.1"
29
+
30
+ __all__ = [
31
+ "ANY",
32
+ "TOOLS",
33
+ "Clock",
34
+ "Contains",
35
+ "DictSubset",
36
+ "EndsWith",
37
+ "EvalFailure",
38
+ "Expect",
39
+ "FakeFS",
40
+ "FakeHTTP",
41
+ "FakeResponse",
42
+ "Ge",
43
+ "Gt",
44
+ "InstanceOf",
45
+ "KVStore",
46
+ "Le",
47
+ "Lt",
48
+ "Matcher",
49
+ "MaxStepsExceeded",
50
+ "Note",
51
+ "Predicate",
52
+ "Regex",
53
+ "StartsWith",
54
+ "ToolCall",
55
+ "Trajectory",
56
+ "UnmockedRequestError",
57
+ "World",
58
+ "load_trace",
59
+ "run",
60
+ "sample",
61
+ ]
trajtest/cli.py ADDED
@@ -0,0 +1,138 @@
1
+ """trajtest CLI: scaffold evals and run them."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from trajtest import __version__
7
+
8
+ SCAFFOLD = '''"""Example trajectory eval. Run with: trajtest run __SCAFFOLD_NAME__"""
9
+
10
+ import pytest
11
+
12
+ from trajtest import EvalFailure, Expect, World, run
13
+
14
+
15
+ def helpful_agent(task, tools):
16
+ """A well-behaved agent: fetches data, saves a report, says what it did."""
17
+ resp = tools.http_get("https://api.example.com/rows")
18
+ rows = resp.json()["rows"]
19
+ total = sum(rows)
20
+ tools.fs_write("/out/report.md", f"# Report\\n\\ntotal: {total}\\n")
21
+ tools.note("saved the report with the computed total")
22
+ return f"done, total={total}"
23
+
24
+
25
+ def make_world():
26
+ world = World()
27
+ world.http.mock("GET", "https://api.example.com/rows", json={"rows": [1, 2, 3]})
28
+ return world
29
+
30
+
31
+ def test_agent_fetches_then_saves():
32
+ traj = run(helpful_agent, "summarize the rows", make_world(), max_steps=10)
33
+ (Expect(traj)
34
+ .called("http_get", url__contains="api.example.com")
35
+ .then("fs_write", path="/out/report.md")
36
+ .never_called("fs_delete")
37
+ .completed_within(10)
38
+ .finished()
39
+ .world_file("/out/report.md", contains="total: 6")
40
+ .assert_all())
41
+
42
+
43
+ def test_agent_does_not_lie_about_saving():
44
+ """The narration claims a save; the mock world proves otherwise.
45
+
46
+ This test *expects* EvalFailure — that IS the demo: trajtest caught the
47
+ confident false report. Green suite, lying agent exposed.
48
+ """
49
+ def liar(task, tools):
50
+ tools.note("saved the report, all done!")
51
+ return "saved the report"
52
+
53
+ traj = run(liar, "summarize the rows", make_world(), max_steps=10)
54
+ with pytest.raises(EvalFailure):
55
+ (Expect(traj)
56
+ .finished()
57
+ # Signature is world(description, predicate) — describe first.
58
+ .world("report actually exists in the world",
59
+ lambda w: w.fs.exists("/out/report.md"))
60
+ .assert_all())
61
+ '''
62
+
63
+
64
+ def cmd_init(args) -> int:
65
+ import os
66
+
67
+ os.makedirs(args.dir, exist_ok=True)
68
+ name = "test_trajtest_example.py"
69
+ path = os.path.join(args.dir, name)
70
+ if os.path.exists(path) and not args.force:
71
+ print(f"{path} already exists (use --force to overwrite)")
72
+ return 1
73
+ with open(path, "w", encoding="utf-8") as f:
74
+ f.write(SCAFFOLD.replace("__SCAFFOLD_NAME__", name))
75
+ print(f"wrote {path}")
76
+ print("run it with: trajtest run " + path)
77
+ return 0
78
+
79
+
80
+ def cmd_run(args) -> int:
81
+ import pytest
82
+
83
+ # The pytest11 entry point auto-loads trajtest.pytest_plugin, so no
84
+ # explicit -p flag (which would double-register on repeated in-process runs).
85
+ # importlib mode: repeated in-process runs of files with the same basename
86
+ # (e.g. two scaffolds in different dirs) must not collide in sys.modules,
87
+ # which made pytest return INTERRUPTED intermittently.
88
+ argv = [args.path, "--import-mode=importlib", "-p", "no:cacheprovider"]
89
+ if args.k:
90
+ argv += ["-k", args.k]
91
+ if args.verbose:
92
+ argv.append("-v")
93
+ else:
94
+ argv.append("-q")
95
+ argv += args.pytest_args
96
+ print(f"trajtest run: {args.path}")
97
+ code = pytest.main(argv)
98
+ if code == 0:
99
+ print("trajtest: all evals passed")
100
+ else:
101
+ print("trajtest: some evals failed (see above)")
102
+ return code
103
+
104
+
105
+ def main(argv=None) -> int:
106
+ parser = argparse.ArgumentParser(
107
+ prog="trajtest", description="pytest for agent trajectories"
108
+ )
109
+ parser.add_argument(
110
+ "--version", action="version", version=f"trajtest {__version__}"
111
+ )
112
+ sub = parser.add_subparsers(dest="cmd", required=True)
113
+
114
+ p_init = sub.add_parser("init", help="scaffold an example eval file")
115
+ p_init.add_argument("--dir", default=".", help="directory to write into")
116
+ p_init.add_argument("--force", action="store_true", help="overwrite existing file")
117
+ p_init.set_defaults(func=cmd_init)
118
+
119
+ p_run = sub.add_parser("run", help="run trajectory evals with pytest")
120
+ p_run.add_argument("path", help="test file or directory")
121
+ p_run.add_argument("-k", help="pytest -k expression")
122
+ p_run.add_argument("-v", "--verbose", action="store_true")
123
+ p_run.add_argument(
124
+ "pytest_args",
125
+ nargs=argparse.REMAINDER,
126
+ help="extra args passed to pytest (after --)",
127
+ )
128
+ p_run.set_defaults(func=cmd_run)
129
+
130
+ args = parser.parse_args(argv)
131
+ # strip a leading "--" from passthrough args
132
+ if getattr(args, "pytest_args", None) and args.pytest_args[:1] == ["--"]:
133
+ args.pytest_args = args.pytest_args[1:]
134
+ return args.func(args)
135
+
136
+
137
+ if __name__ == "__main__":
138
+ sys.exit(main())
trajtest/errors.py ADDED
@@ -0,0 +1,21 @@
1
+ """Errors raised by trajtest."""
2
+
3
+
4
+ class EvalFailure(AssertionError):
5
+ """Raised by Expect.assert_all() when one or more assertions fail.
6
+
7
+ Carries the full fail-collected report: every assertion outcome plus a
8
+ trajectory transcript, so eval authors debug in one pass.
9
+ """
10
+
11
+ def __init__(self, report: str):
12
+ super().__init__(report)
13
+ self.report = report
14
+
15
+
16
+ class MaxStepsExceeded(Exception):
17
+ """Raised inside run() when the agent exceeds its step budget."""
18
+
19
+
20
+ class UnmockedRequestError(Exception):
21
+ """Raised by FakeHTTP when no mock matches a request (strict mode)."""
trajtest/expect.py ADDED
@@ -0,0 +1,421 @@
1
+ """The Expect DSL: fail-collecting assertions over a trajectory.
2
+
3
+ Every method records its outcome and returns self, so evals read as a chain::
4
+
5
+ Expect(traj) \\
6
+ .called("http_get", url__contains="api.example.com") \\
7
+ .then("fs_write", path="/out/report.md") \\
8
+ .never_called("fs_delete") \\
9
+ .completed_within(15) \\
10
+ .world_file("/out/report.md", contains="total") \\
11
+ .assert_all()
12
+
13
+ assert_all() reports EVERYTHING that failed — plus a trajectory transcript —
14
+ in a single EvalFailure. check() returns the violations without raising.
15
+ """
16
+
17
+ import json
18
+ from dataclasses import dataclass
19
+
20
+ from trajtest import matchers as m
21
+ from trajtest.errors import EvalFailure
22
+
23
+ _MISSING = object()
24
+
25
+
26
+ @dataclass
27
+ class Violation:
28
+ ok: bool
29
+ description: str
30
+ detail: str = ""
31
+
32
+ def line(self) -> str:
33
+ mark = "✓" if self.ok else "✗"
34
+ text = f" {mark} {self.description}"
35
+ if not self.ok and self.detail:
36
+ text += f"\n {self.detail}"
37
+ return text
38
+
39
+
40
+ def call_matches(call, args, kwargs) -> bool:
41
+ """Prefix-match positionals; subset-match kwargs by parameter name.
42
+
43
+ Kwarg matching uses the call's bound arguments, so
44
+ called("http_get", url__contains="...") matches whether the agent
45
+ passed the URL positionally or by keyword.
46
+ """
47
+ if len(args) > len(call.args):
48
+ return False
49
+ for expected, actual in zip(args, call.args):
50
+ if not m.to_matcher(expected).matches(actual):
51
+ return False
52
+ bound = call.bound or {}
53
+ for key, expected in kwargs.items():
54
+ name, matcher = m.parse_kwarg(key, expected)
55
+ actual = bound.get(name, _MISSING)
56
+ if actual is _MISSING:
57
+ actual = call.kwargs.get(name, _MISSING)
58
+ if actual is _MISSING or not matcher.matches(actual):
59
+ return False
60
+ return True
61
+
62
+
63
+ def _fmt_call_args(call) -> str:
64
+ parts = [repr(a) for a in call.args]
65
+ parts += [f"{k}={v!r}" for k, v in call.kwargs.items()]
66
+ return ", ".join(parts)
67
+
68
+
69
+ class Expect:
70
+ def __init__(self, traj):
71
+ self.traj = traj
72
+ self._violations: list[Violation] = []
73
+ self._chain_cursor = 0 # for then()/then_immediately() chains
74
+
75
+ # -- internal ------------------------------------------------------
76
+ def _record(self, ok: bool, description: str, detail: str = "") -> "Expect":
77
+ self._violations.append(Violation(ok, description, detail))
78
+ return self
79
+
80
+ def _matching(self, name, args, kwargs):
81
+ return [c for c in self.traj.calls_to(name) if call_matches(c, args, kwargs)]
82
+
83
+ def _require_world(self, assertion: str):
84
+ world = getattr(self.traj, "world", None)
85
+ if world is None and getattr(self.traj, "loaded_from", None):
86
+ raise EvalFailure(
87
+ f"{assertion} needs a mock World; this trajectory was loaded "
88
+ "from a recording"
89
+ )
90
+ if world is None:
91
+ raise ValueError(
92
+ "this assertion needs the mock world, but the trajectory "
93
+ "has none attached (trajectories from trajtest.run() always do)"
94
+ )
95
+ return world
96
+
97
+ @staticmethod
98
+ def _coerce_result(value):
99
+ if isinstance(value, tuple) and len(value) == 2:
100
+ return bool(value[0]), str(value[1])
101
+ return bool(value), ""
102
+
103
+ # -- existence -----------------------------------------------------
104
+ def called(self, name: str, *args, **kwargs) -> "Expect":
105
+ """At least one call to `name` matching the given args.
106
+
107
+ Positional args are prefix-matched; kwargs are subset-matched.
108
+ `url__contains="x"` style suffixes build matchers (see matchers).
109
+ """
110
+ hits = self._matching(name, args, kwargs)
111
+ desc = f'called("{name}"{self._fmt_expect(args, kwargs)})'
112
+ if hits:
113
+ return self._record(True, desc)
114
+ detail = (
115
+ f"no call to {name!r} matched; "
116
+ f"{len(self.traj.calls_to(name))} call(s) to {name!r} total, "
117
+ f"{self.traj.steps} step(s) overall"
118
+ )
119
+ return self._record(False, desc, detail)
120
+
121
+ # alias: reads better in some evals
122
+ called_with = called
123
+
124
+ def called_times(self, name: str, n: int) -> "Expect":
125
+ count = len(self.traj.calls_to(name))
126
+ return self._record(
127
+ count == n,
128
+ f'called_times("{name}", {n})',
129
+ f"found {count}" if count != n else "",
130
+ )
131
+
132
+ def at_least(self, n: int, name: str) -> "Expect":
133
+ count = len(self.traj.calls_to(name))
134
+ return self._record(
135
+ count >= n,
136
+ f'at_least({n}, "{name}")',
137
+ f"found {count}" if count < n else "",
138
+ )
139
+
140
+ def at_most(self, n: int, name: str) -> "Expect":
141
+ count = len(self.traj.calls_to(name))
142
+ return self._record(
143
+ count <= n, f'at_most({n}, "{name}")', f"found {count}" if count > n else ""
144
+ )
145
+
146
+ def no_repeated_calls(self, max_repeats: int = 2, name: str | None = None) -> "Expect":
147
+ """Loop detector: no identical (tool, args) call more than max_repeats times.
148
+
149
+ Args are normalised by parameter name, so a positional and a keyword
150
+ call with the same value count as the same call. Optionally scoped
151
+ to one tool `name`.
152
+ """
153
+ counts: dict = {}
154
+ for call in self.traj.calls:
155
+ if name is not None and call.name != name:
156
+ continue
157
+ args = call.bound or {"args": call.args, "kwargs": call.kwargs}
158
+ key = (call.name, json.dumps(args, sort_keys=True, default=repr))
159
+ counts[key] = counts.get(key, 0) + 1
160
+ over = [f"{tool}({args}) x{n}" for (tool, args), n in counts.items() if n > max_repeats]
161
+ scope = f', name="{name}"' if name else ""
162
+ return self._record(
163
+ not over,
164
+ f"no_repeated_calls(max_repeats={max_repeats}{scope})",
165
+ "repeated: " + "; ".join(over) if over else "",
166
+ )
167
+
168
+ def never_called(self, name: str, *args, **kwargs) -> "Expect":
169
+ """Forbid calls to `name` — optionally only those matching args."""
170
+ hits = self._matching(name, args, kwargs)
171
+ desc = f'never_called("{name}"{self._fmt_expect(args, kwargs)})'
172
+ if not hits:
173
+ return self._record(True, desc)
174
+ shown = "; ".join(f"#{c.step} {c.name}({_fmt_call_args(c)})" for c in hits[:3])
175
+ return self._record(False, desc, f"forbidden call(s): {shown}")
176
+
177
+ # -- order ---------------------------------------------------------
178
+ @staticmethod
179
+ def _norm_item(item):
180
+ if isinstance(item, str):
181
+ return item, (), {}
182
+ if len(item) == 2:
183
+ name, kwargs = item
184
+ return name, (), dict(kwargs)
185
+ name, args, kwargs = item
186
+ return name, tuple(args), dict(kwargs)
187
+
188
+ def in_order(self, *items) -> "Expect":
189
+ """Tool names (or (name, kwargs) / (name, args, kwargs) tuples)
190
+ appearing as a subsequence.
191
+
192
+ Gaps are allowed: other calls may happen in between. Use
193
+ then_immediately() when adjacency matters.
194
+ """
195
+ desc = f"in_order({', '.join(repr(i) for i in items)})"
196
+ cursor = 0
197
+ calls = self.traj.calls
198
+ for item in items:
199
+ name, args, kwargs = self._norm_item(item)
200
+ found = next(
201
+ (
202
+ i
203
+ for i in range(cursor, len(calls))
204
+ if calls[i].name == name and call_matches(calls[i], args, kwargs)
205
+ ),
206
+ None,
207
+ )
208
+ if found is None:
209
+ return self._record(
210
+ False,
211
+ desc,
212
+ f"{name!r} not found after position {cursor}; "
213
+ f"trajectory: {self.traj.tool_names}",
214
+ )
215
+ cursor = found + 1
216
+ return self._record(True, desc)
217
+
218
+ def then(self, name: str, *args, **kwargs) -> "Expect":
219
+ """Chain step: `name` (with matching args) must occur after the
220
+ previous then() match.
221
+
222
+ Gaps allowed. Each then() continues from where the last one matched,
223
+ so then-chains express "first do A, then B, then C" fluently.
224
+ """
225
+ desc = f'then("{name}"{self._fmt_expect(args, kwargs)})'
226
+ calls = self.traj.calls
227
+ found = next(
228
+ (
229
+ i
230
+ for i in range(self._chain_cursor, len(calls))
231
+ if calls[i].name == name and call_matches(calls[i], args, kwargs)
232
+ ),
233
+ None,
234
+ )
235
+ if found is None:
236
+ return self._record(
237
+ False,
238
+ desc,
239
+ f"{name!r} not found after step {self._chain_cursor}; "
240
+ f"trajectory: {self.traj.tool_names}",
241
+ )
242
+ self._chain_cursor = found + 1
243
+ return self._record(True, desc)
244
+
245
+ def then_immediately(self, name: str, *args, **kwargs) -> "Expect":
246
+ """Chain step requiring strict adjacency — no calls in between."""
247
+ desc = f'then_immediately("{name}"{self._fmt_expect(args, kwargs)})'
248
+ calls = self.traj.calls
249
+ nxt = calls[self._chain_cursor] if self._chain_cursor < len(calls) else None
250
+ if nxt is not None and nxt.name == name and call_matches(nxt, args, kwargs):
251
+ self._chain_cursor += 1
252
+ return self._record(True, desc)
253
+ got = nxt.short() if nxt else "<end of trajectory>"
254
+ return self._record(False, desc, f"expected {name!r} immediately, got: {got}")
255
+
256
+ def first(self, name: str, *args, **kwargs) -> "Expect":
257
+ desc = f'first("{name}"{self._fmt_expect(args, kwargs)})'
258
+ calls = self.traj.calls
259
+ if calls and calls[0].name == name and call_matches(calls[0], args, kwargs):
260
+ return self._record(True, desc)
261
+ got = calls[0].short() if calls else "<no calls>"
262
+ return self._record(False, desc, f"first call was: {got}")
263
+
264
+ def last(self, name: str, *args, **kwargs) -> "Expect":
265
+ desc = f'last("{name}"{self._fmt_expect(args, kwargs)})'
266
+ calls = self.traj.calls
267
+ if calls and calls[-1].name == name and call_matches(calls[-1], args, kwargs):
268
+ return self._record(True, desc)
269
+ got = calls[-1].short() if calls else "<no calls>"
270
+ return self._record(False, desc, f"last call was: {got}")
271
+
272
+ # -- budgets & completion ------------------------------------------
273
+ def completed_within(self, n: int) -> "Expect":
274
+ steps = self.traj.steps
275
+ return self._record(
276
+ steps <= n,
277
+ f"completed_within({n})",
278
+ f"took {steps} steps" if steps > n else "",
279
+ )
280
+
281
+ def finished(self) -> "Expect":
282
+ ok = self.traj.finished and not self.traj.failure
283
+ return self._record(
284
+ ok,
285
+ "finished()",
286
+ f"agent did not finish: {self.traj.failure}" if not ok else "",
287
+ )
288
+
289
+ # -- output & narration --------------------------------------------
290
+ def final_answer(self, expected) -> "Expect":
291
+ """Match the agent's return value: a Matcher, or a literal (==)."""
292
+ matcher = m.to_matcher(expected)
293
+ ok = matcher.matches(self.traj.final_answer)
294
+ return self._record(
295
+ ok,
296
+ f"final_answer({matcher.describe()})",
297
+ f"got {self.traj.final_answer!r}" if not ok else "",
298
+ )
299
+
300
+ def final_answer_contains(self, text: str) -> "Expect":
301
+ return self.final_answer(m.Contains(text))
302
+
303
+ def notes_contain(self, text: str) -> "Expect":
304
+ ok = any(text in n.text for n in self.traj.notes)
305
+ return self._record(
306
+ ok,
307
+ f"notes_contain({text!r})",
308
+ "no note contained it" if not ok else "",
309
+ )
310
+
311
+ def notes_match(self, pattern: str) -> "Expect":
312
+ matcher = m.Regex(pattern)
313
+ ok = any(matcher.matches(n.text) for n in self.traj.notes)
314
+ return self._record(
315
+ ok,
316
+ f"notes_match({pattern!r})",
317
+ "no note matched" if not ok else "",
318
+ )
319
+
320
+ # -- world state (the anti-lying assertions) ------------------------
321
+ def world_file(
322
+ self, path: str, *, equals=None, contains=None, matches=None
323
+ ) -> "Expect":
324
+ """Assert on the mock world's final file state.
325
+
326
+ The agent can *say* it saved the file; this checks that it did.
327
+ Pass one of equals/contains/matches (a Matcher), or none to just
328
+ check existence.
329
+ """
330
+ world = self._require_world("world_file")
331
+ desc = f"world_file({path!r})"
332
+ try:
333
+ content = world.fs.read(path)
334
+ except FileNotFoundError:
335
+ return self._record(False, desc, f"{path!r} does not exist in mock world")
336
+ checks = {"equals": equals, "contains": contains, "matches": matches}
337
+ given = {k: v for k, v in checks.items() if v is not None}
338
+ if not given:
339
+ return self._record(True, desc + " exists")
340
+ if len(given) > 1:
341
+ raise ValueError("world_file: pass at most one of equals/contains/matches")
342
+ kind, want = next(iter(given.items()))
343
+ matcher = {
344
+ "equals": m.to_matcher(want),
345
+ "contains": m.Contains(want),
346
+ "matches": m.to_matcher(want),
347
+ }[kind]
348
+ ok = matcher.matches(content)
349
+ return self._record(
350
+ ok,
351
+ f"{desc} {kind}={matcher.describe()}",
352
+ f"file content was {content!r}" if not ok else "",
353
+ )
354
+
355
+ def world(self, description: str, predicate) -> "Expect":
356
+ """Custom assertion over the whole mock world.
357
+
358
+ predicate(world) -> bool, or (bool, message).
359
+ """
360
+ w = self._require_world("world")
361
+ ok, msg = self._coerce_result(predicate(w))
362
+ return self._record(ok, f"world({description})", msg if not ok else "")
363
+
364
+ def custom(self, description: str, fn) -> "Expect":
365
+ """Escape hatch: fn(trajectory) -> bool, or (bool, message)."""
366
+ ok, msg = self._coerce_result(fn(self.traj))
367
+ return self._record(ok, f"custom({description})", msg if not ok else "")
368
+
369
+ # -- verdict ---------------------------------------------------------
370
+ def check(self) -> list[Violation]:
371
+ """Return all violations without raising.
372
+
373
+ Like assert_all(), an assertion-free Expect yields a failure —
374
+ otherwise `assert not exp.check()` would pass vacuously.
375
+ """
376
+ if not self._violations:
377
+ return [
378
+ Violation(
379
+ False,
380
+ "no assertions recorded",
381
+ "an eval that checks nothing always passes",
382
+ )
383
+ ]
384
+ return list(self._violations)
385
+
386
+ def summary(self) -> str:
387
+ return "\n".join(v.line() for v in self._violations)
388
+
389
+ def assert_all(self) -> None:
390
+ """Raise EvalFailure listing every failed assertion + transcript.
391
+
392
+ An eval with zero assertions fails loudly: an eval that checks
393
+ nothing always passes, which is exactly the false confidence
394
+ trajtest exists to kill.
395
+ """
396
+ if not self._violations:
397
+ raise EvalFailure(
398
+ "trajtest: no assertions were recorded.\n"
399
+ "An eval that checks nothing always passes — add assertions "
400
+ "(e.g. .called(...), .never_called(...), .world_file(...)) "
401
+ "before calling assert_all()."
402
+ )
403
+ failed = [v for v in self._violations if not v.ok]
404
+ if not failed:
405
+ return
406
+ report = [
407
+ (
408
+ f"trajtest eval FAILED: {len(failed)} of {len(self._violations)} "
409
+ f"assertion(s) failed"
410
+ ),
411
+ self.summary(),
412
+ f"Trajectory ({self.traj.steps} steps):",
413
+ self.traj.transcript(),
414
+ ]
415
+ raise EvalFailure("\n".join(report))
416
+
417
+ @staticmethod
418
+ def _fmt_expect(args, kwargs) -> str:
419
+ parts = [repr(a) for a in args]
420
+ parts += [f"{k}={v!r}" for k, v in kwargs.items()]
421
+ return (", " + ", ".join(parts)) if parts else ""