@giamme/forge 0.10.0 → 0.11.1

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 (55) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/SKILL.md +4 -3
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +13 -1
  5. package/docs/commands.md +11 -5
  6. package/docs/dataset.md +13 -0
  7. package/docs/decomposed-runs.md +13 -3
  8. package/docs/forge-ui.md +8 -3
  9. package/docs/memory.md +4 -2
  10. package/docs/reviews.md +73 -2
  11. package/forge +16 -1
  12. package/package.json +1 -1
  13. package/references/dataset.md +86 -4
  14. package/references/decompose.md +24 -6
  15. package/references/harnesses.md +43 -1
  16. package/references/ui.md +4 -1
  17. package/scripts/forge-config.py +5 -2
  18. package/scripts/forge-control.py +4 -0
  19. package/scripts/forge-dispatch.sh +235 -121
  20. package/scripts/forge-issues.py +345 -17
  21. package/scripts/forge-lib-plans.sh +6 -2
  22. package/scripts/forge-lib-review.sh +158 -3
  23. package/scripts/forge-lib-task.sh +132 -14
  24. package/scripts/forge-parallel.sh +96 -11
  25. package/scripts/forge-prompt.py +61 -4
  26. package/scripts/forge-route.py +72 -12
  27. package/scripts/forge-runtime.py +47 -1
  28. package/scripts/forge-ui.py +8 -4
  29. package/scripts/forge_dataset/__init__.py +7 -2
  30. package/scripts/forge_dataset/cli.py +44 -1
  31. package/scripts/forge_dataset/export.py +36 -3
  32. package/scripts/forge_dataset/forecast.py +355 -0
  33. package/scripts/forge_dataset/harvest.py +23 -2
  34. package/scripts/forge_dataset/importer.py +20 -8
  35. package/scripts/forge_dataset/reviewers.py +145 -0
  36. package/scripts/forge_dataset/routing.py +1 -0
  37. package/scripts/forge_jev/__init__.py +7 -3
  38. package/scripts/forge_registry.py +15 -10
  39. package/ui/README.md +5 -4
  40. package/ui/docs/api.md +88 -23
  41. package/ui/forge_ui/control.py +26 -5
  42. package/ui/forge_ui/reader.py +184 -11
  43. package/ui/forge_ui/static/app.js +24 -10
  44. package/ui/forge_ui/static/cloud3d.js +4 -1
  45. package/ui/forge_ui/static/cloudmodel.js +1 -1
  46. package/ui/forge_ui/static/cloudui.js +13 -4
  47. package/ui/forge_ui/static/findingsview.js +65 -14
  48. package/ui/forge_ui/static/graph.js +6 -5
  49. package/ui/forge_ui/static/leaderboard.js +18 -0
  50. package/ui/forge_ui/static/modelstats.js +19 -1
  51. package/ui/forge_ui/static/panel.js +52 -10
  52. package/ui/forge_ui/static/runmodel.js +128 -13
  53. package/ui/forge_ui/static/scene3d.js +32 -15
  54. package/ui/forge_ui/static/settings.js +48 -12
  55. 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
- """Where an extracted archive's run dir lived: its own FORGE_ARCHIVE.json, else a MANIFEST's source.
506
-
507
- The archive's word wins, and it brings the run's dataset.uid along for the case where
508
- the run dir it was taken from no longer has one to read.
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
- return mapper
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
- write(import_tree(work, _archive_mapper(work, None), args, counts), blob_failures)
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
- write(import_tree(work, _archive_mapper(work, source), args, counts))
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 jev.json
153
- # below the current directory.
154
- return Path(os.environ.get('XDG_CONFIG_HOME') or str(Path.home() / '.config')).expanduser() / 'forge/jev.json'
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: