@giamme/forge 0.10.0 → 0.11.0
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.
- package/CHANGELOG.md +30 -0
- package/SKILL.md +4 -3
- package/VERSION +1 -1
- package/docs/artifacts.md +11 -0
- package/docs/commands.md +8 -3
- package/docs/dataset.md +13 -0
- package/docs/decomposed-runs.md +13 -3
- package/docs/forge-ui.md +8 -3
- package/docs/memory.md +4 -2
- package/docs/reviews.md +73 -2
- package/forge +16 -1
- package/package.json +1 -1
- package/references/dataset.md +86 -4
- package/references/decompose.md +24 -6
- package/references/harnesses.md +43 -1
- package/references/ui.md +4 -1
- package/scripts/forge-config.py +5 -2
- package/scripts/forge-control.py +4 -0
- package/scripts/forge-dispatch.sh +235 -121
- package/scripts/forge-issues.py +344 -16
- package/scripts/forge-lib-plans.sh +6 -2
- package/scripts/forge-lib-review.sh +158 -3
- package/scripts/forge-lib-task.sh +132 -14
- package/scripts/forge-parallel.sh +96 -11
- package/scripts/forge-prompt.py +61 -4
- package/scripts/forge-route.py +72 -12
- package/scripts/forge-runtime.py +47 -1
- package/scripts/forge_dataset/__init__.py +7 -2
- package/scripts/forge_dataset/cli.py +44 -1
- package/scripts/forge_dataset/export.py +36 -3
- package/scripts/forge_dataset/forecast.py +355 -0
- package/scripts/forge_dataset/harvest.py +23 -2
- package/scripts/forge_dataset/importer.py +20 -8
- package/scripts/forge_dataset/reviewers.py +145 -0
- package/scripts/forge_dataset/routing.py +1 -0
- package/scripts/forge_jev/__init__.py +7 -3
- package/scripts/forge_registry.py +15 -10
- package/ui/README.md +4 -3
- package/ui/docs/api.md +75 -15
- package/ui/forge_ui/control.py +12 -0
- package/ui/forge_ui/reader.py +184 -11
- package/ui/forge_ui/static/app.js +24 -10
- package/ui/forge_ui/static/cloud3d.js +4 -1
- package/ui/forge_ui/static/cloudmodel.js +1 -1
- package/ui/forge_ui/static/cloudui.js +13 -4
- package/ui/forge_ui/static/findingsview.js +29 -3
- package/ui/forge_ui/static/graph.js +6 -5
- package/ui/forge_ui/static/leaderboard.js +18 -0
- package/ui/forge_ui/static/modelstats.js +19 -1
- package/ui/forge_ui/static/panel.js +52 -10
- package/ui/forge_ui/static/runmodel.js +128 -13
- package/ui/forge_ui/static/scene3d.js +32 -15
- package/ui/forge_ui/static/style.css +7 -3
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""`forge dataset forecast <plan>`: what a plan is likely to cost, from forge's own history.
|
|
2
|
+
|
|
3
|
+
For each task of a plan, the tasks in the dataset that ran at the same tier on the same
|
|
4
|
+
model (the dwarf spec resolved through the registry; the effort too when the spec names one)
|
|
5
|
+
say what to expect: the median and p90 of tokens (input + output, dwarf and QA, over every
|
|
6
|
+
attempt) and of wall time (the same dispatches' durations), and the first-attempt PASS rate
|
|
7
|
+
with a 95% Wilson interval. Below MIN_HISTORY matching tasks a row says "not enough history"
|
|
8
|
+
instead of numbers a handful of tasks would make up, and a task whose rate is under
|
|
9
|
+
FLAG_BELOW is flagged as likely to need a retry.
|
|
10
|
+
|
|
11
|
+
Advisory only: nothing that routes a task, builds a prompt or gates a run reads it. The
|
|
12
|
+
rates are observational (who ran what was not randomized unless `--explore` drew it), so a
|
|
13
|
+
low rate can be the tasks a model was given rather than the model.
|
|
14
|
+
|
|
15
|
+
`forge-parallel.sh run` prints a short version before dispatching (`--no-forecast` or
|
|
16
|
+
FORGE_FORECAST=off skip it). There it runs under a time budget (FORGE_FORECAST_BUDGET_S,
|
|
17
|
+
default 3s), is silent when there is no history, never changes the run's exit code, and
|
|
18
|
+
records what it predicted: `<plan>/forecast.json` and a `forecast` dataset event keyed by
|
|
19
|
+
the run, so a later report can check the forecast against what happened.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import math
|
|
26
|
+
import os
|
|
27
|
+
import statistics
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
from collections import defaultdict
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from . import SKILL, connect, dataset_dir, record
|
|
35
|
+
from .export import Runs, Store
|
|
36
|
+
from .routing import tasks as routing_tasks, wilson
|
|
37
|
+
|
|
38
|
+
MIN_HISTORY = 5
|
|
39
|
+
FLAG_BELOW = 0.5
|
|
40
|
+
BUDGET_S = 3.0
|
|
41
|
+
OFF = {'0', 'off', 'false', 'no'}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --- the registry, as forge-route.py reads it (that module's name is not importable) ---
|
|
45
|
+
|
|
46
|
+
def registry_models() -> dict:
|
|
47
|
+
"""{alias: {harness: model id}}; the first row of an alias is its default."""
|
|
48
|
+
out = {}
|
|
49
|
+
path = Path(os.environ.get('FORGE_REGISTRY') or str(SKILL / 'registry.tsv'))
|
|
50
|
+
try:
|
|
51
|
+
lines = path.read_text().splitlines()
|
|
52
|
+
except OSError:
|
|
53
|
+
return out
|
|
54
|
+
for line in lines:
|
|
55
|
+
if not line.strip() or line.startswith('#'):
|
|
56
|
+
continue
|
|
57
|
+
cells = line.split('\t')
|
|
58
|
+
if len(cells) >= 3:
|
|
59
|
+
out.setdefault(cells[0], {}).setdefault(cells[1], cells[2])
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve(spec, registry: dict) -> tuple:
|
|
64
|
+
"""(model id, effort or None) a dwarf spec such as `sol`, `sol:high` or `sol:high:codex` runs."""
|
|
65
|
+
parts = str(spec or '').split(':')
|
|
66
|
+
alias = parts[0]
|
|
67
|
+
effort = parts[1] if len(parts) > 1 and parts[1] else None
|
|
68
|
+
harness = parts[2] if len(parts) > 2 else None
|
|
69
|
+
rows = registry.get(alias)
|
|
70
|
+
if not rows:
|
|
71
|
+
return alias or None, effort
|
|
72
|
+
if harness and harness in rows:
|
|
73
|
+
return rows[harness], effort
|
|
74
|
+
return next(iter(rows.values())), effort
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --- reading the plan ---
|
|
78
|
+
|
|
79
|
+
def _tsv(path: Path) -> list:
|
|
80
|
+
try:
|
|
81
|
+
lines = path.read_text().splitlines()
|
|
82
|
+
except OSError:
|
|
83
|
+
return []
|
|
84
|
+
cols = ('id', 'deps', 'difficulty', 'files', 'dwarf', 'qa', 'title')
|
|
85
|
+
return [dict(zip(cols, line.split('\t'))) for line in lines if line.strip() and not line.startswith('#')]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def plan_tasks(plan: Path) -> tuple:
|
|
89
|
+
"""([task dicts to forecast], [ids already merged]) from tasks.tsv (declared tier when kept)."""
|
|
90
|
+
declared = {r['id']: r for r in _tsv(plan / 'tasks.declared.tsv')}
|
|
91
|
+
out, merged = [], []
|
|
92
|
+
for row in _tsv(plan / 'tasks.tsv'):
|
|
93
|
+
tid = row.get('id')
|
|
94
|
+
if not tid:
|
|
95
|
+
continue
|
|
96
|
+
if (plan / 'tasks' / tid / 'merged').exists():
|
|
97
|
+
merged.append(tid)
|
|
98
|
+
continue
|
|
99
|
+
tier = (declared.get(tid) or {}).get('difficulty') or row.get('difficulty') or None
|
|
100
|
+
out.append(dict(task=tid, tier=tier, spec=row.get('dwarf') or None))
|
|
101
|
+
return out, merged
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def waves(plan: Path) -> dict:
|
|
105
|
+
out = {}
|
|
106
|
+
try:
|
|
107
|
+
lines = (plan / 'waves.tsv').read_text().splitlines()
|
|
108
|
+
except OSError:
|
|
109
|
+
return out
|
|
110
|
+
for line in lines:
|
|
111
|
+
cells = line.split('\t')
|
|
112
|
+
if len(cells) >= 2 and cells[1].strip():
|
|
113
|
+
out[cells[1].strip()] = cells[0].strip()
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def run_uid(plan: Path):
|
|
118
|
+
try:
|
|
119
|
+
value = (plan / 'dataset.uid').read_text().strip()
|
|
120
|
+
except OSError:
|
|
121
|
+
return None
|
|
122
|
+
return value or None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- history ---
|
|
126
|
+
|
|
127
|
+
def _cost(runs: Runs, run, task) -> tuple:
|
|
128
|
+
"""(tokens, seconds) a task's dwarf and QA dispatches took over every attempt; None when unrecorded."""
|
|
129
|
+
tokens = seconds = None
|
|
130
|
+
for role in ('dwarf', 'qa'):
|
|
131
|
+
for e in runs.dispatches.get((run, task, role), []):
|
|
132
|
+
t = e['d'].get('tokens') or {}
|
|
133
|
+
used = [t.get(k) for k in ('input', 'output') if isinstance(t.get(k), (int, float))]
|
|
134
|
+
if used:
|
|
135
|
+
tokens = (tokens or 0) + sum(used)
|
|
136
|
+
d = e['d'].get('duration_s')
|
|
137
|
+
if isinstance(d, (int, float)):
|
|
138
|
+
seconds = (seconds or 0) + d
|
|
139
|
+
return tokens, seconds
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def history(runs: Runs, registry: dict, *, repo=None, exclude_run=None) -> list:
|
|
143
|
+
"""Every task that ran, with the (tier, model, effort) it ran at and what it cost."""
|
|
144
|
+
out = []
|
|
145
|
+
for r in routing_tasks(runs, repo=repo):
|
|
146
|
+
if not r['attempts'] or r['run'] == exclude_run:
|
|
147
|
+
continue
|
|
148
|
+
model, effort = r.get('model_id'), r.get('effort')
|
|
149
|
+
if not model:
|
|
150
|
+
model, effort = resolve(r['ran'], registry)
|
|
151
|
+
if not model:
|
|
152
|
+
continue
|
|
153
|
+
tokens, seconds = _cost(runs, r['run'], r['task'])
|
|
154
|
+
out.append(dict(tier=r['tier'], model=model, effort=effort, first_pass=r['first_pass'],
|
|
155
|
+
tokens=tokens, seconds=seconds))
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def nearest_rank(values: list, q: float):
|
|
160
|
+
ordered = sorted(values)
|
|
161
|
+
return ordered[max(0, math.ceil(q * len(ordered)) - 1)]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _spread(values: list) -> dict:
|
|
165
|
+
if len(values) < MIN_HISTORY:
|
|
166
|
+
return dict(n=len(values), median=None, p90=None)
|
|
167
|
+
return dict(n=len(values), median=statistics.median(values), p90=nearest_rank(values, 0.9))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def forecast_task(task: dict, past: list, registry: dict) -> dict:
|
|
171
|
+
model, effort = resolve(task['spec'], registry) if task['spec'] else (None, None)
|
|
172
|
+
same = [h for h in past if model and h['tier'] == task['tier'] and h['model'] == model
|
|
173
|
+
and (effort is None or h['effort'] == effort)]
|
|
174
|
+
outcomes = [h['first_pass'] for h in same if h['first_pass'] is not None]
|
|
175
|
+
k, n = sum(1 for x in outcomes if x), len(outcomes)
|
|
176
|
+
enough = n >= MIN_HISTORY
|
|
177
|
+
low, high = wilson(k, n) if enough else (None, None)
|
|
178
|
+
rate = k / n if enough else None
|
|
179
|
+
return dict(task=task['task'], tier=task['tier'], spec=task['spec'], model=model, effort=effort,
|
|
180
|
+
history=len(same), enough=enough,
|
|
181
|
+
first_pass=dict(k=k, n=n, rate=rate, ci=[low, high] if enough else None),
|
|
182
|
+
tokens=_spread([h['tokens'] for h in same if h['tokens'] is not None]),
|
|
183
|
+
time_s=_spread([h['seconds'] for h in same if h['seconds'] is not None]),
|
|
184
|
+
flag=bool(enough and rate < FLAG_BELOW),
|
|
185
|
+
note=None if enough else 'not enough history (%d/%d)' % (n, MIN_HISTORY))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def totals(rows: list, wave_of: dict) -> dict:
|
|
189
|
+
tok = [r for r in rows if r['tokens']['median'] is not None]
|
|
190
|
+
sec = [r for r in rows if r['time_s']['median'] is not None]
|
|
191
|
+
rated = [r for r in rows if r['enough']]
|
|
192
|
+
by_wave = defaultdict(float)
|
|
193
|
+
for r in sec:
|
|
194
|
+
w = wave_of.get(r['task'], r['task'])
|
|
195
|
+
by_wave[w] = max(by_wave[w], r['time_s']['median'])
|
|
196
|
+
return dict(
|
|
197
|
+
tasks=len(rows), forecast=len(rated), flagged=sum(1 for r in rows if r['flag']),
|
|
198
|
+
tokens=dict(tasks=len(tok), median=sum(r['tokens']['median'] for r in tok) if tok else None,
|
|
199
|
+
p90=sum(r['tokens']['p90'] for r in tok) if tok else None),
|
|
200
|
+
time_s=dict(tasks=len(sec), median=sum(r['time_s']['median'] for r in sec) if sec else None,
|
|
201
|
+
p90=sum(r['time_s']['p90'] for r in sec) if sec else None,
|
|
202
|
+
by_waves=sum(by_wave.values()) if (sec and wave_of) else None),
|
|
203
|
+
first_pass_expected=sum(r['first_pass']['rate'] for r in rated) if rated else None)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def build(plan: Path, *, repo=None) -> dict | None:
|
|
207
|
+
"""The forecast for a plan, or None when there is no history at all (no store, no tasks)."""
|
|
208
|
+
# Reading must not create a store: a fresh machine gets nothing, not an empty database.
|
|
209
|
+
if not (dataset_dir() / 'dataset.sqlite').exists():
|
|
210
|
+
return None
|
|
211
|
+
db = connect()
|
|
212
|
+
try:
|
|
213
|
+
runs = Runs(Store(db))
|
|
214
|
+
finally:
|
|
215
|
+
db.close()
|
|
216
|
+
registry = registry_models()
|
|
217
|
+
uid = run_uid(plan)
|
|
218
|
+
past = history(runs, registry, repo=repo, exclude_run=uid)
|
|
219
|
+
if not past:
|
|
220
|
+
return None
|
|
221
|
+
todo, merged = plan_tasks(plan)
|
|
222
|
+
rows = [forecast_task(t, past, registry) for t in todo]
|
|
223
|
+
return dict(plan=str(plan), run=uid, at=time.time(), min_history=MIN_HISTORY, flag_below=FLAG_BELOW,
|
|
224
|
+
history_tasks=len(past), tasks=rows, merged=merged, total=totals(rows, waves(plan)))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# --- output ---
|
|
228
|
+
|
|
229
|
+
def _tokens(v):
|
|
230
|
+
if v is None:
|
|
231
|
+
return '?'
|
|
232
|
+
return '%.1fM' % (v / 1e6) if v >= 1e6 else ('%.0fk' % (v / 1e3) if v >= 1e3 else '%d' % v)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _time(v):
|
|
236
|
+
if v is None:
|
|
237
|
+
return '?'
|
|
238
|
+
v = int(round(v))
|
|
239
|
+
return '%dh%02dm' % (v // 3600, v % 3600 // 60) if v >= 3600 else ('%dm%02ds' % (v // 60, v % 60) if v >= 60 else '%ds' % v)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _pct(v):
|
|
243
|
+
return '%.0f%%' % (v * 100)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def lines(fc: dict) -> list:
|
|
247
|
+
out = ['forecast from %d past task(s) at the same tier and model (advisory; routing never reads it)'
|
|
248
|
+
% fc['history_tasks']]
|
|
249
|
+
for r in fc['tasks']:
|
|
250
|
+
where = '%s %s' % (r['tier'] or '?', r['spec'] or '?')
|
|
251
|
+
if not r['enough']:
|
|
252
|
+
out.append(' %-14s %-22s %s' % (r['task'], where, r['note']))
|
|
253
|
+
continue
|
|
254
|
+
fp = r['first_pass']
|
|
255
|
+
t, s = r['tokens'], r['time_s']
|
|
256
|
+
out.append(' %-14s %-22s first try %s [%s-%s] (%d/%d) tokens %s (p90 %s) time %s (p90 %s)%s' % (
|
|
257
|
+
r['task'], where, _pct(fp['rate']), _pct(fp['ci'][0]), _pct(fp['ci'][1]), fp['k'], fp['n'],
|
|
258
|
+
_tokens(t['median']), _tokens(t['p90']), _time(s['median']), _time(s['p90']),
|
|
259
|
+
' <- likely to need a retry' if r['flag'] else ''))
|
|
260
|
+
if fc['merged']:
|
|
261
|
+
out.append(' %d task(s) already merged, not forecast' % len(fc['merged']))
|
|
262
|
+
tot = fc['total']
|
|
263
|
+
if tot['forecast'] or tot['tokens']['tasks']:
|
|
264
|
+
tk, tm = tot['tokens'], tot['time_s']
|
|
265
|
+
summary = ' total for %d/%d task(s) with history: tokens %s (p90 %s), time %s (p90 %s) summed' % (
|
|
266
|
+
max(tk['tasks'], tm['tasks']), tot['tasks'], _tokens(tk['median']), _tokens(tk['p90']),
|
|
267
|
+
_time(tm['median']), _time(tm['p90']))
|
|
268
|
+
if tm['by_waves'] is not None:
|
|
269
|
+
summary += ', about %s by waves (ignores --max-parallel)' % _time(tm['by_waves'])
|
|
270
|
+
out.append(summary)
|
|
271
|
+
if tot['first_pass_expected'] is not None:
|
|
272
|
+
out.append(' expected first-try passes: %.1f of %d forecast task(s)%s' % (
|
|
273
|
+
tot['first_pass_expected'], tot['forecast'],
|
|
274
|
+
', %d flagged under %s' % (tot['flagged'], _pct(fc['flag_below'])) if tot['flagged'] else ''))
|
|
275
|
+
return out
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def save(plan: Path, fc: dict) -> None:
|
|
279
|
+
"""Keep what was predicted, so it can be checked against what happened. Fail-open."""
|
|
280
|
+
body = {k: v for k, v in fc.items() if k != 'at'}
|
|
281
|
+
digest = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:16]
|
|
282
|
+
try:
|
|
283
|
+
tmp = plan / ('.forecast.json.%d' % os.getpid())
|
|
284
|
+
tmp.write_text(json.dumps(fc, indent=2, sort_keys=True) + '\n')
|
|
285
|
+
tmp.replace(plan / 'forecast.json')
|
|
286
|
+
except OSError:
|
|
287
|
+
pass
|
|
288
|
+
if not fc.get('run'): # record() itself does nothing under FORGE_DATASET=off
|
|
289
|
+
return
|
|
290
|
+
from .harvest import first_line, locate
|
|
291
|
+
run_id = locate(plan).get('run_id') or plan.name
|
|
292
|
+
repo = first_line(plan / 'repo') or None
|
|
293
|
+
record([dict(rid='forecast:%s:%s' % (fc['run'], digest), kind='forecast', ts=fc['at'], src='live',
|
|
294
|
+
run=fc['run'], run_id=run_id, mode='parallel', task=None, data=fc)], repo=repo, where='forecast')
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def advise(plan: Path, shown: list) -> None:
|
|
298
|
+
"""The run's advisory block: print it, record it. Never raises."""
|
|
299
|
+
try:
|
|
300
|
+
fc = build(plan)
|
|
301
|
+
except Exception:
|
|
302
|
+
return
|
|
303
|
+
if not fc or not fc['tasks']:
|
|
304
|
+
return
|
|
305
|
+
for line in lines(fc):
|
|
306
|
+
print(line)
|
|
307
|
+
sys.stdout.flush()
|
|
308
|
+
shown.append(True)
|
|
309
|
+
try:
|
|
310
|
+
save(plan, fc)
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def budget() -> float:
|
|
316
|
+
try:
|
|
317
|
+
value = float(os.environ.get('FORGE_FORECAST_BUDGET_S') or BUDGET_S)
|
|
318
|
+
except ValueError:
|
|
319
|
+
return BUDGET_S
|
|
320
|
+
return value if value > 0 else BUDGET_S
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def main(args) -> int:
|
|
324
|
+
plan = Path(args.plan).resolve()
|
|
325
|
+
if args.run:
|
|
326
|
+
# Called by forge-parallel.sh run: silent on anything unexpected, exit 0, and never longer than the budget.
|
|
327
|
+
if os.environ.get('FORGE_FORECAST', 'on').strip().lower() in OFF or not (plan / 'tasks.tsv').is_file():
|
|
328
|
+
return 0
|
|
329
|
+
shown = []
|
|
330
|
+
worker = threading.Thread(target=advise, args=(plan, shown), daemon=True)
|
|
331
|
+
worker.start()
|
|
332
|
+
worker.join(budget())
|
|
333
|
+
if worker.is_alive():
|
|
334
|
+
# Past the budget the run goes on without it. Once the block is out, only the
|
|
335
|
+
# recording is cut short, which is not worth a line.
|
|
336
|
+
if not shown:
|
|
337
|
+
print('forecast skipped: reading the history took over %gs' % budget())
|
|
338
|
+
sys.stdout.flush()
|
|
339
|
+
os._exit(0)
|
|
340
|
+
return 0
|
|
341
|
+
if not (plan / 'tasks.tsv').is_file():
|
|
342
|
+
print('forge dataset forecast: no tasks.tsv in %s' % plan, file=sys.stderr)
|
|
343
|
+
return 2
|
|
344
|
+
fc = build(plan, repo=os.path.realpath(args.repo) if args.repo else None)
|
|
345
|
+
if args.json:
|
|
346
|
+
print(json.dumps(fc, indent=2, sort_keys=True))
|
|
347
|
+
return 0
|
|
348
|
+
if not fc:
|
|
349
|
+
print('no task history in the dataset yet, so nothing to forecast from')
|
|
350
|
+
return 0
|
|
351
|
+
for line in lines(fc):
|
|
352
|
+
print(line)
|
|
353
|
+
print(' tokens are input + output of the dwarf and QA dispatches over every attempt; time is their summed duration')
|
|
354
|
+
print(' rates are observational: a low one can be the tasks a model was given rather than the model')
|
|
355
|
+
return 0
|
|
@@ -30,7 +30,7 @@ PIPELINE_ROLES = ('task', 'solo', 'verification', 'parallel')
|
|
|
30
30
|
TASKS_COLS = ('id', 'deps', 'difficulty', 'files', 'dwarf', 'qa', 'title')
|
|
31
31
|
STRUCTURED = ('codex', 'claude', 'openclaude', 'grok', 'opencode') # as forge-runtime.py's: .last is the message, not the log
|
|
32
32
|
# Plan files that change what a plan decides, added to its rid only when present (see plan_event).
|
|
33
|
-
PLAN_RID_LATER = ('decompose_level', 'shadow_qa', 'shadow_rate', 'explore')
|
|
33
|
+
PLAN_RID_LATER = ('decompose_level', 'shadow_qa', 'shadow_rate', 'explore', 'reviewer_session')
|
|
34
34
|
|
|
35
35
|
# Files a task or solo execution may write. begin() snapshots their signatures so
|
|
36
36
|
# finish() can tell this execution's outputs from what an earlier one left behind.
|
|
@@ -369,12 +369,32 @@ def intervention_rid(name: str, uid: str | None, task: str | None, values: dict)
|
|
|
369
369
|
|
|
370
370
|
# --- dispatch: one model call ----------------------------------------------------------
|
|
371
371
|
|
|
372
|
+
def session_record(attempt: Path, role: str, metrics: dict) -> tuple[str | None, str | None]:
|
|
373
|
+
"""The harness session, and whether this dispatch was asked to continue one.
|
|
374
|
+
|
|
375
|
+
`<role>.session` is the id live capture published; `metrics.json` fills it in when an
|
|
376
|
+
archive has the id and not the file. `<role>.continue` exists only when continuing was
|
|
377
|
+
requested. `mode=continued` is the success. Any other mode, or a file with no mode, is
|
|
378
|
+
`failed`: the request is what makes the judgment not independent, and a failed continue
|
|
379
|
+
can still have printed a verdict.
|
|
380
|
+
"""
|
|
381
|
+
recorded = first_line(attempt / (role + '.session'))
|
|
382
|
+
if not recorded:
|
|
383
|
+
value = metrics.get('session_id')
|
|
384
|
+
recorded = value.strip() if isinstance(value, str) and value.strip() else None
|
|
385
|
+
path = attempt / (role + '.continue')
|
|
386
|
+
if not path.exists():
|
|
387
|
+
return recorded, None
|
|
388
|
+
return recorded, 'continued' if kv(path).get('mode') == 'continued' else 'failed'
|
|
389
|
+
|
|
390
|
+
|
|
372
391
|
def dispatch_event(attempt: Path, root: Path, role: str, harness: str, rc, prov: dict, src: str = 'live') -> dict:
|
|
373
392
|
metrics = read_json(attempt / 'metrics.json') or {}
|
|
374
393
|
resolved = kv(attempt / (role + '.resolved'))
|
|
375
394
|
infra = kv(attempt / (role + '.infra'))
|
|
376
395
|
harness = harness or metrics.get('harness') or resolved.get('harness')
|
|
377
396
|
message = text(attempt / (role + '.last'))
|
|
397
|
+
session_id, session_continue = session_record(attempt, role, metrics)
|
|
378
398
|
final_source = 'structured' if harness in STRUCTURED else 'log_copy'
|
|
379
399
|
delivery = resolved.get('delivery') or 'custom'
|
|
380
400
|
orphans = text(attempt / (role + '.orphans'))
|
|
@@ -399,6 +419,7 @@ def dispatch_event(attempt: Path, root: Path, role: str, harness: str, rc, prov:
|
|
|
399
419
|
prompt_bytes=metrics.get('prompt_bytes'), final_source=final_source,
|
|
400
420
|
verdict=verdict, findings=found, learnings=learnings(message),
|
|
401
421
|
findings_parse=review_completeness(verdict, found, parsed) if role == 'qa' else None,
|
|
422
|
+
session_id=session_id, session_continue=session_continue,
|
|
402
423
|
root=str(root), scope=prov.get('scope'), trigger=prov.get('trigger'),
|
|
403
424
|
**({'shadow': shadow} if shadow else {}),
|
|
404
425
|
)
|
|
@@ -672,7 +693,7 @@ def plan_event(plan: Path, prov: dict, src: str = 'live', inventory: bool = True
|
|
|
672
693
|
fractal_selection=read_json(plan / 'fractal-selection.json'),
|
|
673
694
|
jev_coupling=read_json(plan / 'jev-coupling.json'),
|
|
674
695
|
options={name: first_line(plan / name) for name in ('qa_threshold', 'verify_retries', 'timeout', 'final_qa',
|
|
675
|
-
'shadow_qa', 'shadow_rate', 'explore')
|
|
696
|
+
'shadow_qa', 'shadow_rate', 'explore', 'reviewer_session')
|
|
676
697
|
if (plan / name).exists()},
|
|
677
698
|
flags=sorted(name for name in ('no_memory', 'no_ripwire', 'yolo_dwarf', 'yolo_qa') if (plan / name).exists()),
|
|
678
699
|
blobs=blobs(goal=put_file(plan / 'goal.txt'), request=put_file(plan / 'request.md', 'prompt'),
|
|
@@ -501,13 +501,19 @@ ARCHIVE_FORMAT = 1
|
|
|
501
501
|
ARCHIVE_MAX_FILE = 50 << 20
|
|
502
502
|
|
|
503
503
|
|
|
504
|
-
def _archive_mapper(work: Path, source: Path | None) -> Mapper:
|
|
505
|
-
"""
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
504
|
+
def _archive_mapper(work: Path, source: Path | None) -> tuple[Path, Mapper]:
|
|
505
|
+
"""The run dir in an extracted archive, and where that dir lived.
|
|
506
|
+
|
|
507
|
+
FORGE_ARCHIVE.json names it. A legacy MANIFEST archive holds it under the source
|
|
508
|
+
path's basename; the extraction root still maps to the source's parent, as before.
|
|
509
|
+
Import walks that dir, so a run named review-* is read and the review-* skip applies
|
|
510
|
+
only inside a run. An archive whose layout cannot be identified is unchanged: a bad
|
|
511
|
+
description or a forge archive with no run dir still raises, and anything else is
|
|
512
|
+
walked from the extraction root. The archive's word wins, and it brings the run's
|
|
513
|
+
dataset.uid along when the dir it was taken from no longer has one.
|
|
509
514
|
"""
|
|
510
515
|
mapper = Mapper()
|
|
516
|
+
root = work
|
|
511
517
|
meta_path = work / ARCHIVE_META
|
|
512
518
|
if meta_path.is_file():
|
|
513
519
|
meta = json.loads(meta_path.read_text())
|
|
@@ -530,9 +536,13 @@ def _archive_mapper(work: Path, source: Path | None) -> Mapper:
|
|
|
530
536
|
for rel, value in (legacy.items() if isinstance(legacy, dict) else ()):
|
|
531
537
|
if isinstance(value, str) and value.strip() and not os.path.isabs(rel) and '..' not in Path(rel).parts:
|
|
532
538
|
mapper.legacy[str(Path(original) / rel)] = value.strip()
|
|
539
|
+
root = run
|
|
533
540
|
elif source is not None:
|
|
534
541
|
mapper.add(work, source.parent)
|
|
535
|
-
|
|
542
|
+
named = work / Path(source).name
|
|
543
|
+
if named.is_dir():
|
|
544
|
+
root = named
|
|
545
|
+
return root, mapper
|
|
536
546
|
|
|
537
547
|
|
|
538
548
|
def import_run(path, strict: bool = True, include_temp: bool = True) -> dict:
|
|
@@ -577,7 +587,8 @@ def import_run(path, strict: bool = True, include_temp: bool = True) -> dict:
|
|
|
577
587
|
work = Path(tempfile.mkdtemp(prefix='forge-dataset-import-'))
|
|
578
588
|
try:
|
|
579
589
|
_extract(path, work)
|
|
580
|
-
|
|
590
|
+
root, mapper = _archive_mapper(work, None)
|
|
591
|
+
write(import_tree(root, mapper, args, counts), blob_failures)
|
|
581
592
|
except Exception as error:
|
|
582
593
|
counts.setdefault('failed', []).append('%s: %s' % (path, error))
|
|
583
594
|
finally:
|
|
@@ -789,7 +800,8 @@ def main(args) -> int:
|
|
|
789
800
|
work = Path(tempfile.mkdtemp(prefix='forge-dataset-import-'))
|
|
790
801
|
try:
|
|
791
802
|
_extract(archive, work)
|
|
792
|
-
|
|
803
|
+
root, mapper = _archive_mapper(work, source)
|
|
804
|
+
write(import_tree(root, mapper, args, counts))
|
|
793
805
|
except Exception as error:
|
|
794
806
|
counts.setdefault('failed', []).append('%s: %s' % (archive, error))
|
|
795
807
|
finally:
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""`forge dataset reviewers`: how the work each reviewer passed held up afterwards.
|
|
2
|
+
|
|
3
|
+
A report card per reviewer (qa) model. For every attempt a primary reviewer PASSed whose
|
|
4
|
+
reviewed commit was integrated and then followed by `forge dataset outcomes`, the outcome
|
|
5
|
+
at one horizon (default 14 days) says whether the work held: it did not if a later commit
|
|
6
|
+
reverted it, or if fewer than 80% of the lines it added were still there (rewritten; the
|
|
7
|
+
same rule as the export's `survives` label). The rate of work that did not hold has a
|
|
8
|
+
95% Wilson interval, and is only given from MIN_LANDED outcomes on: below that the row
|
|
9
|
+
says "not enough data" rather than a rate a handful of tasks would make up.
|
|
10
|
+
|
|
11
|
+
Advisory only. Nothing that builds a prompt or routes a task reads this report, and it
|
|
12
|
+
counts only what forge saw: survival is followed for parallel integrations
|
|
13
|
+
(`integrate_advanced`), so solo runs and work merged by hand are not here. A row is keyed by
|
|
14
|
+
the reviewer's alias, as the forge-ui Models view is, or by its model when no alias was recorded.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from collections import defaultdict
|
|
22
|
+
|
|
23
|
+
from . import connect, dataset_dir
|
|
24
|
+
from .export import SURVIVES_AT, Runs, Store
|
|
25
|
+
from .routing import wilson
|
|
26
|
+
|
|
27
|
+
MIN_LANDED = 10
|
|
28
|
+
HORIZON_DAYS = 14
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _empty(horizon_days):
|
|
32
|
+
return dict(horizon_days=horizon_days, min_landed=MIN_LANDED, survives_at=SURVIVES_AT, reviewers=[],
|
|
33
|
+
unattributed=dict(not_passed=0, no_review=0), skipped=dict(lost=0, no_additions=0),
|
|
34
|
+
other_horizons=[])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def report(runs: Runs, *, horizon_days: int = HORIZON_DAYS, repo: str = None) -> dict:
|
|
38
|
+
out = _empty(horizon_days)
|
|
39
|
+
paths = runs.store.repo_paths
|
|
40
|
+
# Which attempts reviewed each commit: a QA-only resume or a combined task can share one.
|
|
41
|
+
attempts_of = defaultdict(list)
|
|
42
|
+
for key, commit in runs.reviewed.items():
|
|
43
|
+
attempts_of[commit].append(key)
|
|
44
|
+
outcomes, horizons = {}, set()
|
|
45
|
+
for e in runs.store.of('outcome'):
|
|
46
|
+
d = e['d']
|
|
47
|
+
if d.get('name') != 'survival' or not d.get('reviewed'):
|
|
48
|
+
continue
|
|
49
|
+
if repo and paths.get(e['repo']) != repo:
|
|
50
|
+
continue
|
|
51
|
+
if d.get('horizon_days') != horizon_days:
|
|
52
|
+
horizons.add(d.get('horizon_days'))
|
|
53
|
+
continue
|
|
54
|
+
outcomes.setdefault(d['reviewed'], d) # one outcome per commit and horizon
|
|
55
|
+
out['other_horizons'] = sorted(h for h in horizons if isinstance(h, int))
|
|
56
|
+
rows = {}
|
|
57
|
+
for commit, d in sorted(outcomes.items()):
|
|
58
|
+
if d.get('status') == 'lost':
|
|
59
|
+
out['skipped']['lost'] += 1
|
|
60
|
+
continue
|
|
61
|
+
if d.get('status') != 'ok' or not d.get('added'):
|
|
62
|
+
out['skipped']['no_additions'] += 1
|
|
63
|
+
continue
|
|
64
|
+
reviews = []
|
|
65
|
+
for run, task, attempt in attempts_of.get(commit, ()):
|
|
66
|
+
reviews += [r for r in runs.dispatches.get((run, task, 'qa'), ()) if r['attempt'] == attempt]
|
|
67
|
+
passed = {}
|
|
68
|
+
for r in sorted(reviews, key=lambda r: r['ts']):
|
|
69
|
+
name = r['d'].get('alias') or r['d'].get('model')
|
|
70
|
+
if r['d'].get('verdict') == 'PASS' and name:
|
|
71
|
+
passed[name] = r['d']
|
|
72
|
+
if not passed:
|
|
73
|
+
# Reviewed and FAILed, then accepted by the severity gate or a human; or no review recorded.
|
|
74
|
+
out['unattributed']['not_passed' if reviews else 'no_review'] += 1
|
|
75
|
+
continue
|
|
76
|
+
reverted = bool(d.get('reverted_by'))
|
|
77
|
+
rewritten = not reverted and d['surviving'] / d['added'] < SURVIVES_AT
|
|
78
|
+
for name, r in passed.items():
|
|
79
|
+
row = rows.setdefault(name, dict(reviewer=name, specs=set(), landed=0, reverted=0, rewritten=0))
|
|
80
|
+
spec = ':'.join(x for x in (r.get('model'), r.get('effort')) if x)
|
|
81
|
+
if spec:
|
|
82
|
+
row['specs'].add(spec)
|
|
83
|
+
row['landed'] += 1
|
|
84
|
+
row['reverted'] += reverted
|
|
85
|
+
row['rewritten'] += rewritten
|
|
86
|
+
for row in rows.values():
|
|
87
|
+
row['specs'] = sorted(row['specs'])
|
|
88
|
+
row['did_not_hold'] = row['reverted'] + row['rewritten']
|
|
89
|
+
row['enough'] = row['landed'] >= MIN_LANDED
|
|
90
|
+
if row['enough']:
|
|
91
|
+
row['rate'] = row['did_not_hold'] / row['landed']
|
|
92
|
+
row['ci'] = list(wilson(row['did_not_hold'], row['landed']))
|
|
93
|
+
else:
|
|
94
|
+
row['rate'], row['ci'] = None, None
|
|
95
|
+
row['note'] = 'not enough data (%d/%d)' % (row['landed'], MIN_LANDED)
|
|
96
|
+
out['reviewers'] = sorted(rows.values(), key=lambda r: (not r['enough'], -r['landed'], r['reviewer']))
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load(horizon_days: int = HORIZON_DAYS, repo: str = None) -> dict:
|
|
101
|
+
# Reading must not create a store: a fresh machine (or forge-ui asking) gets an empty report.
|
|
102
|
+
if not (dataset_dir() / 'dataset.sqlite').exists():
|
|
103
|
+
return _empty(horizon_days)
|
|
104
|
+
db = connect()
|
|
105
|
+
try:
|
|
106
|
+
runs = Runs(Store(db))
|
|
107
|
+
finally:
|
|
108
|
+
db.close()
|
|
109
|
+
return report(runs, horizon_days=horizon_days, repo=repo)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _pct(v):
|
|
113
|
+
return '%.0f%%' % (v * 100)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main(args) -> int:
|
|
117
|
+
if args.horizon_days < 1:
|
|
118
|
+
print('forge dataset reviewers: --horizon-days must be at least 1', file=sys.stderr)
|
|
119
|
+
return 2
|
|
120
|
+
rep = load(args.horizon_days, os.path.realpath(args.repo) if args.repo else None)
|
|
121
|
+
if args.json:
|
|
122
|
+
print(json.dumps(rep, indent=2, sort_keys=True))
|
|
123
|
+
return 0
|
|
124
|
+
print('reviewers: PASSed work that did not hold up %d days after integrate' % rep['horizon_days'])
|
|
125
|
+
print(' (reverted, or under %d%% of its added lines kept; advisory only, routing never reads it)'
|
|
126
|
+
% (SURVIVES_AT * 100))
|
|
127
|
+
if not rep['reviewers']:
|
|
128
|
+
print(' no PASSed work with a %d-day outcome yet; `forge dataset outcomes --horizon-days %d` records them'
|
|
129
|
+
% (rep['horizon_days'], rep['horizon_days']))
|
|
130
|
+
else:
|
|
131
|
+
print(' %-24s %6s %8s %9s %s' % ('reviewer', 'landed', 'reverted', 'rewritten', 'did not hold'))
|
|
132
|
+
for r in rep['reviewers']:
|
|
133
|
+
held = r['note'] if not r['enough'] else '%s [%s-%s] (%d/%d)' % (
|
|
134
|
+
_pct(r['rate']), _pct(r['ci'][0]), _pct(r['ci'][1]), r['did_not_hold'], r['landed'])
|
|
135
|
+
print(' %-24s %6d %8d %9d %s' % (r['reviewer'], r['landed'], r['reverted'], r['rewritten'], held))
|
|
136
|
+
u, s = rep['unattributed'], rep['skipped']
|
|
137
|
+
if u['not_passed'] or u['no_review']:
|
|
138
|
+
print(' not counted for any reviewer: %d landed after a FAIL (accepted by the gate or by hand), %d with no review'
|
|
139
|
+
% (u['not_passed'], u['no_review']))
|
|
140
|
+
if s['lost'] or s['no_additions']:
|
|
141
|
+
print(' skipped: %d lost (history rewritten past the integration), %d added no lines' % (s['lost'], s['no_additions']))
|
|
142
|
+
if rep['other_horizons']:
|
|
143
|
+
print(' outcomes also recorded at %s days: pass --horizon-days to see them'
|
|
144
|
+
% ', '.join(str(h) for h in rep['other_horizons']))
|
|
145
|
+
return 0
|
|
@@ -107,6 +107,7 @@ def tasks(runs: Runs, *, repo=None, since=None) -> list:
|
|
|
107
107
|
out.append(dict(
|
|
108
108
|
run=run, task=tid, tier=row.get('declared_difficulty') or row.get('difficulty'),
|
|
109
109
|
ran=first['events'][0]['d'].get('dwarf') if first else None, model=model,
|
|
110
|
+
model_id=resolved.get('model'), effort=resolved.get('effort'),
|
|
110
111
|
first_pass=first['outcome'] if first else None,
|
|
111
112
|
censored=(first['reason'] if first and first['outcome'] is None else (None if first else 'not_run')),
|
|
112
113
|
passed=any(a['outcome'] for a in attempts) or (run, tid) in runs.accepted,
|
|
@@ -149,9 +149,13 @@ _runtime_spec.loader.exec_module(_runtime)
|
|
|
149
149
|
|
|
150
150
|
|
|
151
151
|
def config_path() -> Path:
|
|
152
|
-
# An empty XDG_CONFIG_HOME counts as unset (XDG spec): taken literally it put
|
|
153
|
-
# below the current directory.
|
|
154
|
-
|
|
152
|
+
# An empty or relative XDG_CONFIG_HOME counts as unset (XDG spec): taken literally it put
|
|
153
|
+
# jev.json below the current directory. Same rule as forge_registry.registry_dir().
|
|
154
|
+
value = os.environ.get('XDG_CONFIG_HOME') or ''
|
|
155
|
+
base = Path(value).expanduser() if value else None
|
|
156
|
+
if base is None or not base.is_absolute():
|
|
157
|
+
base = Path.home() / '.config'
|
|
158
|
+
return base / 'forge/jev.json'
|
|
155
159
|
|
|
156
160
|
|
|
157
161
|
def load_config() -> dict:
|