otto-cli-agent 0.1.0__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.
- agent/README.md +22 -0
- agent/__init__.py +0 -0
- agent/cli/README.md +77 -0
- agent/cli/__init__.py +0 -0
- agent/cli/art.py +371 -0
- agent/cli/chat.py +309 -0
- agent/cli/clipboard.py +106 -0
- agent/cli/context.py +56 -0
- agent/cli/doctor.py +60 -0
- agent/cli/errors.py +58 -0
- agent/cli/eval.py +130 -0
- agent/cli/eval_claw.py +414 -0
- agent/cli/eval_compaction.py +106 -0
- agent/cli/eval_hle.py +92 -0
- agent/cli/eval_memory.py +253 -0
- agent/cli/eval_swe.py +172 -0
- agent/cli/lessons.py +97 -0
- agent/cli/main.py +67 -0
- agent/cli/modals.py +570 -0
- agent/cli/models.py +64 -0
- agent/cli/output.py +54 -0
- agent/cli/route.py +78 -0
- agent/cli/sessions.py +125 -0
- agent/cli/setup_screen.py +562 -0
- agent/cli/shell.py +548 -0
- agent/cli/tui.py +1807 -0
- agent/cli/ui.py +14 -0
- agent/cli/usage_panel.py +159 -0
- agent/config/README.md +7 -0
- agent/config/__init__.py +0 -0
- agent/config/envfile.py +76 -0
- agent/eval/README.md +76 -0
- agent/eval/__init__.py +0 -0
- agent/eval/claw_bench.py +1031 -0
- agent/eval/compaction_bench.py +229 -0
- agent/eval/data/README.md +10 -0
- agent/eval/data/claw/README.md +108 -0
- agent/eval/data/claw/llm_judge-gemini.patch +57 -0
- agent/eval/data/claw/otto.yaml +35 -0
- agent/eval/failures.py +276 -0
- agent/eval/golden/README.md +33 -0
- agent/eval/golden/code_01.json +6 -0
- agent/eval/golden/code_02.json +6 -0
- agent/eval/golden/code_03.json +6 -0
- agent/eval/golden/code_04.json +6 -0
- agent/eval/golden/code_05.json +6 -0
- agent/eval/golden/code_06.json +6 -0
- agent/eval/golden/math_01.json +6 -0
- agent/eval/golden/math_02.json +6 -0
- agent/eval/golden/math_03.json +6 -0
- agent/eval/golden/math_04.json +6 -0
- agent/eval/golden/math_05.json +6 -0
- agent/eval/golden/math_06.json +6 -0
- agent/eval/golden/nphard_gcp_01.json +6 -0
- agent/eval/golden/nphard_ksp_01.json +6 -0
- agent/eval/golden/nphard_math_binpacking_01.json +6 -0
- agent/eval/golden/nphard_math_clique_01.json +6 -0
- agent/eval/golden/nphard_math_setcover_01.json +6 -0
- agent/eval/golden/nphard_math_subsetsum_01.json +6 -0
- agent/eval/golden/nphard_tsp_01.json +6 -0
- agent/eval/golden/nphard_tsp_02.json +6 -0
- agent/eval/hle_bench.py +273 -0
- agent/eval/langfuse_sync.py +172 -0
- agent/eval/memory_bench.py +538 -0
- agent/eval/runner.py +174 -0
- agent/eval/single_agent.py +120 -0
- agent/eval/swe_bench.py +604 -0
- agent/eval/terminal_bench.py +345 -0
- agent/memory/README.md +102 -0
- agent/memory/__init__.py +42 -0
- agent/memory/embeddings.py +302 -0
- agent/memory/hashing.py +15 -0
- agent/memory/lessons.py +483 -0
- agent/memory/queue.py +531 -0
- agent/memory/retrieval.py +493 -0
- agent/memory/session.py +60 -0
- agent/memory/sessions.py +436 -0
- agent/memory/store.py +429 -0
- agent/memory/tokens.py +60 -0
- agent/memory/wiring.py +146 -0
- agent/pipeline/README.md +135 -0
- agent/pipeline/__init__.py +0 -0
- agent/pipeline/browsing.py +609 -0
- agent/pipeline/budget.py +403 -0
- agent/pipeline/codemap.py +254 -0
- agent/pipeline/evidence.py +325 -0
- agent/pipeline/execution.py +67 -0
- agent/pipeline/modes.py +137 -0
- agent/pipeline/native.py +1137 -0
- agent/pipeline/nodes.py +3644 -0
- agent/pipeline/pricing.py +209 -0
- agent/pipeline/progress.py +139 -0
- agent/pipeline/rag.py +139 -0
- agent/pipeline/research.py +1325 -0
- agent/pipeline/run.py +528 -0
- agent/pipeline/screen.py +77 -0
- agent/pipeline/state.py +220 -0
- agent/pipeline/toolkit.py +328 -0
- agent/pipeline/tools.py +1990 -0
- agent/pipeline/tracing.py +147 -0
- agent/pipeline/usage.py +251 -0
- agent/pipeline/vision.py +84 -0
- agent/pipeline/walkthrough.py +735 -0
- agent/pipeline/workspace.py +229 -0
- agent/router/README.md +60 -0
- agent/router/__init__.py +0 -0
- agent/router/automap.py +114 -0
- agent/router/health.py +229 -0
- agent/router/llm_provider/README.md +38 -0
- agent/router/llm_provider/__init__.py +202 -0
- agent/router/llm_provider/anthropic_provider.py +128 -0
- agent/router/llm_provider/base.py +507 -0
- agent/router/llm_provider/custom.py +152 -0
- agent/router/llm_provider/gemini_provider.py +122 -0
- agent/router/llm_provider/inception_provider.py +687 -0
- agent/router/llm_provider/openai_provider.py +151 -0
- agent/router/llm_provider/retired.py +145 -0
- agent/router/llm_provider/temperature.py +371 -0
- agent/router/mapping.py +579 -0
- agent/router/outcomes.py +363 -0
- agent/router/overrides.py +389 -0
- agent/router/reload.py +28 -0
- agent/router/router.py +413 -0
- agent/router/setup.py +123 -0
- otto_cli_agent-0.1.0.dist-info/METADATA +115 -0
- otto_cli_agent-0.1.0.dist-info/RECORD +129 -0
- otto_cli_agent-0.1.0.dist-info/WHEEL +4 -0
- otto_cli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- otto_cli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
agent/router/outcomes.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""What each seat's model actually achieved, recorded so the chain can be
|
|
2
|
+
ordered by evidence instead of by the order somebody typed it in.
|
|
3
|
+
|
|
4
|
+
Milestone 4's last piece. Logging verified per-(task, model) outcomes and
|
|
5
|
+
routing on them is worth about +15.3% relative on its own, and it is the one
|
|
6
|
+
form of adaptation that costs no model calls: the evidence is a by-product of
|
|
7
|
+
runs that were happening anyway.
|
|
8
|
+
|
|
9
|
+
WHAT IS RECORDED, AND WHY SO LITTLE. A run's approval cannot be attributed to
|
|
10
|
+
one model when the agent changed modes mid-run -- three models touched the
|
|
11
|
+
answer and only one verdict came back. So a multi-mode run records NOTHING.
|
|
12
|
+
That throws away real data, and it is still the right call: a log that
|
|
13
|
+
mis-attributes is worse than a smaller honest one, because the whole purpose
|
|
14
|
+
of the log is to be trusted enough to change routing.
|
|
15
|
+
|
|
16
|
+
WHAT IT CANNOT LEARN. The evaluator's own seat. Every run uses it, and nothing
|
|
17
|
+
in a run says whether the judge was RIGHT -- crediting the judge with the
|
|
18
|
+
verdict it issued would be a system marking its own homework. Those seats stay
|
|
19
|
+
where mapping.py pins them until something outside a run says otherwise.
|
|
20
|
+
|
|
21
|
+
THE ORDERING IS CONSERVATIVE ON PURPOSE. A candidate only moves when it has
|
|
22
|
+
`MIN_SAMPLES` runs behind it, and it is ordered on approval rate with cost as
|
|
23
|
+
the tiebreak. Below that threshold the declared order in mapping.py stands
|
|
24
|
+
exactly as written, so a fresh install, a test, and a route nobody has
|
|
25
|
+
exercised all behave the way they read.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import random
|
|
31
|
+
import sqlite3
|
|
32
|
+
from contextlib import contextmanager
|
|
33
|
+
from contextvars import ContextVar
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
log = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
#: Where the log lives. Beside the memory stores rather than in the repo: it is
|
|
40
|
+
#: a record of what this installation has observed, not a fact about the code.
|
|
41
|
+
DB_DIR = Path.home() / ".otto"
|
|
42
|
+
|
|
43
|
+
#: How many runs a (task, model) pair needs before its number is allowed to
|
|
44
|
+
#: move anything. Small samples on a benchmark whose own scores swing 0.36
|
|
45
|
+
#: between identical runs are noise, and acting on noise is how a router
|
|
46
|
+
#: convinces itself a coin is weighted.
|
|
47
|
+
MIN_SAMPLES = 12
|
|
48
|
+
|
|
49
|
+
#: How often the chain is handed to the candidate we know LEAST about instead
|
|
50
|
+
#: of the one the evidence prefers.
|
|
51
|
+
#:
|
|
52
|
+
#: Without this the ordering freezes permanently, and the first version of
|
|
53
|
+
#: this file had exactly that bug. Once a candidate is demoted it stops being
|
|
54
|
+
#: resolved, so it stops accruing runs, so its record stays frozen at the
|
|
55
|
+
#: twelve samples that demoted it -- measured: 200 further runs left the
|
|
56
|
+
#: loser on 12. Twelve runs then decide a seat forever, and a model that
|
|
57
|
+
#: later improves (a newer pin, a provider that fixed something, or simply
|
|
58
|
+
#: twelve unlucky draws) never gets a second chance.
|
|
59
|
+
#:
|
|
60
|
+
#: One in ten, and only while the log is writable: exploring without
|
|
61
|
+
#: recording what happened costs a worse answer and learns nothing, which is
|
|
62
|
+
#: also what keeps a held-out measurement reproducible.
|
|
63
|
+
EXPLORATION_RATE = 0.10
|
|
64
|
+
|
|
65
|
+
#: How much better one candidate's approval rate has to be before it overtakes
|
|
66
|
+
#: a candidate declared above it. A margin, not a strict comparison, because at
|
|
67
|
+
#: MIN_SAMPLES a difference of one run is a difference of 8 points.
|
|
68
|
+
MIN_MARGIN = 0.10
|
|
69
|
+
|
|
70
|
+
_SCHEMA = """
|
|
71
|
+
CREATE TABLE IF NOT EXISTS seat_outcomes (
|
|
72
|
+
task TEXT NOT NULL,
|
|
73
|
+
model_id TEXT NOT NULL,
|
|
74
|
+
runs INTEGER NOT NULL DEFAULT 0,
|
|
75
|
+
approved INTEGER NOT NULL DEFAULT 0,
|
|
76
|
+
calls INTEGER NOT NULL DEFAULT 0,
|
|
77
|
+
PRIMARY KEY (task, model_id)
|
|
78
|
+
);
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class SeatRecord:
|
|
84
|
+
task: str
|
|
85
|
+
model_id: str
|
|
86
|
+
runs: int
|
|
87
|
+
approved: int
|
|
88
|
+
calls: int
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def approval_rate(self) -> float:
|
|
92
|
+
return self.approved / self.runs if self.runs else 0.0
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def calls_per_run(self) -> float:
|
|
96
|
+
return self.calls / self.runs if self.runs else 0.0
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def trusted(self) -> bool:
|
|
100
|
+
return self.runs >= MIN_SAMPLES
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_UNSET = object()
|
|
104
|
+
_LOG: ContextVar = ContextVar("otto_seat_outcomes", default=_UNSET)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def log_path() -> Path:
|
|
108
|
+
return DB_DIR / "outcomes.db"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@contextmanager
|
|
112
|
+
def bind_log(path: Path | str | None):
|
|
113
|
+
"""Point the log somewhere else, or turn it off with None.
|
|
114
|
+
|
|
115
|
+
Off, not "use the default" -- same reason agent/memory/lessons.py draws
|
|
116
|
+
that distinction. A measurement that is supposed to hold routing fixed has
|
|
117
|
+
to be able to say so, or the thing being measured changes underneath it.
|
|
118
|
+
"""
|
|
119
|
+
token = _LOG.set(None if path is None else Path(path))
|
|
120
|
+
try:
|
|
121
|
+
yield
|
|
122
|
+
finally:
|
|
123
|
+
_LOG.reset(token)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
#: Whether a run may WRITE what it observed. Separate from whether it may
|
|
127
|
+
#: read, because a held-out measurement needs exactly that combination: route
|
|
128
|
+
#: on everything the development runs observed, contribute nothing back. Same
|
|
129
|
+
#: split, and the same reason, as agent/memory/lessons.py's.
|
|
130
|
+
_WRITES: ContextVar[bool] = ContextVar("otto_seat_writes", default=True)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@contextmanager
|
|
134
|
+
def read_only():
|
|
135
|
+
"""Routing may read the log, nothing may be added to it."""
|
|
136
|
+
token = _WRITES.set(False)
|
|
137
|
+
try:
|
|
138
|
+
yield
|
|
139
|
+
finally:
|
|
140
|
+
_WRITES.reset(token)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _connect() -> sqlite3.Connection | None:
|
|
144
|
+
bound = _LOG.get()
|
|
145
|
+
if bound is None:
|
|
146
|
+
return None
|
|
147
|
+
path = log_path() if bound is _UNSET else bound
|
|
148
|
+
try:
|
|
149
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
conn = sqlite3.connect(str(path), check_same_thread=False)
|
|
151
|
+
conn.executescript(_SCHEMA)
|
|
152
|
+
return conn
|
|
153
|
+
except sqlite3.Error as exc:
|
|
154
|
+
# Routing must never stop because a log could not be opened.
|
|
155
|
+
log.warning("seat outcome log unavailable: %s", exc)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def record(task: str, model_id: str, *, approved: bool, calls: int = 0) -> None:
|
|
160
|
+
"""One finished, single-mode run against one seat."""
|
|
161
|
+
if not _WRITES.get():
|
|
162
|
+
return
|
|
163
|
+
conn = _connect()
|
|
164
|
+
if conn is None or not model_id:
|
|
165
|
+
return
|
|
166
|
+
try:
|
|
167
|
+
conn.execute(
|
|
168
|
+
"INSERT INTO seat_outcomes (task, model_id, runs, approved, calls) "
|
|
169
|
+
"VALUES (?, ?, 1, ?, ?) "
|
|
170
|
+
"ON CONFLICT(task, model_id) DO UPDATE SET "
|
|
171
|
+
" runs = runs + 1, approved = approved + ?, calls = calls + ?",
|
|
172
|
+
(str(task), model_id, int(approved), int(calls),
|
|
173
|
+
int(approved), int(calls)),
|
|
174
|
+
)
|
|
175
|
+
conn.commit()
|
|
176
|
+
except sqlite3.Error as exc:
|
|
177
|
+
log.warning("could not record seat outcome: %s", exc)
|
|
178
|
+
finally:
|
|
179
|
+
conn.close()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def records(task: str | None = None) -> list[SeatRecord]:
|
|
183
|
+
"""Everything observed, best approval rate first within each task."""
|
|
184
|
+
conn = _connect()
|
|
185
|
+
if conn is None:
|
|
186
|
+
return []
|
|
187
|
+
try:
|
|
188
|
+
sql = "SELECT task, model_id, runs, approved, calls FROM seat_outcomes"
|
|
189
|
+
args: tuple = ()
|
|
190
|
+
if task is not None:
|
|
191
|
+
sql += " WHERE task = ?"
|
|
192
|
+
args = (str(task),)
|
|
193
|
+
rows = conn.execute(sql, args).fetchall()
|
|
194
|
+
except sqlite3.Error:
|
|
195
|
+
return []
|
|
196
|
+
finally:
|
|
197
|
+
conn.close()
|
|
198
|
+
found = [SeatRecord(*row) for row in rows]
|
|
199
|
+
found.sort(key=lambda r: (r.task, -r.approval_rate, r.calls_per_run))
|
|
200
|
+
return found
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def preference(task: str) -> dict[str, SeatRecord]:
|
|
204
|
+
"""Trusted records for `task`, by model id. Untrusted ones are left out
|
|
205
|
+
entirely rather than returned with a caveat -- a caller that has to
|
|
206
|
+
remember to check `trusted` is a caller that will forget."""
|
|
207
|
+
return {r.model_id: r for r in records(task) if r.trusted}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def spec_id(candidate) -> str:
|
|
211
|
+
"""The model id a candidate resolves to, when that is knowable without
|
|
212
|
+
asking a provider. A capability query is not a model yet, so it gets "" --
|
|
213
|
+
and `reorder` then leaves it exactly where it was declared, because the
|
|
214
|
+
log is keyed on a model.
|
|
215
|
+
|
|
216
|
+
Here rather than on Router because it is a fact about this log's key, and
|
|
217
|
+
the router is one caller of it.
|
|
218
|
+
"""
|
|
219
|
+
spec = getattr(candidate, "spec", None)
|
|
220
|
+
if not spec:
|
|
221
|
+
return ""
|
|
222
|
+
_, _, model_id = spec.partition(":")
|
|
223
|
+
return model_id
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def reorder(task: str, candidates, model_id_of=spec_id) -> list:
|
|
227
|
+
"""`candidates`, best-evidenced first, declared order preserved otherwise.
|
|
228
|
+
|
|
229
|
+
`model_id_of(candidate)` returns the model id a candidate would resolve to,
|
|
230
|
+
or "" when that cannot be known without asking a provider -- an open query,
|
|
231
|
+
for instance.
|
|
232
|
+
|
|
233
|
+
ONLY TWO MEASURED CANDIDATES EVER TRADE PLACES. A candidate nobody has
|
|
234
|
+
evidence about keeps its declared position, even against one with a good
|
|
235
|
+
record, because "we measured A at 90%" is not evidence that B is worse than
|
|
236
|
+
A -- B has no number at all. The declared order in mapping.py encodes a
|
|
237
|
+
person's judgment, and unseating it needs evidence on both sides.
|
|
238
|
+
|
|
239
|
+
Between two measured candidates the challenger has to be ahead by
|
|
240
|
+
`MIN_MARGIN` to overtake. Inside that margin the incumbent keeps its place
|
|
241
|
+
unless it is also spending more calls per run to get the same result, which
|
|
242
|
+
is the one case where a tie is not really a tie.
|
|
243
|
+
|
|
244
|
+
Adjacent swaps rather than a sort key: a chain is two or three candidates
|
|
245
|
+
long, and "B overtakes A" is a comparison between two records, not a score
|
|
246
|
+
each can be given on its own.
|
|
247
|
+
"""
|
|
248
|
+
known = preference(task)
|
|
249
|
+
if not known:
|
|
250
|
+
return list(candidates)
|
|
251
|
+
|
|
252
|
+
ordered = list(candidates)
|
|
253
|
+
if explorer := _explore(ordered, known, model_id_of):
|
|
254
|
+
ordered.remove(explorer)
|
|
255
|
+
return [explorer, *ordered]
|
|
256
|
+
|
|
257
|
+
for _ in range(len(ordered)):
|
|
258
|
+
settled = True
|
|
259
|
+
for i in range(len(ordered) - 1):
|
|
260
|
+
incumbent = known.get(model_id_of(ordered[i]) or "")
|
|
261
|
+
challenger = known.get(model_id_of(ordered[i + 1]) or "")
|
|
262
|
+
if _overtakes(challenger, incumbent):
|
|
263
|
+
ordered[i], ordered[i + 1] = ordered[i + 1], ordered[i]
|
|
264
|
+
settled = False
|
|
265
|
+
if settled:
|
|
266
|
+
break
|
|
267
|
+
return ordered
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _explore(candidates, known, model_id_of):
|
|
271
|
+
"""Occasionally, the trusted candidate with the fewest runs behind it.
|
|
272
|
+
|
|
273
|
+
Exploitation alone is a ratchet: the winner keeps winning because only
|
|
274
|
+
the winner is ever asked. This is the one in ten that keeps every
|
|
275
|
+
measured candidate's record alive. It returns None the rest of the time,
|
|
276
|
+
and always when the log cannot be written to -- an exploration nobody
|
|
277
|
+
records is a worse answer bought for nothing.
|
|
278
|
+
"""
|
|
279
|
+
if not _WRITES.get() or random.random() >= EXPLORATION_RATE:
|
|
280
|
+
return None
|
|
281
|
+
measured = [(known[mid], c) for c in candidates
|
|
282
|
+
if (mid := model_id_of(c) or "") in known]
|
|
283
|
+
if len(measured) < 2:
|
|
284
|
+
return None # nothing to explore between
|
|
285
|
+
return min(measured, key=lambda pair: pair[0].runs)[1]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _overtakes(challenger: SeatRecord | None, incumbent: SeatRecord | None) -> bool:
|
|
289
|
+
"""Whether the candidate declared SECOND should be tried first."""
|
|
290
|
+
if challenger is None or incumbent is None:
|
|
291
|
+
return False
|
|
292
|
+
# `>=`, not `>`. The docstring says the challenger has to be ahead BY
|
|
293
|
+
# MIN_MARGIN, and at exactly that margin a strict `>` sent it to the tie
|
|
294
|
+
# branch below instead -- so "ahead by ten points" did not overtake while
|
|
295
|
+
# "ahead by ten points and a hair" did. Nothing in the numbers justifies
|
|
296
|
+
# that line falling between them.
|
|
297
|
+
if challenger.approval_rate >= incumbent.approval_rate + MIN_MARGIN:
|
|
298
|
+
return True
|
|
299
|
+
if abs(challenger.approval_rate - incumbent.approval_rate) < MIN_MARGIN:
|
|
300
|
+
# Same result, measurably cheaper. A fifth fewer calls per run, so
|
|
301
|
+
# that ordinary run-to-run variation does not shuffle the chain.
|
|
302
|
+
return challenger.calls_per_run < incumbent.calls_per_run * 0.8
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# --------------------------------------------------------------------------
|
|
307
|
+
# Moving the log between machines
|
|
308
|
+
# --------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
def export_records(path: Path) -> int:
|
|
311
|
+
"""Every row as JSON. Returns how many."""
|
|
312
|
+
import json
|
|
313
|
+
|
|
314
|
+
rows = [{"task": r.task, "model_id": r.model_id, "runs": r.runs,
|
|
315
|
+
"approved": r.approved, "calls": r.calls} for r in records()]
|
|
316
|
+
path = Path(path).expanduser()
|
|
317
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
318
|
+
path.write_text(json.dumps(rows, indent=2) + "\n")
|
|
319
|
+
return len(rows)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def import_records(path: Path) -> int:
|
|
323
|
+
"""Merge rows from `path` ADDITIVELY -- runs, approvals and calls are
|
|
324
|
+
summed onto whatever this machine already observed, which is the only
|
|
325
|
+
semantics consistent with `record()`. Honours `read_only()`. Returns how
|
|
326
|
+
many rows were merged."""
|
|
327
|
+
import json
|
|
328
|
+
|
|
329
|
+
if not _WRITES.get():
|
|
330
|
+
return 0
|
|
331
|
+
path = Path(path).expanduser()
|
|
332
|
+
try:
|
|
333
|
+
rows = json.loads(path.read_text())
|
|
334
|
+
except (OSError, ValueError) as exc:
|
|
335
|
+
raise ValueError(f"cannot read {path}: {exc}") from exc
|
|
336
|
+
if not isinstance(rows, list):
|
|
337
|
+
raise ValueError(f"{path} does not hold a list of seat records")
|
|
338
|
+
conn = _connect()
|
|
339
|
+
if conn is None:
|
|
340
|
+
return 0
|
|
341
|
+
merged = 0
|
|
342
|
+
try:
|
|
343
|
+
for row in rows:
|
|
344
|
+
if not isinstance(row, dict) or not row.get("task") or not row.get("model_id"):
|
|
345
|
+
continue
|
|
346
|
+
runs, approved, calls = (int(row.get(k) or 0) for k in ("runs", "approved", "calls"))
|
|
347
|
+
if runs <= 0:
|
|
348
|
+
continue
|
|
349
|
+
conn.execute(
|
|
350
|
+
"INSERT INTO seat_outcomes (task, model_id, runs, approved, calls) "
|
|
351
|
+
"VALUES (?, ?, ?, ?, ?) "
|
|
352
|
+
"ON CONFLICT(task, model_id) DO UPDATE SET "
|
|
353
|
+
" runs = runs + ?, approved = approved + ?, calls = calls + ?",
|
|
354
|
+
(str(row["task"]), str(row["model_id"]), runs, approved, calls,
|
|
355
|
+
runs, approved, calls),
|
|
356
|
+
)
|
|
357
|
+
merged += 1
|
|
358
|
+
conn.commit()
|
|
359
|
+
except sqlite3.Error as exc:
|
|
360
|
+
log.warning("could not import seat outcomes: %s", exc)
|
|
361
|
+
finally:
|
|
362
|
+
conn.close()
|
|
363
|
+
return merged
|