@giamme/forge 0.0.0-stage → 0.8.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.
Files changed (127) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1854 -2
  4. package/SKILL.md +122 -0
  5. package/VERSION +1 -0
  6. package/forge +74 -0
  7. package/package.json +26 -4
  8. package/references/dataset.md +329 -0
  9. package/references/decompose.md +667 -0
  10. package/references/fractal-dashboard.md +21 -0
  11. package/references/fractal.md +307 -0
  12. package/references/harnesses.md +353 -0
  13. package/references/jev.md +865 -0
  14. package/references/memory.md +228 -0
  15. package/references/planning.md +42 -0
  16. package/references/ripwire.md +47 -0
  17. package/references/runtime.md +64 -0
  18. package/references/solo.md +113 -0
  19. package/references/ui.md +112 -0
  20. package/registry.tsv +81 -0
  21. package/scripts/forge-artifact.sh +78 -0
  22. package/scripts/forge-contract.py +430 -0
  23. package/scripts/forge-dataset.py +6 -0
  24. package/scripts/forge-dispatch.sh +665 -0
  25. package/scripts/forge-fail-first.py +195 -0
  26. package/scripts/forge-fractal-dispatch.sh +4 -0
  27. package/scripts/forge-fractal-options.sh +54 -0
  28. package/scripts/forge-fractal.py +6 -0
  29. package/scripts/forge-guard.py +266 -0
  30. package/scripts/forge-install-fractal.sh +4 -0
  31. package/scripts/forge-install-ripwire.sh +52 -0
  32. package/scripts/forge-install.sh +135 -0
  33. package/scripts/forge-jev-options.sh +259 -0
  34. package/scripts/forge-jev.py +6 -0
  35. package/scripts/forge-lib-plans.sh +785 -0
  36. package/scripts/forge-lib-review.sh +494 -0
  37. package/scripts/forge-lib-task.sh +302 -0
  38. package/scripts/forge-lib-verify.sh +30 -0
  39. package/scripts/forge-memory.sh +575 -0
  40. package/scripts/forge-metrics.sh +39 -0
  41. package/scripts/forge-parallel-options.sh +76 -0
  42. package/scripts/forge-parallel.sh +1717 -0
  43. package/scripts/forge-plan-check.py +118 -0
  44. package/scripts/forge-prompt.py +45 -0
  45. package/scripts/forge-ripwire.py +136 -0
  46. package/scripts/forge-route.py +344 -0
  47. package/scripts/forge-runtime.py +533 -0
  48. package/scripts/forge-schedule.py +312 -0
  49. package/scripts/forge-shadow.py +305 -0
  50. package/scripts/forge-solo.sh +347 -0
  51. package/scripts/forge-ui-options.sh +42 -0
  52. package/scripts/forge-ui.py +435 -0
  53. package/scripts/forge-update.sh +71 -0
  54. package/scripts/forge-verify-coverage.py +956 -0
  55. package/scripts/forge-version.py +198 -0
  56. package/scripts/forge_dataset/__init__.py +439 -0
  57. package/scripts/forge_dataset/capture.py +330 -0
  58. package/scripts/forge_dataset/checks.py +109 -0
  59. package/scripts/forge_dataset/cli.py +233 -0
  60. package/scripts/forge_dataset/export.py +1009 -0
  61. package/scripts/forge_dataset/harvest.py +716 -0
  62. package/scripts/forge_dataset/importer.py +412 -0
  63. package/scripts/forge_dataset/outcomes.py +275 -0
  64. package/scripts/forge_dataset/routing.py +278 -0
  65. package/scripts/forge_fractal/__init__.py +83 -0
  66. package/scripts/forge_fractal/bridge.py +46 -0
  67. package/scripts/forge_fractal/cli.py +202 -0
  68. package/scripts/forge_fractal/dashboard.css +138 -0
  69. package/scripts/forge_fractal/dashboard.html +41 -0
  70. package/scripts/forge_fractal/dashboard.js +687 -0
  71. package/scripts/forge_fractal/decomposition.py +262 -0
  72. package/scripts/forge_fractal/execution.py +659 -0
  73. package/scripts/forge_fractal/inspection.py +261 -0
  74. package/scripts/forge_fractal/install.py +158 -0
  75. package/scripts/forge_fractal/invocation.py +94 -0
  76. package/scripts/forge_fractal/pipeline.py +124 -0
  77. package/scripts/forge_fractal/selection.py +179 -0
  78. package/scripts/forge_jev/__init__.py +376 -0
  79. package/scripts/forge_jev/backtest.py +322 -0
  80. package/scripts/forge_jev/cli.py +980 -0
  81. package/scripts/forge_jev/client.py +418 -0
  82. package/scripts/forge_jev/corpus.py +156 -0
  83. package/scripts/forge_jev/coupling.py +244 -0
  84. package/scripts/forge_jev/features.py +24 -0
  85. package/scripts/forge_jev/gates.py +100 -0
  86. package/scripts/forge_jev/memory.py +163 -0
  87. package/scripts/forge_jev/outcome.py +49 -0
  88. package/scripts/forge_jev/predict.py +204 -0
  89. package/scripts/forge_jev/questions.py +661 -0
  90. package/scripts/forge_jev/review.py +73 -0
  91. package/scripts/forge_jev/routing.py +271 -0
  92. package/scripts/forge_jev/subset.py +123 -0
  93. package/scripts/forge_jev/verify.py +453 -0
  94. package/scripts/forge_registry.py +140 -0
  95. package/ui/README.md +287 -0
  96. package/ui/THIRD_PARTY_NOTICES.md +69 -0
  97. package/ui/docs/api.md +888 -0
  98. package/ui/forge_ui/__init__.py +20 -0
  99. package/ui/forge_ui/__main__.py +135 -0
  100. package/ui/forge_ui/diffs.py +454 -0
  101. package/ui/forge_ui/executions.py +436 -0
  102. package/ui/forge_ui/export.py +204 -0
  103. package/ui/forge_ui/findings.py +132 -0
  104. package/ui/forge_ui/fractal.py +583 -0
  105. package/ui/forge_ui/models.py +216 -0
  106. package/ui/forge_ui/reader.py +1336 -0
  107. package/ui/forge_ui/server.py +303 -0
  108. package/ui/forge_ui/static/app.js +924 -0
  109. package/ui/forge_ui/static/cloud3d.js +646 -0
  110. package/ui/forge_ui/static/cloudmodel.js +375 -0
  111. package/ui/forge_ui/static/cloudui.js +394 -0
  112. package/ui/forge_ui/static/diffview.js +216 -0
  113. package/ui/forge_ui/static/favicon.svg +1 -0
  114. package/ui/forge_ui/static/files.js +776 -0
  115. package/ui/forge_ui/static/gantt.js +97 -0
  116. package/ui/forge_ui/static/graph.js +266 -0
  117. package/ui/forge_ui/static/index.html +55 -0
  118. package/ui/forge_ui/static/iso.js +765 -0
  119. package/ui/forge_ui/static/leaderboard.js +315 -0
  120. package/ui/forge_ui/static/map3d.js +45 -0
  121. package/ui/forge_ui/static/modelstats.js +249 -0
  122. package/ui/forge_ui/static/notify.js +179 -0
  123. package/ui/forge_ui/static/panel.js +526 -0
  124. package/ui/forge_ui/static/runmodel.js +651 -0
  125. package/ui/forge_ui/static/scene3d.js +996 -0
  126. package/ui/forge_ui/static/search.js +119 -0
  127. package/ui/forge_ui/static/style.css +618 -0
