vis-agent 0.2.38.dev7557__tar.gz → 0.2.38.dev7568__tar.gz

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.
Files changed (107) hide show
  1. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/.gitignore +3 -0
  2. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/PKG-INFO +1 -1
  3. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/pyproject.toml +1 -1
  4. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/activity.py +1 -0
  5. vis_agent-0.2.38.dev7568/src/blockether/vis/evals/__init__.py +68 -0
  6. vis_agent-0.2.38.dev7568/src/blockether/vis/evals/harbor.py +303 -0
  7. vis_agent-0.2.38.dev7568/src/blockether/vis/evals/stats.py +170 -0
  8. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/evals/trajectory.py +29 -8
  9. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_activity.py +16 -0
  10. vis_agent-0.2.38.dev7568/tests/test_evals_harbor.py +228 -0
  11. vis_agent-0.2.38.dev7568/tests/test_evals_stats.py +94 -0
  12. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_evals_trajectory.py +19 -2
  13. vis_agent-0.2.38.dev7557/src/blockether/vis/evals/__init__.py +0 -26
  14. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/LICENSE +0 -0
  15. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/README.md +0 -0
  16. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/fixtures/activity-arguments.json +0 -0
  17. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/fixtures/activity-cases.json +0 -0
  18. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/fixtures/activity-copy.json +0 -0
  19. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/fixtures/activity-execution.json +0 -0
  20. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/fixtures/activity-groups.json +0 -0
  21. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/fixtures/activity-read-session.json +0 -0
  22. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/fixtures/activity-reads.json +0 -0
  23. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/fixtures/activity-results.json +0 -0
  24. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/fixtures/activity-tables.json +0 -0
  25. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/fixtures/activity.json +0 -0
  26. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/fixtures/diff.json +0 -0
  27. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/fixtures/live-primitives.json +0 -0
  28. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/fixtures/session-health.json +0 -0
  29. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/fixtures/view.json +0 -0
  30. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/schema/activity.json +0 -0
  31. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/schema/agents.json +0 -0
  32. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/schema/automations.json +0 -0
  33. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/schema/common.json +0 -0
  34. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/schema/config.json +0 -0
  35. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/schema/content.json +0 -0
  36. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/schema/council.json +0 -0
  37. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/schema/diff.json +0 -0
  38. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/schema/gateway.json +0 -0
  39. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/schema/improve.json +0 -0
  40. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/schema/provider.json +0 -0
  41. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/schema/rooms.json +0 -0
  42. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/schema/symbol.json +0 -0
  43. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/schema/toggle.json +0 -0
  44. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/contracts/schema/view.json +0 -0
  45. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/examples/greeter/tests/test_greeter.py +0 -0
  46. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/hatch_build.py +0 -0
  47. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/__init__.py +0 -0
  48. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/_contracts.py +0 -0
  49. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/_outside.py +0 -0
  50. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/_wire.py +0 -0
  51. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/engine/__init__.py +0 -0
  52. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/engine/_agent.py +0 -0
  53. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/engine/_agents.py +0 -0
  54. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/engine/_client.py +0 -0
  55. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/engine/_council.py +0 -0
  56. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/engine/_extensions.py +0 -0
  57. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/engine/_local.py +0 -0
  58. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/extension.py +0 -0
  59. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/extension_package.py +0 -0
  60. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/py.typed +0 -0
  61. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/rooms.py +0 -0
  62. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/src/blockether/vis/views.py +0 -0
  63. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/conftest.py +0 -0
  64. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_agent.py +0 -0
  65. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_agent_extension_lifecycle.py +0 -0
  66. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_agent_extensions.py +0 -0
  67. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_agents.py +0 -0
  68. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_api_guides.py +0 -0
  69. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_authoring.py +0 -0
  70. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_catalog.py +0 -0
  71. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_catalog_recipe.py +0 -0
  72. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_ci_report_recipe.py +0 -0
  73. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_client.py +0 -0
  74. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_client_extensions_engine.py +0 -0
  75. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_code_quality_recipe.py +0 -0
  76. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_contracts.py +0 -0
  77. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_council.py +0 -0
  78. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_declarations.py +0 -0
  79. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_engine.py +0 -0
  80. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_engine_docstrings.py +0 -0
  81. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_extension_package.py +0 -0
  82. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_extension_reference.py +0 -0
  83. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_gateway_performance.py +0 -0
  84. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_goals.py +0 -0
  85. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_live_primitives.py +0 -0
  86. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_live_wait.py +0 -0
  87. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_local.py +0 -0
  88. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_method_typing.py +0 -0
  89. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_monitor_recipe.py +0 -0
  90. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_namespace.py +0 -0
  91. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_outside.py +0 -0
  92. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_providers.py +0 -0
  93. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_record_arguments.py +0 -0
  94. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_registration.py +0 -0
  95. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_repair_hooks.py +0 -0
  96. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_result_sequence.py +0 -0
  97. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_rooms.py +0 -0
  98. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_scoped_settings.py +0 -0
  99. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_sdk_bootstrap.py +0 -0
  100. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_sdk_guide.py +0 -0
  101. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_sdk_typing.py +0 -0
  102. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_structured_live.py +0 -0
  103. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_symbol_contract.py +0 -0
  104. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_testing.py +0 -0
  105. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_text_list_arguments.py +0 -0
  106. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_tools.py +0 -0
  107. {vis_agent-0.2.38.dev7557 → vis_agent-0.2.38.dev7568}/tests/test_views.py +0 -0
