verifiers 0.2.2.dev52__py3-none-any.whl → 0.2.2.dev54__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.
verifiers/v1/__init__.py CHANGED
@@ -4,6 +4,12 @@ from pydantic_config import BaseConfig
4
4
 
5
5
  from verifiers.v1.acp import ACP
6
6
  from verifiers.v1.agent import Agent, Agents, Interaction, Segment, make_agent
7
+ from verifiers.v1.artifacts import (
8
+ ARTIFACTS_DIR,
9
+ Artifact,
10
+ collect,
11
+ restore,
12
+ )
7
13
  from verifiers.v1.clients import (
8
14
  BaseClientConfig,
9
15
  Client,
@@ -109,10 +115,10 @@ from verifiers.v1.taskset import Taskset
109
115
  from verifiers.v1.trace import (
110
116
  TRACE_VERSION,
111
117
  AgentInfo,
118
+ AgentSpan,
112
119
  Branch,
113
120
  Error,
114
121
  EvalRunInfo,
115
- GenerationSpan,
116
122
  ModelCall,
117
123
  Reward,
118
124
  RunInfo,
@@ -138,7 +144,6 @@ from verifiers.v1.types import (
138
144
  Response,
139
145
  Sampling,
140
146
  SamplingConfig,
141
- StrictBaseModel,
142
147
  SystemMessage,
143
148
  TextContentPart,
144
149
  Tool,
@@ -171,7 +176,6 @@ __all__ = [ # noqa: RUF022 - grouped by public API area
171
176
  "Response",
172
177
  "Sampling",
173
178
  "SamplingConfig",
174
- "StrictBaseModel",
175
179
  "SystemMessage",
176
180
  "TextContentPart",
177
181
  "Tool",
@@ -207,7 +211,7 @@ __all__ = [ # noqa: RUF022 - grouped by public API area
207
211
  "Timing",
208
212
  "TimeSpan",
209
213
  "TimeSplit",
210
- "GenerationSpan",
214
+ "AgentSpan",
211
215
  "Error",
212
216
  # decorators
213
217
  "stop",
@@ -297,6 +301,11 @@ __all__ = [ # noqa: RUF022 - grouped by public API area
297
301
  "PATCH_CAP_BYTES",
298
302
  "capture_patch",
299
303
  "resolve_head",
304
+ # grading artifacts
305
+ "ARTIFACTS_DIR",
306
+ "Artifact",
307
+ "collect",
308
+ "restore",
300
309
  # scoring
301
310
  "compare_stdout_results",
302
311
  "extract_boxed_answer",
@@ -0,0 +1,142 @@
1
+ """Artifact collection and restoration across runtimes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import shlex
7
+ import uuid
8
+ from pathlib import PurePosixPath
9
+ from typing import TYPE_CHECKING
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ if TYPE_CHECKING:
14
+ from verifiers.v1.runtimes import Runtime
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ ARTIFACTS_DIR = "/logs/artifacts"
19
+ """Implicit artifact directory; tasks that write here need no declaration."""
20
+
21
+ MAX_ARTIFACT_BYTES = 32 * 1024 * 1024
22
+ """Ceiling per collection. Sized for a delta, not a tree: the grading box boots from the
23
+ agent's image, so the repo is already there and only its output has to travel."""
24
+
25
+
26
+ class Artifact(BaseModel):
27
+ """One path to restore at the same location in another runtime."""
28
+
29
+ source: str
30
+ exclude: list[str] = Field(default_factory=list)
31
+ """`tar --exclude` patterns, applied when `source` is a directory."""
32
+
33
+
34
+ async def collect(
35
+ runtime: Runtime, artifacts: list[Artifact] | None = None
36
+ ) -> dict[str, bytes]:
37
+ """Tar the convention dir and every declared path out of `runtime`.
38
+
39
+ Keyed by source path; the values are tar archives. Insertion order is the order
40
+ they were declared, and a path cannot be collected twice.
41
+
42
+ A declared source that is missing raises: it was declared because grading needs it,
43
+ and grading a partial state scores the rollout wrong rather than failing it. The
44
+ implicit convention sweep is exempt — most tasks never write there.
45
+
46
+ Each source is archived separately so its exclude patterns stay local.
47
+ """
48
+ # Resolve relative sources against the runtime workdir. Joining also normalises
49
+ # `/work/` to `/work`, so one tree cannot key two entries (the source is both the
50
+ # dict key and `restore`'s rm -rf target).
51
+ workdir = PurePosixPath(getattr(runtime.config, "workdir", "") or "/")
52
+ declared = [
53
+ a.model_copy(update={"source": str(workdir / a.source)})
54
+ for a in artifacts or []
55
+ ]
56
+ convention = PurePosixPath(ARTIFACTS_DIR)
57
+ sweep = not any(
58
+ (p := PurePosixPath(a.source)) == convention
59
+ or p.is_relative_to(convention)
60
+ or convention.is_relative_to(p)
61
+ for a in declared
62
+ )
63
+ entries = ([Artifact(source=ARTIFACTS_DIR)] if sweep else []) + declared
64
+
65
+ collected: dict[str, bytes] = {}
66
+ budget = MAX_ARTIFACT_BYTES
67
+ for artifact in entries:
68
+ source = artifact.source
69
+ if (await runtime.run(["test", "-e", source], {})).exit_code != 0:
70
+ if sweep and source == ARTIFACTS_DIR:
71
+ continue
72
+ raise RuntimeError(
73
+ f"declared artifact {source!r} does not exist in the runtime"
74
+ )
75
+ archive = await _tar_out(runtime, artifact, budget)
76
+ budget -= len(archive)
77
+ collected[source] = archive
78
+
79
+ logger.debug("collected artifact roots: %s", list(collected))
80
+ return collected
81
+
82
+
83
+ async def restore(runtime: Runtime, collected: dict[str, bytes]) -> None:
84
+ """Extract `collected` in `runtime` at the original absolute paths."""
85
+ if not collected:
86
+ return
87
+ # Restoring into the subprocess runtime would extract absolute paths onto the
88
+ # developer's filesystem, so refuse it before any archive reaches the host.
89
+ if getattr(runtime.config, "type", None) == "subprocess":
90
+ raise RuntimeError(
91
+ "refusing to restore artifacts into the subprocess runtime: extraction "
92
+ "writes to absolute paths on the host. Grade in a container."
93
+ )
94
+ # Clear every root up front, not per entry: a later nested root would otherwise
95
+ # delete content an earlier one just restored. Clearing also drops any file or
96
+ # symlink the image left at the target.
97
+ roots = " ".join(shlex.quote(root) for root in collected)
98
+ await _run(runtime, f"rm -rf -- {roots}", "clear artifact roots")
99
+ for root, archive in collected.items():
100
+ path = f"/tmp/vf-artifact-{uuid.uuid4().hex}.tar"
101
+ await runtime.write(path, archive)
102
+ await _run(
103
+ runtime,
104
+ f"tar -xf {shlex.quote(path)} -C / && rm -f {shlex.quote(path)}",
105
+ f"restore artifact {root!r}",
106
+ )
107
+
108
+
109
+ async def _tar_out(runtime: Runtime, artifact: Artifact, budget: int) -> bytes:
110
+ path = f"/tmp/vf-artifact-{uuid.uuid4().hex}.tar"
111
+ excludes = " ".join(f"--exclude={shlex.quote(p)}" for p in artifact.exclude)
112
+ try:
113
+ await _run(
114
+ runtime,
115
+ f"tar -cf {shlex.quote(path)} -C / {excludes} -- "
116
+ f"{shlex.quote(artifact.source.lstrip('/'))}",
117
+ f"collect artifact {artifact.source!r}",
118
+ )
119
+ # Size it in the box: an oversized collection is refused before it reaches host
120
+ # memory, not after.
121
+ sized = await runtime.run(["sh", "-c", f"wc -c < {shlex.quote(path)}"], {})
122
+ if (raw := sized.stdout.strip()).isdigit() and int(raw) > budget:
123
+ raise RuntimeError(
124
+ f"artifact {artifact.source!r} takes the collection over the "
125
+ f"{MAX_ARTIFACT_BYTES} byte limit. The grading box boots from the "
126
+ "agent's image, so only the delta needs to travel — narrow the source "
127
+ "or add `exclude` patterns."
128
+ )
129
+ return await runtime.read(path)
130
+ finally:
131
+ # Best-effort: the box is about to be destroyed and the name is unique per call.
132
+ try:
133
+ await runtime.run(["rm", "-f", path], {})
134
+ except Exception:
135
+ logger.debug("failed to remove %s", path, exc_info=True)
136
+
137
+
138
+ async def _run(runtime: Runtime, command: str, action: str) -> None:
139
+ result = await runtime.run(["sh", "-c", command], {})
140
+ if result.exit_code:
141
+ detail = (result.stderr or result.stdout).strip()[-500:]
142
+ raise RuntimeError(f"failed to {action}: {detail}")
@@ -393,18 +393,19 @@ def _breakdown(scored: list[Trace], done: list[Trace]) -> Table | None:
393
393
  phase_count: dict[str, int] = {}
394
394
  model_secs = harness_secs = 0.0
395
395
  for trace in done:
396
- prompt, completion, cached, reasoning, _ = _tokens(trace)
397
- total_in += prompt
398
- total_out += completion
399
- if cached is not None:
400
- total_cached += cached
401
- have_cached = True
402
- if reasoning is not None:
403
- total_reasoning += reasoning
404
- have_reasoning = True
405
- if trace.usage is not None and trace.usage.cost is not None:
406
- total_cost += trace.usage.cost
407
- have_cost = True
396
+ total_in += trace.num_input_tokens
397
+ total_out += trace.num_output_tokens
398
+ usage = trace.usage
399
+ if usage is not None:
400
+ if usage.cached_input_tokens is not None:
401
+ total_cached += usage.cached_input_tokens
402
+ have_cached = True
403
+ if usage.reasoning_tokens is not None:
404
+ total_reasoning += usage.reasoning_tokens
405
+ have_reasoning = True
406
+ if usage.cost is not None:
407
+ total_cost += usage.cost
408
+ have_cost = True
408
409
  # Judge / auxiliary scoring calls (off the message graph) shown separately from the agent's.
409
410
  judge = Usage.aggregate(trace.extra_usage)
410
411
  if judge is not None:
@@ -413,13 +414,13 @@ def _breakdown(scored: list[Trace], done: list[Trace]) -> Table | None:
413
414
  if judge.cost is not None:
414
415
  total_judge_cost += judge.cost
415
416
  have_judge = True
416
- for phase in ("boot", "setup", "generation", "finalize", "scoring"):
417
+ for phase in ("boot", "setup", "agent", "finalize", "scoring"):
417
418
  span = getattr(trace.timing, phase)
418
419
  if span.end: # phase was timed for this rollout
419
420
  phase_secs[phase] = phase_secs.get(phase, 0.0) + span.duration
420
421
  phase_count[phase] = phase_count.get(phase, 0) + 1
421
- model_secs += trace.timing.generation.model.duration
422
- harness_secs += trace.timing.generation.harness.duration
422
+ model_secs += trace.timing.agent.model.duration
423
+ harness_secs += trace.timing.agent.harness.duration
423
424
  if (
424
425
  total_in
425
426
  or total_out
@@ -448,12 +449,12 @@ def _breakdown(scored: list[Trace], done: list[Trace]) -> Table | None:
448
449
  usage.append(cost)
449
450
  grid.add_row("usage", " · ".join(usage))
450
451
  time_segments = []
451
- for phase in ("boot", "setup", "generation", "finalize", "scoring"):
452
+ for phase in ("boot", "setup", "agent", "finalize", "scoring"):
452
453
  count = phase_count.get(phase)
453
454
  if not count:
454
455
  continue
455
456
  segment = f"{phase} {format_time(phase_secs[phase] / count)}"
456
- if phase == "generation":
457
+ if phase == "agent":
457
458
  segment += (
458
459
  f" (model {format_time(model_secs / count)}"
459
460
  f" + harness {format_time(harness_secs / count)})"
@@ -464,26 +465,6 @@ def _breakdown(scored: list[Trace], done: list[Trace]) -> Table | None:
464
465
  return grid if grid.row_count else None
465
466
 
466
467
 
467
- def _tokens(trace: Trace) -> tuple[int, int, int | None, int | None, int]:
468
- """Input/output tokens summed across all branches: per branch, output is every assistant
469
- (completion) token generated across its turns and input is the fed-in tokens counted once
470
- (system + user + tool) — the final sequence minus everything the model generated. A rollout
471
- yields one training sample per branch (a linear trace is a single branch; compaction and
472
- subagents add more), so the totals sum them — matching `Trace.num_input_tokens` /
473
- `Trace.num_output_tokens`, whose sum is `num_total_tokens`.
474
-
475
- Both counts come from provider-reported usage. Returns the branch count from the same derived
476
- view so each dashboard tick materializes it once."""
477
- usage = trace.usage
478
- cached = usage.cached_input_tokens if usage else None
479
- reasoning = usage.reasoning_tokens if usage else None
480
- branches = trace.branches
481
- nbranches = len(branches)
482
- prompt = sum(b.num_input_tokens for b in branches)
483
- completion = sum(b.num_output_tokens for b in branches)
484
- return prompt, completion, cached, reasoning, nbranches
485
-
486
-
487
468
  def _stage(trace: Trace) -> str:
488
469
  """The stage a live (not-yet-done) rollout is in, derived from its trace's timing
489
470
  spans — the engine opens and closes each span exactly at the stage transitions, so
@@ -495,7 +476,7 @@ def _stage(trace: Trace) -> str:
495
476
  for stage, span in (
496
477
  ("scoring", trace.timing.scoring),
497
478
  ("finalize", trace.timing.finalize),
498
- ("running", trace.timing.generation),
479
+ ("running", trace.timing.agent),
499
480
  ("setup", trace.timing.setup),
500
481
  ("boot", trace.timing.boot),
501
482
  ):
@@ -551,7 +532,9 @@ def Rows(groups: list[list[RunSlot]], now: float, runtime_type: str) -> Table:
551
532
  base = f"name={task.name[:32]}" if task.name else f"idx={task.idx}"
552
533
  if not slot.traces:
553
534
  if slot.done: # the env's rollout() itself failed before any trace
554
- error = slot.episode.error if slot.episode is not None else None
535
+ error = (
536
+ slot.episode.last_error if slot.episode is not None else None
537
+ )
555
538
  group_rows.append(
556
539
  (
557
540
  "error",
@@ -602,7 +585,7 @@ def Rows(groups: list[list[RunSlot]], now: float, runtime_type: str) -> Table:
602
585
  end = (
603
586
  t.timing.scoring.end
604
587
  or t.timing.finalize.end
605
- or t.timing.generation.end
588
+ or t.timing.agent.end
606
589
  # a rollout that errored in boot/setup has only that span's end — freeze there
607
590
  # once done, else (still running) the timer would grow off `now` forever
608
591
  or (
@@ -612,8 +595,12 @@ def Rows(groups: list[list[RunSlot]], now: float, runtime_type: str) -> Table:
612
595
  )
613
596
  or now
614
597
  )
615
- prompt, completion, cached, reasoning, nbranches = _tokens(t)
616
- cost = t.usage.cost if t.usage else None
598
+ prompt, completion = t.num_input_tokens, t.num_output_tokens
599
+ nbranches = t.num_branches
600
+ usage = t.usage
601
+ cached = usage.cached_input_tokens if usage else None
602
+ reasoning = usage.reasoning_tokens if usage else None
603
+ cost = usage.cost if usage else None
617
604
  tokens = ""
618
605
  if prompt or completion:
619
606
  tokens = f"{format_count(prompt)}/{format_count(completion)} tokens"
verifiers/v1/cli/debug.py CHANGED
@@ -123,16 +123,16 @@ def record_debug_error(
123
123
  action_timeout: float | None,
124
124
  ) -> None:
125
125
  now = time.time()
126
- for span in (trace.timing.boot, trace.timing.setup, trace.timing.generation):
126
+ for span in (trace.timing.boot, trace.timing.setup, trace.timing.agent):
127
127
  if span.start and not span.end:
128
128
  span.end = now
129
- in_action = bool(trace.timing.generation.start)
129
+ in_action = bool(trace.timing.agent.start)
130
130
  stage = (
131
131
  "debug action" if in_action else "setup" if trace.timing.setup.start else "boot"
132
132
  )
133
133
  timeout = action_timeout if in_action else setup_timeout
134
134
  error_start = (
135
- trace.timing.generation.start
135
+ trace.timing.agent.start
136
136
  if in_action
137
137
  else trace.timing.setup.start or trace.timing.boot.start
138
138
  )
@@ -234,9 +234,9 @@ async def debug_task(task: Task, config: DebugConfig) -> tuple[Trace, bool]:
234
234
  await runtime.prepare_execution([])
235
235
  trace.timing.setup.end = time.time()
236
236
 
237
- trace.timing.generation.start = time.time()
237
+ trace.timing.agent.start = time.time()
238
238
  debug.update(await run_action(runtime, config))
239
- trace.timing.generation.end = time.time()
239
+ trace.timing.agent.end = time.time()
240
240
  if not debug.get("ok"):
241
241
  record_action_failure(trace, debug)
242
242
  trace.stop(str(debug["reason"]))
@@ -246,7 +246,7 @@ async def debug_task(task: Task, config: DebugConfig) -> tuple[Trace, bool]:
246
246
  except Exception as e: # noqa: BLE001 - persist any framework failure on the trace
247
247
  record_debug_error(trace, debug, e, setup_timeout, config.timeout.total)
248
248
  finally:
249
- trace.split_generation()
249
+ trace.split_agent_time()
250
250
  trace.info["debug"] = debug
251
251
  try:
252
252
  await runtime.stop()
@@ -84,7 +84,7 @@ async def run_replay(config: ReplayConfig, source: Path, out: Path) -> list[Trac
84
84
  episodes = read_episodes(
85
85
  source, Trace[WireTaskData, state_cls(task_cls), WireAgentConfig]
86
86
  )
87
- sourced = [(trace, e.env) for e in episodes for trace in e.traces]
87
+ sourced = [(trace, e.env.id) for e in episodes for trace in e.traces]
88
88
  if config.num_traces is not None:
89
89
  sourced = sourced[: config.num_traces]
90
90
  traces = [trace for trace, _ in sourced]
verifiers/v1/env.py CHANGED
@@ -20,7 +20,7 @@ from verifiers.v1.configs.env import (
20
20
  _declared_agent_configs,
21
21
  default_agent_harness,
22
22
  )
23
- from verifiers.v1.episode import Episode
23
+ from verifiers.v1.episode import EnvInfo, Episode
24
24
  from verifiers.v1.errors import EnvError, boundary
25
25
  from verifiers.v1.harness import Harness, HarnessConfig
26
26
  from verifiers.v1.interception import (
@@ -257,7 +257,7 @@ class Env(ABC, Generic[ConfigT]):
257
257
  completed subset, its exception on the episode's `errors`. `on_trace` observes
258
258
  each agent-run's trace at mint; `on_discard` its abandonment (a per-agent
259
259
  retry mints a replacement)."""
260
- episode = Episode(env=self.config.env_id)
260
+ episode = Episode(env=EnvInfo(id=self.config.env_id))
261
261
  agents = self._episode_agents(ctx, episode.traces, on_trace, on_discard)
262
262
  try:
263
263
  async with asyncio.timeout(self.config.timeout.episode):
@@ -4,6 +4,7 @@ from verifiers.v1.envs.agentic_judge.env import (
4
4
  Criterion,
5
5
  JudgeTaskConfig,
6
6
  ScoreConfig,
7
+ TextFile,
7
8
  )
8
9
 
9
10
  __all__ = [
@@ -12,4 +13,5 @@ __all__ = [
12
13
  "Criterion",
13
14
  "JudgeTaskConfig",
14
15
  "ScoreConfig",
16
+ "TextFile",
15
17
  ]