@@ -0,0 +1,275 @@
1
+ """`forge dataset outcomes`: what happened to merged work after it landed, read from git.
2
+
3
+ Every other outcome in the store is a reviewer's opinion. This one is not: once
4
+ `integrate` fast-forwards a branch, the lines each task added either stay in the code or
5
+ get rewritten or reverted. That is observable later, from the repo's history alone.
6
+
7
+ `integrate` merges the run branch with --no-ff and fast-forwards the user's branch to that
8
+ merge, never squashing, so every attempt commit lands as-is and `git blame` still
9
+ attributes a task's surviving lines to its own commits (base_ref..reviewed_commit).
10
+ For each `integrate_advanced` event this records, per task it brought in, how many of the
11
+ lines the task added are still attributed to the task's commits at a horizon (14 days by
12
+ default) after the integration, and whether any later commit reverted it.
13
+
14
+ Survival describes the artifact that landed (with every retry it took), not the first
15
+ model that was routed to the task: the exporter attaches it to the review of that diff.
16
+
17
+ Read-only by construction: the store is opened read-only (connect() would migrate it),
18
+ and git runs only object-store queries (rev-parse, rev-list, log, diff between commits,
19
+ blame at a commit, cat-file) with GIT_OPTIONAL_LOCKS=0, so nothing touches a working
20
+ tree or an index. Results are written with record(), which refuses a store inside the
21
+ repo. A horizon still in the future, or a query that failed, writes nothing and is tried
22
+ again next time; the rid names the integration, the reviewed commit and the horizon, so
23
+ a repeat harvest adds nothing.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import re
30
+ import sqlite3
31
+ import subprocess
32
+ import time
33
+ from collections import Counter, defaultdict
34
+ from pathlib import Path
35
+
36
+ from . import dataset_dir, record, sha256
37
+
38
+ HORIZON_DAYS = 14
39
+ GIT_TIMEOUT_S = 120
40
+ _REVERTS = re.compile(r'This reverts commit ([0-9a-f]{7,40})')
41
+ _HUNK = re.compile(rb'^([0-9a-f]{40}) \d+ \d+ (\d+)$')
42
+
43
+
44
+ class GitError(Exception):
45
+ pass
46
+
47
+
48
+ class Git:
49
+ """Object-store queries in one repo. A failure raises; it never reads as "nothing found"."""
50
+
51
+ def __init__(self, repo: str):
52
+ self.repo = repo
53
+ self.env = dict(os.environ, GIT_OPTIONAL_LOCKS='0', GIT_TERMINAL_PROMPT='0', LC_ALL='C')
54
+
55
+ def out(self, *args, ok=(0,)) -> tuple[int, bytes]:
56
+ try:
57
+ result = subprocess.run(['git', '-C', self.repo, *args], stdout=subprocess.PIPE,
58
+ stderr=subprocess.DEVNULL, timeout=GIT_TIMEOUT_S, env=self.env)
59
+ except (OSError, subprocess.SubprocessError) as error:
60
+ raise GitError('%s: %s' % (args[0], error)) from error
61
+ if result.returncode not in ok:
62
+ raise GitError('%s exited %d' % (args[0], result.returncode))
63
+ return result.returncode, result.stdout
64
+
65
+ def text(self, *args) -> str:
66
+ return self.out(*args)[1].decode('utf-8', 'replace')
67
+
68
+ def exists(self, obj: str) -> bool:
69
+ return self.out('cat-file', '-e', obj, ok=(0, 1, 128))[0] == 0
70
+
71
+ def blamed(self, rev: str, path: str, commits: set) -> int:
72
+ """Lines of `path` at `rev` whose origin is one of `commits`, streamed, never capped."""
73
+ try:
74
+ proc = subprocess.Popen(['git', '-C', self.repo, 'blame', '--incremental', rev, '--', path],
75
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=self.env)
76
+ except OSError as error:
77
+ raise GitError('blame: %s' % error) from error
78
+ count = 0
79
+ try:
80
+ for line in proc.stdout:
81
+ match = _HUNK.match(line.rstrip(b'\n'))
82
+ if match and match.group(1).decode() in commits:
83
+ count += int(match.group(2))
84
+ if proc.wait(timeout=GIT_TIMEOUT_S):
85
+ raise GitError('blame %s exited %d' % (path, proc.returncode))
86
+ except subprocess.TimeoutExpired as error:
87
+ proc.kill()
88
+ raise GitError('blame %s timed out' % path) from error
89
+ finally:
90
+ proc.stdout.close()
91
+ return count
92
+
93
+
94
+ def read_store():
95
+ """Integrations, merges and attempts, from a read-only connection; None without a store."""
96
+ path = dataset_dir() / 'dataset.sqlite'
97
+ if not path.exists():
98
+ return None
99
+ db = sqlite3.connect('file:%s?mode=ro' % path, uri=True, timeout=5)
100
+ try:
101
+ rows = db.execute("SELECT kind, run, task, repo, ts, data FROM events WHERE kind IN ('event', 'attempt')"
102
+ ).fetchall()
103
+ repos = dict(db.execute('SELECT repo, path FROM repos'))
104
+ have = {rid for (rid,) in db.execute("SELECT rid FROM events WHERE kind = 'outcome'")}
105
+ finally:
106
+ db.close()
107
+ integrations, merges, bases = [], {}, {}
108
+ for kind, run, task, repo, ts, data in rows:
109
+ d = json.loads(data)
110
+ if kind == 'attempt':
111
+ if d.get('reviewed_commit') and d.get('base_ref'):
112
+ bases[d['reviewed_commit']] = d['base_ref']
113
+ elif d.get('name') == 'integrate_advanced' and d.get('candidate') and d.get('parent'):
114
+ integrations.append(dict(run=run, repo=repo, ts=ts, **{k: d.get(k) for k in
115
+ ('branch', 'parent', 'candidate', 'target')}))
116
+ elif d.get('name') == 'merge' and d.get('status') == 'MERGED' and task:
117
+ commit = d.get('commit') or d.get('reviewed_commit')
118
+ if commit:
119
+ # The last MERGED event for a commit wins; split/combine copies keep their run.
120
+ merges[(repo, commit)] = dict(run=run, task=task, commit=commit, integration=d.get('integration'))
121
+ return dict(integrations=sorted(integrations, key=lambda i: i['ts']), merges=merges, bases=bases,
122
+ repos=repos, have=have)
123
+
124
+
125
+ def outcome_rid(candidate: str, reviewed: str, horizon_days: int) -> str:
126
+ return 'outcome:survival:' + sha256(('%s:%s:%d' % (candidate, reviewed, horizon_days)).encode())[:24]
127
+
128
+
129
+ def horizon_rev(git: Git, branch: str, candidate: str, cutoff: float, now: float):
130
+ """('ok', rev, ts) | ('lost', None, None) | ('pending', None, None).
131
+
132
+ The branch is resolved once to an immutable tip. The candidate must sit on its
133
+ first-parent chain (a rebase, squash or reset loses it: unobservable, not failure).
134
+ The horizon is found by position along that chain, so out-of-order commit dates
135
+ cannot jump past a commit that came later.
136
+ """
137
+ if not branch or git.out('rev-parse', '--verify', '-q', branch + '^{commit}', ok=(0, 1))[0]:
138
+ return 'lost', None, None
139
+ tip = git.text('rev-parse', branch + '^{commit}').strip()
140
+ chain = [] # first-parent commits after the candidate, newest first
141
+ for line in git.text('log', '--first-parent', '--format=%H %ct %P', '%s..%s' % (candidate, tip)).splitlines():
142
+ sha, ct, *parents = line.split()
143
+ chain.append((sha, int(ct), parents))
144
+ if tip != candidate and (not chain or chain[-1][2][:1] != [candidate]):
145
+ return 'lost', None, None # the walk never passed through the candidate
146
+ chain = [(sha, ct) for sha, ct, _ in reversed(chain)]
147
+ candidate_ct = int(git.text('log', '-1', '--format=%ct', candidate).strip())
148
+ rev, rev_ts = candidate, candidate_ct
149
+ for sha, ct in chain:
150
+ if ct > cutoff:
151
+ return 'ok', rev, rev_ts
152
+ rev, rev_ts = sha, ct
153
+ if now < cutoff:
154
+ return 'pending', None, None # more commits may still land before the horizon
155
+ return 'ok', rev, rev_ts
156
+
157
+
158
+ def survival(git: Git, base: str, reviewed: str, rev: str) -> dict:
159
+ commits = set(git.text('rev-list', '%s..%s' % (base, reviewed)).split())
160
+ files, added_total, surviving_total = {}, 0, 0
161
+ for entry in git.text('diff', '--numstat', '-z', '--no-renames', base, reviewed).split('\0'):
162
+ cells = entry.lstrip('\n').split('\t', 2)
163
+ if len(cells) != 3 or cells[0] == '-':
164
+ continue # binary: no lines to follow
165
+ added, path = int(cells[0]), cells[2]
166
+ if added == 0:
167
+ continue
168
+ kept = git.blamed(rev, path, commits) if git.exists('%s:%s' % (rev, path)) else 0
169
+ kept = min(kept, added)
170
+ files[path] = [added, kept]
171
+ added_total += added
172
+ surviving_total += kept
173
+ return dict(commits=sorted(commits), files=files, added=added_total, surviving=surviving_total)
174
+
175
+
176
+ def reverts(git: Git, candidate: str, rev: str, shas: set) -> list:
177
+ if rev == candidate:
178
+ return []
179
+ out = []
180
+ for block in git.text('log', '--format=%H%x00%B%x01', '%s..%s' % (candidate, rev)).split('\x01'):
181
+ sha, _, body = block.strip('\n').partition('\x00')
182
+ if any(any(s.startswith(m) for s in shas) for m in _REVERTS.findall(body)):
183
+ out.append(sha)
184
+ return out
185
+
186
+
187
+ def harvest(*, horizon_days: int = HORIZON_DAYS, repo: str | None = None, include_temp: bool = False,
188
+ now: float | None = None) -> tuple[list, Counter]:
189
+ from .importer import is_temp
190
+ state = read_store()
191
+ counts = Counter()
192
+ if state is None:
193
+ return [], counts
194
+ now = time.time() if now is None else now
195
+ wanted = str(Path(repo).resolve()) if repo else None
196
+ by_repo = defaultdict(list)
197
+ for integration in state['integrations']:
198
+ path = state['repos'].get(integration['repo'])
199
+ if not path or (wanted and str(Path(path).resolve()) != wanted):
200
+ continue
201
+ if is_temp(path) and not include_temp:
202
+ counts['skipped_temp'] += 1
203
+ continue
204
+ if not Path(path).is_dir():
205
+ counts['repo_missing'] += 1
206
+ continue
207
+ by_repo[path].append(integration)
208
+ out = []
209
+ for path, integrations in by_repo.items():
210
+ git = Git(path)
211
+ for integration in integrations:
212
+ candidate, parent = integration['candidate'], integration['parent']
213
+ try:
214
+ if not git.exists(candidate + '^{commit}'):
215
+ counts['candidate_missing'] += 1
216
+ continue
217
+ landed = set(git.text('rev-list', candidate, '^' + parent).split())
218
+ tasks = [m for (r, c), m in state['merges'].items() if r == integration['repo'] and c in landed]
219
+ todo = [m for m in tasks if outcome_rid(candidate, m['commit'], horizon_days) not in state['have']]
220
+ counts['already'] += len(tasks) - len(todo)
221
+ if not todo:
222
+ continue
223
+ cutoff = integration['ts'] + horizon_days * 86400
224
+ status, rev, rev_ts = horizon_rev(git, integration['branch'], candidate, cutoff, now)
225
+ except GitError:
226
+ counts['git_error'] += 1
227
+ continue
228
+ if status == 'pending':
229
+ counts['pending'] += len(todo)
230
+ continue
231
+ for merge in todo:
232
+ base = state['bases'].get(merge['commit'])
233
+ if not base:
234
+ counts['no_base'] += 1
235
+ continue
236
+ data = dict(name='survival', candidate=candidate, reviewed=merge['commit'], base_ref=base,
237
+ branch=integration['branch'], horizon_days=horizon_days, integrated_ts=integration['ts'],
238
+ horizon_rev=rev, horizon_ts=rev_ts, integrate_run=integration['run'])
239
+ try:
240
+ if status == 'ok':
241
+ found = survival(git, base, merge['commit'], rev)
242
+ task_shas = set(found.pop('commits')) | {s for s in (merge.get('integration'), candidate) if s}
243
+ data.update(found, reverted_by=reverts(git, candidate, rev, task_shas),
244
+ status='ok' if found['added'] else 'no_additions')
245
+ else:
246
+ data.update(status='lost')
247
+ except GitError:
248
+ counts['git_error'] += 1
249
+ continue
250
+ counts[data['status']] += 1
251
+ out.append((path, dict(rid=outcome_rid(candidate, merge['commit'], horizon_days), kind='outcome',
252
+ src='harvest', ts=now, run=merge['run'], task=merge['task'],
253
+ mode='parallel', data=data)))
254
+ return out, counts
255
+
256
+
257
+ def main(args) -> int:
258
+ events, counts = harvest(horizon_days=args.horizon_days, repo=args.repo, include_temp=args.include_temp)
259
+ written = 0
260
+ if not args.dry_run:
261
+ by_repo = defaultdict(list)
262
+ for path, event in events:
263
+ by_repo[path].append(event)
264
+ for path, batch in by_repo.items():
265
+ written += record(batch, repo=path, where='outcomes')
266
+ label = 'would record' if args.dry_run else 'recorded'
267
+ print('%s %d survival outcome(s) at %d days' % (label, len(events) if args.dry_run else written,
268
+ args.horizon_days))
269
+ for key in ('ok', 'no_additions', 'lost', 'pending', 'already', 'no_base', 'candidate_missing',
270
+ 'repo_missing', 'skipped_temp', 'git_error'):
271
+ if counts.get(key):
272
+ print(' %-17s %d' % (key, counts[key]))
273
+ if not args.dry_run and written < len(events):
274
+ print(' %d not written; see %s' % (len(events) - written, dataset_dir() / 'errors.log'))
275
+ return 0
@@ -0,0 +1,278 @@
1
+ """`forge dataset routing`: how each model does per tier, and what the randomized picks say.
2
+
3
+ Two tables, kept apart because they answer different questions:
4
+
5
+ - **Observational**: every task's first attempt, by declared tier and the model and effort
6
+ that ran it (resolved, so `sol` and `gpt-6.1-sol:medium:codex` are one row). First-pass rate
7
+ (Wilson 95%), the share that passed review at some attempt (or that a human accepted),
8
+ unfinished tasks, attempts per task and the implementer's runtime. Routing chose who ran what, so a model sent the hard tasks looks
9
+ worse here than it is. Fractal runs are left out: their dispatches have their own identities
10
+ and their runtime would be missing.
11
+ - **Randomized**: the `plan --explore` draws (forge-route.py), one row per (tier, default,
12
+ alternative) pair, since round-robin pools make the pair differ between rows and a spec has
13
+ no chance at all outside its own pairs. Each task counts in the arm it was *assigned*
14
+ (intent to treat), whatever the approval gate or a retry did afterwards; tasks with no
15
+ outcome are attrition, reported per arm. First-pass per arm is a Hajek (self-normalized
16
+ inverse-propensity) mean, the difference has a run-cluster bootstrap interval, and Kish's
17
+ effective n and the number of runs say how much the interval can be trusted.
18
+
19
+ The decision rule is fixed in advance and only advises: a pair is judged at the first of 30,
20
+ 60 or 120 alternative assignments it reaches, on exactly the assignments up to that point.
21
+ The alternative is the stronger model for a low-tier pair (switch if the interval is above
22
+ 0) and the cheaper one for medium and high (non-inferior if the interval's lower end is above
23
+ -0.10 and the alternative's runtime is lower). Nothing here changes a plan.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import math
29
+ import os
30
+ import random
31
+ import statistics
32
+ import time
33
+ from collections import defaultdict
34
+
35
+ from . import connect
36
+ from .export import Runs, Store, _attempts
37
+
38
+ WINDOWS = (30, 60, 120)
39
+ NONINFERIORITY = -0.10
40
+ BOOTSTRAP = 2000
41
+ MIN_ALTERNATIVES, MIN_RUNS = 10, 3
42
+
43
+
44
+ def wilson(k: int, n: int, z: float = 1.96):
45
+ if not n:
46
+ return None, None
47
+ p = k / n
48
+ centre = (p + z * z / (2 * n)) / (1 + z * z / n)
49
+ half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
50
+ return max(0.0, centre - half), min(1.0, centre + half)
51
+
52
+
53
+ def decision_before(decisions, started):
54
+ """The routing decision attempt 1 ran under: the latest recorded before it started.
55
+
56
+ A re-plan after attempt 1 began (or one with a new draw) must not relabel what that
57
+ attempt was assigned. Decisions repeat across plan revisions, so they are deduplicated.
58
+ """
59
+ unique = {}
60
+ for d in decisions or ():
61
+ unique[d.get('decision') or json.dumps(d, sort_keys=True)] = d
62
+ ordered = sorted(unique.values(), key=lambda d: d.get('at') or 0)
63
+ if not ordered:
64
+ return None
65
+ if started is None:
66
+ return ordered[-1]
67
+ before = [d for d in ordered if (d.get('at') or 0) <= started + 1]
68
+ return before[-1] if before else None
69
+
70
+
71
+ def _started(runs: Runs, run, task, first) -> float | None:
72
+ for e in runs.dispatches.get((run, task, 'dwarf'), []):
73
+ if e['attempt'] == first['n'] and e['d'].get('wall_started'):
74
+ return float(e['d']['wall_started'])
75
+ return first['events'][0]['ts'] if first else None
76
+
77
+
78
+ def _runtime(runs: Runs, run, task) -> float | None:
79
+ durations = [e['d'].get('duration_s') for e in runs.dispatches.get((run, task, 'dwarf'), [])]
80
+ durations = [x for x in durations if isinstance(x, (int, float))]
81
+ return sum(durations) if durations else None
82
+
83
+
84
+ def tasks(runs: Runs, *, repo=None, since=None) -> list:
85
+ """One record per (run, task) that a plan held, with its routing decision and outcome."""
86
+ out = []
87
+ for run, revisions in runs.plans.items():
88
+ if any((p['d'].get('fractal_selection') or {}).get('enabled') for p in revisions):
89
+ continue
90
+ rows, decisions = {}, defaultdict(list)
91
+ for plan in revisions:
92
+ for tid, row in (plan['d'].get('tasks') or {}).items():
93
+ rows[tid] = row
94
+ decisions[tid].extend(row.get('route') or [])
95
+ for tid, row in rows.items():
96
+ events = runs.attempts.get((run, tid), [])
97
+ attempts = _attempts(runs, run, tid, events)
98
+ first = attempts[0] if attempts else None
99
+ if repo and not (events and runs.store.repo_paths.get(events[0]['repo']) == repo):
100
+ continue
101
+ if since and not (events and events[0]['ts'] >= since):
102
+ continue
103
+ started = _started(runs, run, tid, first) if first else None
104
+ decision = decision_before(decisions.get(tid), started)
105
+ resolved = runs.resolved(run, tid, 'dwarf', first['n']) if first else {}
106
+ model = ':'.join(x for x in (resolved.get('model'), resolved.get('effort')) if x) or None
107
+ out.append(dict(
108
+ run=run, task=tid, tier=row.get('declared_difficulty') or row.get('difficulty'),
109
+ ran=first['events'][0]['d'].get('dwarf') if first else None, model=model,
110
+ first_pass=first['outcome'] if first else None,
111
+ censored=(first['reason'] if first and first['outcome'] is None else (None if first else 'not_run')),
112
+ passed=any(a['outcome'] for a in attempts) or (run, tid) in runs.accepted,
113
+ attempts=len(attempts), runtime_s=_runtime(runs, run, tid), decision=decision))
114
+ return out
115
+
116
+
117
+ def observational(records) -> list:
118
+ cells = defaultdict(list)
119
+ for r in records:
120
+ if (r['model'] or r['ran']) and r['first_pass'] is not None:
121
+ cells[(r['tier'], r['model'] or r['ran'])].append(r)
122
+ rows = []
123
+ for (tier, spec), group in sorted(cells.items(), key=lambda kv: (str(kv[0][0]), str(kv[0][1]))):
124
+ k = sum(1 for r in group if r['first_pass'])
125
+ low, high = wilson(k, len(group))
126
+ runtimes = [r['runtime_s'] for r in group if r['runtime_s'] is not None]
127
+ rows.append(dict(tier=tier, spec=spec, tasks=len(group), first_pass=k / len(group), ci=[low, high],
128
+ passed=sum(1 for r in group if r['passed']) / len(group),
129
+ unfinished=sum(1 for r in group if not r['passed']),
130
+ attempts=statistics.mean(r['attempts'] for r in group),
131
+ runtime_min=statistics.median(runtimes) / 60 if runtimes else None))
132
+ return rows
133
+
134
+
135
+ def _hajek(items):
136
+ """(weighted first-pass, Kish effective n) over items with an outcome."""
137
+ known = [(1 / i['propensity'], 1.0 if i['first_pass'] else 0.0) for i in items if i['first_pass'] is not None]
138
+ total = sum(w for w, _ in known)
139
+ if not total:
140
+ return None, 0.0
141
+ return sum(w * y for w, y in known) / total, total * total / sum(w * w for w, _ in known)
142
+
143
+
144
+ def _difference(items):
145
+ default = [i for i in items if i['arm'] == 'default']
146
+ alternative = [i for i in items if i['arm'] == 'alternative']
147
+ a, _ = _hajek(alternative)
148
+ d, _ = _hajek(default)
149
+ return None if a is None or d is None else a - d
150
+
151
+
152
+ def _bootstrap(items, rng):
153
+ by_run = defaultdict(list)
154
+ for i in items:
155
+ by_run[i['run']].append(i)
156
+ runs = sorted(by_run, key=str)
157
+ diffs = []
158
+ for _ in range(BOOTSTRAP):
159
+ sample = [x for run in (rng.choice(runs) for _ in runs) for x in by_run[run]]
160
+ diff = _difference(sample)
161
+ if diff is not None:
162
+ diffs.append(diff)
163
+ if len(diffs) < BOOTSTRAP // 2:
164
+ return None
165
+ diffs.sort()
166
+ return [diffs[int(0.025 * len(diffs))], diffs[int(0.975 * len(diffs)) - 1]]
167
+
168
+
169
+ def randomized(records, seed: int = 7) -> list:
170
+ pairs = defaultdict(list)
171
+ old_draws = 0
172
+ for r in records:
173
+ d = r['decision']
174
+ candidates = (d or {}).get('candidates') or []
175
+ if not d:
176
+ continue
177
+ if len(candidates) != 2 or not d.get('propensity'):
178
+ old_draws += 1 # an old all-specs draw has no fixed pair to compare in
179
+ continue
180
+ alternative = next(c for c in candidates if c != d.get('default'))
181
+ pairs[(d.get('tier'), d.get('default'), alternative)].append(dict(
182
+ r, arm='alternative' if d.get('chosen') == alternative else 'default',
183
+ propensity=float(d['propensity']), at=d.get('at') or 0,
184
+ followed=r['ran'] == d.get('chosen') if r['ran'] else None))
185
+ rows = []
186
+ rng = random.Random(seed)
187
+ if old_draws:
188
+ rows.append(dict(note='%d older exploratory draw(s) picked among every spec of their plan; they have no '
189
+ 'fixed pair to compare in and are left out' % old_draws))
190
+ for (tier, default, alternative), items in sorted(pairs.items(), key=lambda kv: tuple(map(str, kv[0]))):
191
+ items.sort(key=lambda i: i['at'])
192
+ n_alt = sum(1 for i in items if i['arm'] == 'alternative')
193
+ window = max((w for w in WINDOWS if n_alt >= w), default=None)
194
+ judged = items
195
+ if window:
196
+ cut = [i for i in items if i['arm'] == 'alternative'][window - 1]['at']
197
+ judged = [i for i in items if i['at'] <= cut]
198
+ arms = {}
199
+ for arm in ('default', 'alternative'):
200
+ group = [i for i in judged if i['arm'] == arm]
201
+ rate, n_eff = _hajek(group)
202
+ runtimes = [i['runtime_s'] for i in group if i['runtime_s'] is not None]
203
+ arms[arm] = dict(spec=default if arm == 'default' else alternative, assigned=len(group),
204
+ first_pass=rate, n_eff=round(n_eff, 1),
205
+ not_followed=sum(1 for i in group if i['followed'] is False),
206
+ no_outcome=sum(1 for i in group if i['first_pass'] is None),
207
+ runtime_min=statistics.median(runtimes) / 60 if runtimes else None)
208
+ run_count = len({i['run'] for i in judged})
209
+ diff = _difference(judged)
210
+ ci = (_bootstrap(judged, rng) if arms['alternative']['assigned'] >= MIN_ALTERNATIVES
211
+ and run_count >= MIN_RUNS else None)
212
+ stronger_alternative = tier == 'low'
213
+ if not window:
214
+ verdict = 'not enough randomized data (%d/%d alternative assignments)' % (n_alt, WINDOWS[0])
215
+ elif ci is None:
216
+ verdict = 'window %d reached, but too few runs for an interval' % window
217
+ elif stronger_alternative:
218
+ verdict = ('switch to %s: it passes more (interval above 0)' % alternative if ci[0] > 0
219
+ else 'keep %s: %s is not shown to pass more' % (default, alternative))
220
+ else:
221
+ cheaper_faster = (arms['alternative']['runtime_min'] or math.inf) < (arms['default']['runtime_min'] or 0)
222
+ verdict = ('%s is non-inferior and faster: worth making the default' % alternative
223
+ if ci[0] > NONINFERIORITY and cheaper_faster
224
+ else 'keep %s: %s is not shown to be non-inferior' % (default, alternative))
225
+ rows.append(dict(tier=tier, default=default, alternative=alternative, alternative_is='stronger'
226
+ if stronger_alternative else 'cheaper', window=window, runs=run_count, arms=arms,
227
+ difference=diff, ci=ci, verdict=verdict))
228
+ return rows
229
+
230
+
231
+ def _pct(x):
232
+ return ' -' if x is None else '%3.0f%%' % (100 * x)
233
+
234
+
235
+ def main(args) -> int:
236
+ db = connect()
237
+ try:
238
+ runs = Runs(Store(db))
239
+ finally:
240
+ db.close()
241
+ repo = os.path.realpath(args.repo) if args.repo else None
242
+ since = time.time() - args.since * 86400 if args.since else None
243
+ records = tasks(runs, repo=repo, since=since)
244
+ report = dict(observational=observational(records), randomized=randomized(records))
245
+ if args.json:
246
+ print(json.dumps(report, indent=2, sort_keys=True))
247
+ return 0
248
+ print('observational: first attempts by declared tier and the model that ran them')
249
+ print(' (routing chose who ran what, so a model given the hard tasks looks worse than it is)')
250
+ print(' %-7s %-26s %5s %6s %-13s %6s %10s %8s %8s' % ('tier', 'model:effort', 'tasks', 'first', '95%',
251
+ 'passed', 'unfinished', 'attempts', 'dwarf min'))
252
+ for r in report['observational']:
253
+ low, high = r['ci']
254
+ print(' %-7s %-26s %5d %6s [%s-%s] %6s %10d %8.1f %8s' % (
255
+ r['tier'], r['spec'], r['tasks'], _pct(r['first_pass']), _pct(low).strip(), _pct(high).strip(),
256
+ _pct(r['passed']), r['unfinished'], r['attempts'],
257
+ '-' if r['runtime_min'] is None else '%.1f' % r['runtime_min']))
258
+ print()
259
+ print('randomized (plan --explore): each pair of models, tasks counted in the arm they were assigned')
260
+ pairs = [r for r in report['randomized'] if 'note' not in r]
261
+ for r in report['randomized']:
262
+ if 'note' in r:
263
+ print(' (%s)' % r['note'])
264
+ if not pairs:
265
+ print(' no exploratory decisions in pairs yet; `plan` explores at 0.2 by default')
266
+ for r in pairs:
267
+ d, a = r['arms']['default'], r['arms']['alternative']
268
+ print(' %s: %s (default) vs %s (%s), %d run(s)%s' % (
269
+ r['tier'], r['default'], r['alternative'], r['alternative_is'], r['runs'],
270
+ ', judged at window %d' % r['window'] if r['window'] else ''))
271
+ for name, arm in (('default', d), ('alternative', a)):
272
+ print(' %-11s %-24s assigned %3d first-pass %s n_eff %5.1f not followed %d no outcome %d dwarf min %s' % (
273
+ name, arm['spec'], arm['assigned'], _pct(arm['first_pass']), arm['n_eff'], arm['not_followed'],
274
+ arm['no_outcome'], '-' if arm['runtime_min'] is None else '%.1f' % arm['runtime_min']))
275
+ if r['difference'] is not None:
276
+ print(' difference %+.2f%s' % (r['difference'], '' if not r['ci'] else ' 95%% [%+.2f, %+.2f]' % tuple(r['ci'])))
277
+ print(' -> %s' % r['verdict'])
278
+ return 0
@@ -0,0 +1,83 @@
1
+ """Opt-in Fractal adapter. Importing this package never imports Fractal."""
2
+ from __future__ import annotations
3
+
4
+ import contextlib
5
+ import fcntl
6
+ import importlib.util
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import re
11
+ import subprocess
12
+
13
+ SCRIPTS = Path(__file__).resolve().parents[1]
14
+ REVISION = '18793200c0d7e8cdb2db369ea3abe5647a1e15e4'
15
+ VERSION = '1.2.0'
16
+ ARCHIVE_SHA256 = '3f7714ee0baec05af8d856db2b00d63398026fae2fcf50edbee542671126072c'
17
+ DEFAULTS = dict(depth=2, children=3, nodes=12, iterations=6, concurrency=3, deadline=2700)
18
+
19
+
20
+ def state_root() -> Path:
21
+ return Path(os.environ.get('XDG_STATE_HOME', str(Path.home() / '.local/state'))).expanduser().resolve() / 'forge/fractal'
22
+
23
+
24
+ def runtime_python() -> Path:
25
+ return state_root() / 'runtime/bin/python'
26
+
27
+
28
+ def write_json(path: Path, data: object) -> None:
29
+ path.parent.mkdir(parents=True, exist_ok=True)
30
+ _runtime.write_json(path, data)
31
+
32
+
33
+ _runtime_spec = importlib.util.spec_from_file_location('forge_runtime', SCRIPTS / 'forge-runtime.py')
34
+ _runtime = importlib.util.module_from_spec(_runtime_spec)
35
+ _runtime_spec.loader.exec_module(_runtime)
36
+
37
+
38
+ def read_json(path: Path) -> dict:
39
+ return json.loads(path.read_text())
40
+
41
+
42
+ def identifier(value: str) -> str:
43
+ if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_-]{0,100}', value):
44
+ raise ValueError('Invalid managed identifier: ' + repr(value))
45
+ return value
46
+
47
+
48
+ def managed_run(value: str) -> Path:
49
+ root = state_root() / 'runs'
50
+ path = root / identifier(value)
51
+ if path.is_symlink() or path.resolve().parent != root.resolve():
52
+ raise ValueError('Run must be a direct managed directory')
53
+ if not path.is_dir() or (path / 'run.json').is_symlink():
54
+ raise ValueError('Unknown managed run: ' + value)
55
+ return path
56
+
57
+
58
+ def safe_file(root: Path, relative: str) -> Path:
59
+ path = root / relative
60
+ if path.resolve() != path.absolute() or not path.resolve().is_relative_to(root.resolve()):
61
+ raise ValueError('Artifact path escapes managed data or contains a symlink')
62
+ return path
63
+
64
+
65
+ @contextlib.contextmanager
66
+ def lock(path: Path, blocking: bool = True):
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ with path.open('a') as handle:
69
+ fcntl.flock(handle, fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
70
+ yield
71
+
72
+
73
+ def command(argv: list, *, cwd: Path | None = None, timeout: float = 120, input: bytes | None = None) -> bytes:
74
+ result = subprocess.run(list(map(str, argv)), cwd=cwd, input=input,
75
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
76
+ if result.returncode:
77
+ raise RuntimeError(result.stderr.decode(errors='replace') or result.stdout.decode(errors='replace'))
78
+ return result.stdout
79
+
80
+
81
+ def artifact(verb: str, *args: object) -> str:
82
+ return command(['/bin/bash', '-c', 'source "$1"; shift; "$@"', 'forge',
83
+ SCRIPTS / 'forge-artifact.sh', verb, *args]).decode().strip()