@giamme/forge 0.9.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 +56 -0
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/artifacts.md +28 -2
- package/docs/commands.md +55 -0
- package/docs/dataset.md +22 -0
- package/docs/decomposed-runs.md +54 -2
- package/docs/forge-ui.md +48 -7
- package/docs/memory.md +4 -2
- package/docs/reviews.md +87 -2
- package/forge +31 -1
- package/package.json +1 -1
- package/references/dataset.md +103 -4
- package/references/decompose.md +41 -5
- package/references/harnesses.md +43 -1
- package/references/ui.md +31 -6
- package/scripts/forge-config.py +350 -0
- package/scripts/forge-control.py +649 -0
- package/scripts/forge-dispatch.sh +235 -121
- package/scripts/forge-issues.py +1134 -0
- package/scripts/forge-lib-plans.sh +6 -2
- package/scripts/forge-lib-review.sh +158 -3
- package/scripts/forge-lib-task.sh +358 -41
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +99 -13
- package/scripts/forge-prompt.py +61 -4
- package/scripts/forge-route.py +245 -28
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +19 -1
- package/scripts/forge-runtime.py +47 -1
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge_dataset/__init__.py +84 -8
- package/scripts/forge_dataset/capture.py +5 -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 +45 -2
- package/scripts/forge_dataset/importer.py +431 -17
- 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 +183 -26
- package/ui/README.md +41 -7
- package/ui/docs/api.md +259 -21
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +587 -0
- package/ui/forge_ui/export.py +17 -3
- package/ui/forge_ui/reader.py +210 -12
- package/ui/forge_ui/server.py +125 -4
- package/ui/forge_ui/static/app.js +104 -18
- 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/controls.js +619 -0
- package/ui/forge_ui/static/findingsview.js +438 -0
- package/ui/forge_ui/static/graph.js +6 -5
- package/ui/forge_ui/static/index.html +7 -1
- 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 +76 -14
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +128 -13
- package/ui/forge_ui/static/scene3d.js +32 -15
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/style.css +184 -5
- package/ui/forge_ui/static/uikit.js +161 -0
|
@@ -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.
|
|
@@ -345,14 +345,56 @@ def run_uid(run_dir: Path, create: bool) -> str:
|
|
|
345
345
|
return legacy_uid(Path(run_dir))
|
|
346
346
|
|
|
347
347
|
|
|
348
|
+
# --- interventions: a human's (or the policy's) change of course ------------------------
|
|
349
|
+
# Escalations and control changes are point events, recorded at the moment they happen, but
|
|
350
|
+
# the run dir also keeps them (tasks/<id>/escalations.tsv, control.log). Their rid hangs on
|
|
351
|
+
# what they say rather than on when they were captured, so live capture and an import of the
|
|
352
|
+
# run dir agree on it and a missed live event is filled in later without a duplicate.
|
|
353
|
+
|
|
354
|
+
INTERVENTIONS = {
|
|
355
|
+
'escalation': ('after_attempt', 'from', 'to', 'source'),
|
|
356
|
+
'control': ('at', 'actor', 'action', 'key', 'value', 'previous'),
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def intervention_data(name: str, values: dict) -> dict:
|
|
361
|
+
"""The fields that identify an intervention, as the strings `--set k=v` delivers."""
|
|
362
|
+
return {field: '' if values.get(field) is None else str(values[field]) for field in INTERVENTIONS[name]}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def intervention_rid(name: str, uid: str | None, task: str | None, values: dict) -> str:
|
|
366
|
+
canonical = json.dumps([uid, task, intervention_data(name, values)], sort_keys=True)
|
|
367
|
+
return 'event:%s:%s' % (name, sha256(canonical.encode())[:24])
|
|
368
|
+
|
|
369
|
+
|
|
348
370
|
# --- dispatch: one model call ----------------------------------------------------------
|
|
349
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
|
+
|
|
350
391
|
def dispatch_event(attempt: Path, root: Path, role: str, harness: str, rc, prov: dict, src: str = 'live') -> dict:
|
|
351
392
|
metrics = read_json(attempt / 'metrics.json') or {}
|
|
352
393
|
resolved = kv(attempt / (role + '.resolved'))
|
|
353
394
|
infra = kv(attempt / (role + '.infra'))
|
|
354
395
|
harness = harness or metrics.get('harness') or resolved.get('harness')
|
|
355
396
|
message = text(attempt / (role + '.last'))
|
|
397
|
+
session_id, session_continue = session_record(attempt, role, metrics)
|
|
356
398
|
final_source = 'structured' if harness in STRUCTURED else 'log_copy'
|
|
357
399
|
delivery = resolved.get('delivery') or 'custom'
|
|
358
400
|
orphans = text(attempt / (role + '.orphans'))
|
|
@@ -377,6 +419,7 @@ def dispatch_event(attempt: Path, root: Path, role: str, harness: str, rc, prov:
|
|
|
377
419
|
prompt_bytes=metrics.get('prompt_bytes'), final_source=final_source,
|
|
378
420
|
verdict=verdict, findings=found, learnings=learnings(message),
|
|
379
421
|
findings_parse=review_completeness(verdict, found, parsed) if role == 'qa' else None,
|
|
422
|
+
session_id=session_id, session_continue=session_continue,
|
|
380
423
|
root=str(root), scope=prov.get('scope'), trigger=prov.get('trigger'),
|
|
381
424
|
**({'shadow': shadow} if shadow else {}),
|
|
382
425
|
)
|
|
@@ -650,7 +693,7 @@ def plan_event(plan: Path, prov: dict, src: str = 'live', inventory: bool = True
|
|
|
650
693
|
fractal_selection=read_json(plan / 'fractal-selection.json'),
|
|
651
694
|
jev_coupling=read_json(plan / 'jev-coupling.json'),
|
|
652
695
|
options={name: first_line(plan / name) for name in ('qa_threshold', 'verify_retries', 'timeout', 'final_qa',
|
|
653
|
-
'shadow_qa', 'shadow_rate', 'explore')
|
|
696
|
+
'shadow_qa', 'shadow_rate', 'explore', 'reviewer_session')
|
|
654
697
|
if (plan / name).exists()},
|
|
655
698
|
flags=sorted(name for name in ('no_memory', 'no_ripwire', 'yolo_dwarf', 'yolo_qa') if (plan / name).exists()),
|
|
656
699
|
blobs=blobs(goal=put_file(plan / 'goal.txt'), request=put_file(plan / 'request.md', 'prompt'),
|