@@ -117,3 +117,6 @@ own_your_harness.pptx
117
117
  # Call journal written by the e2e schema-probe fixture when it runs from this
118
118
  # checkout instead of a temporary workspace.
119
119
  /schema-calls.jsonl
120
+
121
+ # The uv environment of the e2e Harbor project.
122
+ /e2e/.venv/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vis-agent
3
- Version: 0.2.38.dev7557
3
+ Version: 0.2.38.dev7568
4
4
  Summary: The Vis Python SDK — author extensions and control local or remote engines.
5
5
  Project-URL: Homepage, https://vis.blockether.com
6
6
  Project-URL: Documentation, https://vis.blockether.com
@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
8
8
 
9
9
  [project]
10
10
  name = "vis-agent"
11
- version = "0.2.38.dev7557"
11
+ version = "0.2.38.dev7568"
12
12
  description = "The Vis Python SDK — author extensions and control local or remote engines."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -84,6 +84,7 @@ class ActivityRow:
84
84
  resources: tuple[ActivityResource, ...]
85
85
  evidence: tuple[ActivityEvidence, ...]
86
86
  argument_key: str | None = None
87
+ read_key: str | None = None
87
88
  handle_id: str | None = None
88
89
  group_token: str | None = None
89
90
  duration_ms: int | None = None
