vis-agent 0.2.38.dev7554__tar.gz → 0.2.38.dev7565__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 (106) hide show
  1. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/.gitignore +3 -0
  2. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/PKG-INFO +1 -1
  3. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/pyproject.toml +1 -1
  4. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/src/blockether/vis/activity.py +1 -0
  5. vis_agent-0.2.38.dev7565/src/blockether/vis/evals/__init__.py +66 -0
  6. vis_agent-0.2.38.dev7565/src/blockether/vis/evals/harbor.py +303 -0
  7. vis_agent-0.2.38.dev7565/src/blockether/vis/evals/stats.py +170 -0
  8. vis_agent-0.2.38.dev7565/src/blockether/vis/evals/trajectory.py +780 -0
  9. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_activity.py +16 -0
  10. vis_agent-0.2.38.dev7565/tests/test_evals_harbor.py +228 -0
  11. vis_agent-0.2.38.dev7565/tests/test_evals_stats.py +94 -0
  12. vis_agent-0.2.38.dev7565/tests/test_evals_trajectory.py +381 -0
  13. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/LICENSE +0 -0
  14. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/README.md +0 -0
  15. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/fixtures/activity-arguments.json +0 -0
  16. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/fixtures/activity-cases.json +0 -0
  17. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/fixtures/activity-copy.json +0 -0
  18. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/fixtures/activity-execution.json +0 -0
  19. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/fixtures/activity-groups.json +0 -0
  20. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/fixtures/activity-read-session.json +0 -0
  21. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/fixtures/activity-reads.json +0 -0
  22. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/fixtures/activity-results.json +0 -0
  23. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/fixtures/activity-tables.json +0 -0
  24. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/fixtures/activity.json +0 -0
  25. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/fixtures/diff.json +0 -0
  26. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/fixtures/live-primitives.json +0 -0
  27. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/fixtures/session-health.json +0 -0
  28. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/fixtures/view.json +0 -0
  29. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/schema/activity.json +0 -0
  30. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/schema/agents.json +0 -0
  31. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/schema/automations.json +0 -0
  32. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/schema/common.json +0 -0
  33. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/schema/config.json +0 -0
  34. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/schema/content.json +0 -0
  35. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/schema/council.json +0 -0
  36. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/schema/diff.json +0 -0
  37. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/schema/gateway.json +0 -0
  38. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/schema/improve.json +0 -0
  39. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/schema/provider.json +0 -0
  40. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/schema/rooms.json +0 -0
  41. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/schema/symbol.json +0 -0
  42. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/schema/toggle.json +0 -0
  43. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/contracts/schema/view.json +0 -0
  44. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/examples/greeter/tests/test_greeter.py +0 -0
  45. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/hatch_build.py +0 -0
  46. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/src/blockether/vis/__init__.py +0 -0
  47. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/src/blockether/vis/_contracts.py +0 -0
  48. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/src/blockether/vis/_outside.py +0 -0
  49. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/src/blockether/vis/_wire.py +0 -0
  50. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/src/blockether/vis/engine/__init__.py +0 -0
  51. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/src/blockether/vis/engine/_agent.py +0 -0
  52. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/src/blockether/vis/engine/_agents.py +0 -0
  53. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/src/blockether/vis/engine/_client.py +0 -0
  54. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/src/blockether/vis/engine/_council.py +0 -0
  55. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/src/blockether/vis/engine/_extensions.py +0 -0
  56. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/src/blockether/vis/engine/_local.py +0 -0
  57. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/src/blockether/vis/extension.py +0 -0
  58. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/src/blockether/vis/extension_package.py +0 -0
  59. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/src/blockether/vis/py.typed +0 -0
  60. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/src/blockether/vis/rooms.py +0 -0
  61. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/src/blockether/vis/views.py +0 -0
  62. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/conftest.py +0 -0
  63. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_agent.py +0 -0
  64. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_agent_extension_lifecycle.py +0 -0
  65. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_agent_extensions.py +0 -0
  66. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_agents.py +0 -0
  67. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_api_guides.py +0 -0
  68. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_authoring.py +0 -0
  69. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_catalog.py +0 -0
  70. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_catalog_recipe.py +0 -0
  71. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_ci_report_recipe.py +0 -0
  72. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_client.py +0 -0
  73. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_client_extensions_engine.py +0 -0
  74. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_code_quality_recipe.py +0 -0
  75. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_contracts.py +0 -0
  76. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_council.py +0 -0
  77. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_declarations.py +0 -0
  78. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_engine.py +0 -0
  79. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_engine_docstrings.py +0 -0
  80. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_extension_package.py +0 -0
  81. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_extension_reference.py +0 -0
  82. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_gateway_performance.py +0 -0
  83. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_goals.py +0 -0
  84. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_live_primitives.py +0 -0
  85. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_live_wait.py +0 -0
  86. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_local.py +0 -0
  87. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_method_typing.py +0 -0
  88. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_monitor_recipe.py +0 -0
  89. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_namespace.py +0 -0
  90. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_outside.py +0 -0
  91. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_providers.py +0 -0
  92. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_record_arguments.py +0 -0
  93. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_registration.py +0 -0
  94. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_repair_hooks.py +0 -0
  95. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_result_sequence.py +0 -0
  96. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_rooms.py +0 -0
  97. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_scoped_settings.py +0 -0
  98. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_sdk_bootstrap.py +0 -0
  99. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_sdk_guide.py +0 -0
  100. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_sdk_typing.py +0 -0
  101. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_structured_live.py +0 -0
  102. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_symbol_contract.py +0 -0
  103. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_testing.py +0 -0
  104. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_text_list_arguments.py +0 -0
  105. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/tests/test_tools.py +0 -0
  106. {vis_agent-0.2.38.dev7554 → vis_agent-0.2.38.dev7565}/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.dev7554