@@ -0,0 +1,68 @@
1
+ """Evaluation tools: read Vis runs and Harbor jobs, and compare their results.
2
+
3
+ Read one Vis run with `load_trajectory` or `parse_trajectory`. Read the trials of a
4
+ Harbor job or round with `read_trials`, then count them with `case_results` or
5
+ `summarize`. Seal a finished round with `seal_round`. Compare paired results with
6
+ `paired_change` and `mcnemar_exact`. For installation and sessions, use the
7
+ [Python SDK guide](https://vis.blockether.com/python-sdk.html).
8
+ """
9
+
10
+ from .harbor import (
11
+ HarborTrial,
12
+ RunSummary,
13
+ case_results,
14
+ read_trial,
15
+ read_trials,
16
+ seal_digest,
17
+ seal_round,
18
+ seal_rows,
19
+ summarize,
20
+ )
21
+ from .stats import (
22
+ benjamini_hochberg,
23
+ cluster_interval,
24
+ cohen_kappa,
25
+ detectable_change,
26
+ effective_size,
27
+ mcnemar_exact,
28
+ paired_change,
29
+ wilson_interval,
30
+ )
31
+ from .trajectory import (
32
+ FunctionCall,
33
+ Step,
34
+ StepMetrics,
35
+ ToolCall,
36
+ Trajectory,
37
+ TrajectoryMetrics,
38
+ load_trajectory,
39
+ parse_trajectory,
40
+ )
41
+
42
+ __all__ = [
43
+ "FunctionCall",
44
+ "HarborTrial",
45
+ "RunSummary",
46
+ "Step",
47
+ "StepMetrics",
48
+ "ToolCall",
49
+ "Trajectory",
50
+ "TrajectoryMetrics",
51
+ "benjamini_hochberg",
52
+ "case_results",
53
+ "cluster_interval",
54
+ "cohen_kappa",
55
+ "detectable_change",
56
+ "effective_size",
57
+ "load_trajectory",
58
+ "mcnemar_exact",
59
+ "paired_change",
60
+ "parse_trajectory",
61
+ "read_trial",
62
+ "read_trials",
63
+ "seal_digest",
64
+ "seal_round",
65
+ "seal_rows",
66
+ "summarize",
67
+ "wilson_interval",
68
+ ]
@@ -0,0 +1,303 @@
1
+ """Read Harbor jobs: trials, cases, a summary with the cost, and round seals.
2
+
3
+ A Harbor job directory holds `config.json`, `result.json` and one directory for each
4
+ trial. A trial directory holds `result.json`, `agent/trajectory.json` and
5
+ `verifier/reward.json`. A round directory holds one or more job directories.
6
+
7
+ This module reads the JSON files directly, so it does not need Harbor itself.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import time
15
+ from collections.abc import Iterable, Mapping, Sequence
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from types import MappingProxyType
19
+ from typing import Any
20
+
21
+ from .stats import cluster_interval
22
+
23
+ __all__ = [
24
+ "HarborTrial",
25
+ "RunSummary",
26
+ "case_results",
27
+ "read_trial",
28
+ "read_trials",
29
+ "seal_digest",
30
+ "seal_round",
31
+ "seal_rows",
32
+ "summarize",
33
+ ]
34
+
35
+ RESULT_FILE = "result.json"
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class HarborTrial:
40
+ """One Harbor trial, read from its `result.json`.
41
+
42
+ `task` is the Harbor task name. `agent` is the agent name that the trial reports.
43
+ `model` is the model name of the trial configuration, for example
44
+ `openai/gpt-6.1-sol`. `reward` is the `reward` value of the verifier. It is None when
45
+ the verifier gave no reward, for example after an infrastructure error. `rewards`
46
+ holds all named rewards. `error` is the exception type of a failed trial. The token
47
+ counts and `cost_usd` are None when the agent did not report them. `path` is the
48
+ `result.json` file.
49
+ """
50
+
51
+ job: str
52
+ trial: str
53
+ task: str
54
+ agent: str
55
+ agent_version: str | None
56
+ model: str
57
+ reward: float | None
58
+ rewards: Mapping[str, float]
59
+ error: str | None
60
+ input_tokens: int | None
61
+ cache_tokens: int | None
62
+ output_tokens: int | None
63
+ cost_usd: float | None
64
+ task_checksum: str | None
65
+ started_at: str | None
66
+ finished_at: str | None
67
+ path: Path
68
+
69
+ @property
70
+ def is_scored(self) -> bool:
71
+ """True when the verifier gave a reward."""
72
+ return self.reward is not None
73
+
74
+ @property
75
+ def is_passed(self) -> bool:
76
+ """True when the reward is 1 or more."""
77
+ return self.reward is not None and self.reward >= 1
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class RunSummary:
82
+ """The counts, the pass rate and the cost of a set of trials.
83
+
84
+ A case is one task with one agent and one model. `pass_rate` is `passed / scored`,
85
+ and `pass_rate_ci95` is its 95% cluster interval over the cases. Both are
86
+ fractions, or None without a scored trial. `errors` counts the trials with an
87
+ exception. `cost_usd` is the sum of the reported costs, or None when no trial
88
+ reports a cost. `cost_per_pass_usd` divides that sum by the passed trials.
89
+ """
90
+
91
+ trials: int
92
+ scored: int
93
+ passed: int
94
+ errors: int
95
+ cases: int
96
+ pass_rate: float | None
97
+ pass_rate_ci95: tuple[float, float] | None
98
+ input_tokens: int
99
+ cache_tokens: int
100
+ output_tokens: int
101
+ cost_usd: float | None
102
+ cost_per_pass_usd: float | None
103
+ tasks: tuple[str, ...]
104
+ agents: tuple[str, ...]
105
+ models: tuple[str, ...]
106
+ started_at: str | None
107
+ finished_at: str | None
108
+
109
+
110
+ def _integer(value: Any) -> int | None:
111
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
112
+
113
+
114
+ def _number(value: Any) -> float | None:
115
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
116
+ return None
117
+ return float(value)
118
+
119
+
120
+ def _text(value: Any) -> str | None:
121
+ return str(value) if value not in (None, "") else None
122
+
123
+
124
+ def read_trial(path: Path | str) -> HarborTrial | None:
125
+ """Read one finished trial from its `result.json`, or return None for other files.
126
+
127
+ A job `result.json`, an unfinished trial and a file that is not JSON give None.
128
+ """
129
+ path = Path(path)
130
+ try:
131
+ data = json.loads(path.read_text())
132
+ except (OSError, ValueError):
133
+ return None
134
+ if not isinstance(data, dict):
135
+ return None
136
+ if not (
137
+ data.get("task_name") and data.get("trial_name") and data.get("finished_at")
138
+ ):
139
+ return None
140
+ agent = (data.get("config") or {}).get("agent") or {}
141
+ info = data.get("agent_info") or {}
142
+ usage = data.get("agent_result") or {}
143
+ rewards = {
144
+ str(name): number
145
+ for name, value in (
146
+ (data.get("verifier_result") or {}).get("rewards") or {}
147
+ ).items()
148
+ if (number := _number(value)) is not None
149
+ }
150
+ return HarborTrial(
151
+ job=path.parent.parent.name,
152
+ trial=str(data["trial_name"]),
153
+ task=str(data["task_name"]),
154
+ agent=str(
155
+ info.get("name") or agent.get("name") or agent.get("import_path") or ""
156
+ ),
157
+ agent_version=_text(info.get("version")),
158
+ model=str(agent.get("model_name") or ""),
159
+ reward=rewards.get("reward"),
160
+ rewards=MappingProxyType(rewards),
161
+ error=_text((data.get("exception_info") or {}).get("exception_type")),
162
+ input_tokens=_integer(usage.get("n_input_tokens")),
163
+ cache_tokens=_integer(usage.get("n_cache_tokens")),
164
+ output_tokens=_integer(usage.get("n_output_tokens")),
165
+ cost_usd=_number(usage.get("cost_usd")),
166
+ task_checksum=_text(data.get("task_checksum")),
167
+ started_at=_text(data.get("started_at")),
168
+ finished_at=_text(data.get("finished_at")),
169
+ path=path,
170
+ )
171
+
172
+
173
+ def read_trials(directory: Path | str) -> tuple[HarborTrial, ...]:
174
+ """Read the finished trials of a Harbor job directory or of a round of jobs.
175
+
176
+ A trial `result.json` is one or two levels below `directory`. The trials come in
177
+ start order. A directory that does not exist raises `FileNotFoundError`.
178
+ """
179
+ root = Path(directory)
180
+ if not root.is_dir():
181
+ raise FileNotFoundError(f"no Harbor job or round directory at {root}")
182
+ paths = sorted({*root.glob(f"*/{RESULT_FILE}"), *root.glob(f"*/*/{RESULT_FILE}")})
183
+ trials = [trial for trial in map(read_trial, paths) if trial is not None]
184
+ return tuple(
185
+ sorted(trials, key=lambda trial: (trial.started_at or "", str(trial.path)))
186
+ )
187
+
188
+
189
+ def case_results(
190
+ trials: Iterable[HarborTrial],
191
+ ) -> dict[tuple[str, str, str], tuple[int, int]]:
192
+ """Count the passed and the scored trials of each case: a task, an agent and a model.
193
+
194
+ The keys are `(task, agent, model)`. A trial without a reward does not count, so a
195
+ case without a scored trial is absent.
196
+ """
197
+ cases: dict[tuple[str, str, str], tuple[int, int]] = {}
198
+ for trial in trials:
199
+ if not trial.is_scored:
200
+ continue
201
+ key = (trial.task, trial.agent, trial.model)
202
+ passed, runs = cases.get(key, (0, 0))
203
+ cases[key] = (passed + trial.is_passed, runs + 1)
204
+ return cases
205
+
206
+
207
+ def summarize(trials: Iterable[HarborTrial]) -> RunSummary:
208
+ """Summarize a set of trials: counts, the pass rate with its interval, and the cost."""
209
+ trials = tuple(trials)
210
+ cases = case_results(trials)
211
+ scored = sum(trial.is_scored for trial in trials)
212
+ passed = sum(trial.is_passed for trial in trials)
213
+ costs = [trial.cost_usd for trial in trials if trial.cost_usd is not None]
214
+ cost = sum(costs) if costs else None
215
+ starts = [trial.started_at for trial in trials if trial.started_at]
216
+ ends = [trial.finished_at for trial in trials if trial.finished_at]
217
+ return RunSummary(
218
+ trials=len(trials),
219
+ scored=scored,
220
+ passed=passed,
221
+ errors=sum(trial.error is not None for trial in trials),
222
+ cases=len(cases),
223
+ pass_rate=passed / scored if scored else None,
224
+ pass_rate_ci95=cluster_interval(list(cases.values())),
225
+ input_tokens=sum(trial.input_tokens or 0 for trial in trials),
226
+ cache_tokens=sum(trial.cache_tokens or 0 for trial in trials),
227
+ output_tokens=sum(trial.output_tokens or 0 for trial in trials),
228
+ cost_usd=cost,
229
+ cost_per_pass_usd=cost / passed if cost is not None and passed else None,
230
+ tasks=tuple(sorted({trial.task for trial in trials})),
231
+ agents=tuple(sorted({trial.agent for trial in trials})),
232
+ models=tuple(sorted({trial.model for trial in trials})),
233
+ started_at=min(starts) if starts else None,
234
+ finished_at=max(ends) if ends else None,
235
+ )
236
+
237
+
238
+ def _file_sha256(path: Path) -> str:
239
+ digest = hashlib.sha256()
240
+ with path.open("rb") as stream:
241
+ for block in iter(lambda: stream.read(1 << 20), b""):
242
+ digest.update(block)
243
+ return digest.hexdigest()
244
+
245
+
246
+ def seal_rows(trials: Iterable[HarborTrial], root: Path | str) -> list[dict[str, Any]]:
247
+ """Return one seal row for each trial, with the task checksum and the result digest.
248
+
249
+ `result` is the path of the trial `result.json`, relative to `root`, and
250
+ `result_sha256` is the sha256 of that file.
251
+ """
252
+ root = Path(root)
253
+ return [
254
+ {
255
+ "agent": trial.agent,
256
+ "model": trial.model,
257
+ "task": trial.task,
258
+ "reward": trial.reward,
259
+ "error": trial.error,
260
+ "input": trial.input_tokens,
261
+ "cache": trial.cache_tokens,
262
+ "output": trial.output_tokens,
263
+ "agent_version": trial.agent_version,
264
+ "task_checksum": trial.task_checksum,
265
+ "started_at": trial.started_at,
266
+ "finished_at": trial.finished_at,
267
+ "result": trial.path.relative_to(root).as_posix(),
268
+ "result_sha256": _file_sha256(trial.path),
269
+ }
270
+ for trial in sorted(
271
+ trials,
272
+ key=lambda trial: (trial.agent, trial.model, trial.task, str(trial.path)),
273
+ )
274
+ ]
275
+
276
+
277
+ def seal_digest(body: Mapping[str, Any]) -> str:
278
+ """Return the sha256 of a seal body with sorted keys, without its `digest` key."""
279
+ content = {key: value for key, value in body.items() if key != "digest"}
280
+ return hashlib.sha256(json.dumps(content, sort_keys=True).encode()).hexdigest()
281
+
282
+
283
+ def seal_round(
284
+ round_name: str,
285
+ runs: Sequence[Mapping[str, Any]],
286
+ trials: Iterable[HarborTrial],
287
+ root: Path | str,
288
+ sealed_at: str | None = None,
289
+ ) -> dict[str, Any]:
290
+ """Return the seal of a round: its run records, one row for each trial and a digest.
291
+
292
+ Each run record holds the commit, the pins, the profiles and the artifact sha256 of
293
+ one run. The rows hold the task checksums and the result digests. `sealed_at`
294
+ defaults to the current UTC time.
295
+ """
296
+ body: dict[str, Any] = {
297
+ "round": round_name,
298
+ "sealed_at": sealed_at or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
299
+ "runs": [dict(run) for run in runs],
300
+ "rows": seal_rows(trials, root),
301
+ }
302
+ body["digest"] = seal_digest(body)
303
+ return body
@@ -0,0 +1,170 @@
1
+ """Statistics for evaluation results: intervals, power, paired tests and multiple tests.
2
+
3
+ Each rate, interval and change is a fraction from 0 to 1. A unit is the
4
+ `(passed, runs)` count of one case, for example one task on one route. The repeats of
5
+ one unit are not independent, so the cluster interval and the power use an effective
6
+ size between the unit count and the run count.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from collections import Counter
13
+ from collections.abc import Hashable, Sequence
14
+
15
+ __all__ = [
16
+ "Z95",
17
+ "Z_POWER",
18
+ "benjamini_hochberg",
19
+ "cluster_interval",
20
+ "cohen_kappa",
21
+ "detectable_change",
22
+ "effective_size",
23
+ "mcnemar_exact",
24
+ "paired_change",
25
+ "wilson_interval",
26
+ ]
27
+
28
+ Z95 = 1.959964
29
+ """The normal quantile of a two-sided test at the 5% level."""
30
+
31
+ Z_POWER = 0.841621
32
+ """The normal quantile of 80% power."""
33
+
34
+
35
+ def wilson_interval(
36
+ passed: int, runs: int, size: float | None = None
37
+ ) -> tuple[float, float] | None:
38
+ """Return the 95% Wilson interval of a pass rate, or None without runs.
39
+
40
+ `size` replaces the run count when the runs are not independent.
41
+ """
42
+ size = runs if size is None else size
43
+ if not runs or size <= 0:
44
+ return None
45
+ rate = passed / runs
46
+ spread = Z95**2 / size
47
+ center = (rate + spread / 2) / (1 + spread)
48
+ half = Z95 * math.sqrt(rate * (1 - rate) / size + spread / size / 4) / (1 + spread)
49
+ return max(0.0, center - half), min(1.0, center + half)
50
+
51
+
52
+ def effective_size(units: Sequence[tuple[int, int]]) -> float:
53
+ """Return the run count of clustered results, made smaller by their design effect.
54
+
55
+ Each unit is `(passed, runs)` of one case. The size stays between the unit count
56
+ and the run count. Without any variation, it is the unit count.
57
+ """
58
+ units = [(passed, runs) for passed, runs in units if runs]
59
+ count = len(units)
60
+ total = sum(runs for _, runs in units)
61
+ if count < 2 or total == count:
62
+ return float(total)
63
+ rate = sum(passed for passed, _ in units) / total
64
+ if rate in (0, 1):
65
+ return float(count)
66
+ spread = sum((passed - rate * runs) ** 2 for passed, runs in units)
67
+ variance = count / (count - 1) * spread / total**2
68
+ if not variance:
69
+ return float(total)
70
+ return min(float(total), max(float(count), rate * (1 - rate) / variance))
71
+
72
+
73
+ def cluster_interval(units: Sequence[tuple[int, int]]) -> tuple[float, float] | None:
74
+ """Return the 95% interval of the pooled pass rate of clustered units, or None."""
75
+ passed = sum(passed for passed, _ in units)
76
+ runs = sum(runs for _, runs in units)
77
+ return wilson_interval(passed, runs, effective_size(units))
78
+
79
+
80
+ def detectable_change(units: Sequence[tuple[int, int]]) -> float | None:
81
+ """Return the smallest change of the pass rate that a second run of this size detects.
82
+
83
+ The test compares two independent runs of the same size, two-sided at the 5% level,
84
+ with 80% power. The change has steps of 0.001. None means that there are no runs, or
85
+ that no change from 0 to 1 is detectable.
86
+ """
87
+ total = sum(runs for _, runs in units)
88
+ if not total:
89
+ return None
90
+ rate = sum(passed for passed, _ in units) / total
91
+ size = effective_size(units)
92
+
93
+ def detects(other: float) -> bool:
94
+ pooled = (rate + other) / 2
95
+ noise = Z95 * math.sqrt(2 * pooled * (1 - pooled) / size)
96
+ power = Z_POWER * math.sqrt((rate * (1 - rate) + other * (1 - other)) / size)
97
+ return abs(other - rate) >= noise + power
98
+
99
+ smallest = None
100
+ for sign in (1, -1):
101
+ for step in range(1, 1001):
102
+ other = rate + sign * step / 1000
103
+ if not 0 <= other <= 1:
104
+ break
105
+ if detects(other):
106
+ smallest = step if smallest is None else min(smallest, step)
107
+ break
108
+ return smallest / 1000 if smallest is not None else None
109
+
110
+
111
+ def paired_change(changes: Sequence[float]) -> tuple[tuple[float, float], float]:
112
+ """Return the Agresti-Min 95% interval of a mean paired change, and its floor.
113
+
114
+ Each change is the candidate pass rate of one case minus its baseline pass rate.
115
+ The floor is the smallest true change that this many cases detect with 80% power.
116
+ """
117
+ count = len(changes)
118
+ mean = sum(changes) / (count + 2)
119
+ square = (sum(change * change for change in changes) + 1) / (count + 2)
120
+ error = math.sqrt(max(0.0, square - mean * mean) / (count + 2))
121
+ return (mean - Z95 * error, mean + Z95 * error), (Z95 + Z_POWER) * error
122
+
123
+
124
+ def cohen_kappa(pairs: Sequence[tuple[Hashable, Hashable]]) -> float | None:
125
+ """Return Cohen's kappa of two raters over the same items, or None when undefined."""
126
+ if not pairs:
127
+ return None
128
+ count = len(pairs)
129
+ observed = sum(first == second for first, second in pairs) / count
130
+ firsts = Counter(first for first, _ in pairs)
131
+ seconds = Counter(second for _, second in pairs)
132
+ expected = sum(firsts[label] * seconds[label] for label in firsts) / count**2
133
+ if expected >= 1:
134
+ return None
135
+ return (observed - expected) / (1 - expected)
136
+
137
+
138
+ def mcnemar_exact(worse: int, better: int) -> float:
139
+ """Return the two-sided exact McNemar p-value of paired pass and fail results.
140
+
141
+ `worse` counts the cases that pass only in the baseline. `better` counts the cases
142
+ that pass only in the candidate. Cases with the same result on both sides do not
143
+ change the value. Without a changed case, the value is 1.
144
+ """
145
+ if worse < 0 or better < 0:
146
+ raise ValueError("worse and better must not be negative")
147
+ total = worse + better
148
+ if not total:
149
+ return 1.0
150
+ tail = sum(math.comb(total, count) for count in range(min(worse, better) + 1))
151
+ return min(1.0, 2 * tail / 2**total)
152
+
153
+
154
+ def benjamini_hochberg(p_values: Sequence[float]) -> list[float]:
155
+ """Return the Benjamini-Hochberg q-value of each p-value, in the input order.
156
+
157
+ A q-value is the smallest false discovery rate at which its test is a discovery.
158
+ A larger p-value never gets a smaller q-value, and each q-value is at most 1.
159
+ """
160
+ if any(not 0 <= value <= 1 for value in p_values):
161
+ raise ValueError("each p-value must be from 0 to 1")
162
+ count = len(p_values)
163
+ order = sorted(range(count), key=lambda index: p_values[index])
164
+ q_values = [1.0] * count
165
+ smallest = 1.0
166
+ for rank in range(count, 0, -1):
167
+ index = order[rank - 1]
168
+ smallest = min(smallest, p_values[index] * count / rank)
169
+ q_values[index] = smallest
170
+ return q_values
@@ -322,6 +322,24 @@ class ToolCall:
322
322
  """The Python source of the block, or None."""