3
+ Version: 0.2.38.dev7565
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.dev7554"
11
+ version = "0.2.38.dev7565"
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,66 @@
1
+ """Evaluation tools: read Vis runs and Harbor jobs, and compare their results.
2
+
3
+ For the reader of one Vis run, see `blockether.vis.evals.trajectory`. For Harbor jobs
4
+ and round seals, see `blockether.vis.evals.harbor`. For the statistics, see
5
+ `blockether.vis.evals.stats`.
6
+ """
7
+
8
+ from .harbor import (
9
+ HarborTrial,
10
+ RunSummary,
11
+ case_results,
12
+ read_trial,
13
+ read_trials,
14
+ seal_digest,
15
+ seal_round,
16
+ seal_rows,
17
+ summarize,
18
+ )
19
+ from .stats import (
20
+ benjamini_hochberg,
21
+ cluster_interval,
22
+ cohen_kappa,
23
+ detectable_change,
24
+ effective_size,
25
+ mcnemar_exact,
26
+ paired_change,
27
+ wilson_interval,
28
+ )
29
+ from .trajectory import (
30
+ FunctionCall,
31
+ Step,
32
+ StepMetrics,
33
+ ToolCall,
34
+ Trajectory,
35
+ TrajectoryMetrics,
36
+ load_trajectory,
37
+ parse_trajectory,
38
+ )
39
+
40
+ __all__ = [
41
+ "FunctionCall",
42
+ "HarborTrial",
43
+ "RunSummary",
44
+ "Step",
45
+ "StepMetrics",
46
+ "ToolCall",
47
+ "Trajectory",
48
+ "TrajectoryMetrics",
49
+ "benjamini_hochberg",
50
+ "case_results",
51
+ "cluster_interval",
52
+ "cohen_kappa",
53
+ "detectable_change",
54
+ "effective_size",
55
+ "load_trajectory",
56
+ "mcnemar_exact",
57
+ "paired_change",
58
+ "parse_trajectory",
59
+ "read_trial",
60
+ "read_trials",
61
+ "seal_digest",
62
+ "seal_round",
63
+ "seal_rows",
64
+ "summarize",
65
+ "wilson_interval",
66
+ ]
@@ -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