323
323
  return _str(self.arguments.get("code"))
324
324
 
325
+ @property
326
+ def stdout(self) -> str:
327
+ """The standard output of the block: the start of `output`.
328
+
329
+ The first `stdout_chars` Unicode code points of `output` are the standard
330
+ output. A newline separates them from the error text. Vis leaves a blank
331
+ standard output out of `output`, so a blank or missing standard output gives
332
+ "".
333
+ """
334
+ size = self.stdout_chars
335
+ text = self.output or ""
336
+ if not size or len(text) < size:
337
+ return ""
338
+ head = text[:size]
339
+ if not head.strip() or (len(text) > size and text[size] != "\n"):
340
+ return ""
341
+ return head
342
+
325
343
  @property
326
344
  def activity_state(self) -> str | None:
327
345
  """The state of the whole block, or None without Activity."""
@@ -468,8 +486,8 @@ class Trajectory:
468
486
  """One Vis run read from an ATIF trajectory.
469
487
 
470
488
  The typed fields hold what evaluations check. `document` keeps the whole input,
471
- also the keys that this reader does not know. Child runs of Council subagents
472
- are in `subagents`.
489
+ also the keys that this reader does not know. Child runs are in
490
+ `subagent_trajectories`.
473
491
  """
474
492
 
475
493
  schema_version: str
@@ -479,7 +497,7 @@ class Trajectory:
479
497
  steps: tuple[Step, ...]
480
498
  final_metrics: TrajectoryMetrics | None
481
499
  extra: Mapping[str, Any]
482
- subagents: tuple[Trajectory, ...]
500
+ subagent_trajectories: tuple[Trajectory, ...]
483
501
  document: Mapping[str, Any]
484
502
 
485
503
  @property
@@ -553,8 +571,9 @@ class Trajectory:
553
571
  def final_step(self) -> Step | None:
554
572
  """The step with the final answer, or None when the run has no final answer.
555
573
 
556
- A trace run marks that step with `is_final`. A persisted run has no marks:
557
- then it is the last agent step, if that step has no code blocks and no error.
574
+ Vis marks that step with `is_final`. A file without marks, such as one from an
575
+ earlier Vis version, ends with the last agent step, if that step has no code
576
+ blocks and no error.
558
577
  """
559
578
  agent_steps = self.agent_steps
560
579
  if any("is_final" in step.extra for step in agent_steps):
@@ -584,7 +603,7 @@ class Trajectory:
584
603
  operation: Keep only calls of this function, for example `grep`.
585
604
  state: Keep only calls with this outcome, for example `failed`.
586
605
 
587
- The calls of subagents are in `subagents`, not here.
606
+ The calls of child runs are in `subagent_trajectories`, not here.
588
607
  """
589
608
  return tuple(
590
609
  call
@@ -614,7 +633,9 @@ class Trajectory:
614
633
  self.final_metrics.to_dict() if self.final_metrics is not None else None
615
634
  ),
616
635
  "steps": [step.to_dict() for step in self.steps],
617
- "subagents": [child.to_dict() for child in self.subagents],
636
+ "subagent_trajectories": [
637
+ child.to_dict() for child in self.subagent_trajectories
638
+ ],
618
639
  }
619
640
 
620
641
 
@@ -720,7 +741,7 @@ def _trajectory(
720
741
  steps=tuple(_step(step, index, strict) for index, step in enumerate(steps)),
721
742
  final_metrics=TrajectoryMetrics._from_atif(document.get("final_metrics")),
722
743
  extra=_map(document.get("extra")),
723
- subagents=tuple(
744
+ subagent_trajectories=tuple(
724
745
  _trajectory(child, strict, version)
725
746
  for child in (children if isinstance(children, tuple) else ())
726
747
  if isinstance(child, Mapping)
@@ -184,6 +184,22 @@ def test_activity_event_uses_the_shared_fixture_and_named_records():
184
184
  )
185
185
 
186
186
 
187
+ def test_activity_rows_carry_every_field_of_the_row_schema():
188
+ # Regression: the engine writes `read_key`, and the SDK row rejected it.
189
+ from dataclasses import fields
190
+
191
+ from blockether.vis.activity import ActivityProjection, ActivityRow
192
+
193
+ properties = _contracts.definition("activity", "row")["properties"]
194
+ assert {field.name for field in fields(ActivityRow)} == set(properties)
195
+ fixture = json.loads((_contracts._DATA / "fixtures/activity.json").read_text())
196
+ row = dict(fixture["rows"][0], read_key="a" * 64)
197
+ wire = dict(fixture, rows=[row, *fixture["rows"][1:]])
198
+ projection = ActivityProjection.from_wire(wire)
199
+ assert projection.rows[0].read_key == "a" * 64
200
+ assert projection.to_wire() == wire
201
+
202
+
187
203
  @pytest.mark.parametrize(
188
204
  "sample",
189
205
  json.loads((_contracts._DATA / "fixtures/activity-cases.json").read_text()),