@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.
- package/CHANGELOG.md +181 -0
- package/LICENSE +21 -0
- package/README.md +1854 -2
- package/SKILL.md +122 -0
- package/VERSION +1 -0
- package/forge +74 -0
- package/package.json +26 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +353 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +665 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runtime.py +533 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +198 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +83 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +287 -0
- package/ui/THIRD_PARTY_NOTICES.md +69 -0
- package/ui/docs/api.md +888 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +204 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +583 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1336 -0
- package/ui/forge_ui/server.py +303 -0
- package/ui/forge_ui/static/app.js +924 -0
- package/ui/forge_ui/static/cloud3d.js +646 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +394 -0
- package/ui/forge_ui/static/diffview.js +216 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/index.html +55 -0
- package/ui/forge_ui/static/iso.js +765 -0
- package/ui/forge_ui/static/leaderboard.js +315 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +526 -0
- package/ui/forge_ui/static/runmodel.js +651 -0
- package/ui/forge_ui/static/scene3d.js +996 -0
- package/ui/forge_ui/static/search.js +119 -0
- package/ui/forge_ui/static/style.css +618 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Stdlib-only decision-model HTTP client; every failure degrades to None, never raises.
|
|
2
|
+
|
|
3
|
+
Two providers answer the same request shape ({state, model, questions}): TypeSafe's
|
|
4
|
+
hosted Jev, and Cloudflare Workers AI's hosted Clef. Which one a call goes to is the
|
|
5
|
+
site's (`provider_for`); without a Cloudflare provider configured every site asks
|
|
6
|
+
TypeSafe exactly as it always has.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
import uuid
|
|
21
|
+
|
|
22
|
+
from . import (CLOUDFLARE_CONTEXT, CLOUDFLARE_MAX_QUESTIONS, CLOUDFLARE_MODEL, CLOUDFLARE_URL, DEFAULTS,
|
|
23
|
+
acting, api_key, cloudflare, load_config, provider_for, redact, secrets, shadow, write_json)
|
|
24
|
+
|
|
25
|
+
_BACKOFFS = (0.2, 0.4, 0.8)
|
|
26
|
+
_RETRYABLE_CODES = (429, 529)
|
|
27
|
+
# Cloudflare's own codes inside the envelope. Capacity and throttling pass; a used-up
|
|
28
|
+
# allocation or a plan that does not include the model will not pass by retrying.
|
|
29
|
+
_CF_RETRY = (3040, 971)
|
|
30
|
+
_CF_FINAL = {4006: 'quota', 3036: 'quota', 5035: 'plan'}
|
|
31
|
+
# How many chunks of one request are in flight at once, so a 120-question drift request
|
|
32
|
+
# does not open 120 / 64 sockets at a rate limiter that already counts requests.
|
|
33
|
+
_CF_CONCURRENCY = 4
|
|
34
|
+
# Bytes of an error body read for its codes; a misbehaving proxy cannot stream forever.
|
|
35
|
+
_ERROR_BODY_BYTES = 64 * 1024
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _skip(run_dir: Path | str | None, site: str, reason: str) -> None:
|
|
39
|
+
if run_dir is None:
|
|
40
|
+
return
|
|
41
|
+
try:
|
|
42
|
+
with (Path(run_dir) / 'jev.skip').open('a') as handle:
|
|
43
|
+
handle.write(f'{site}: {reason}\n')
|
|
44
|
+
handle.flush()
|
|
45
|
+
except OSError:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _log(run_dir, site, key, ok, reason, latency_s, model, usage, questions, answers, provider='typesafe') -> None:
|
|
50
|
+
if run_dir is None:
|
|
51
|
+
return
|
|
52
|
+
entry = dict(at=time.time(), site=site, key=key, ok=ok, reason=reason, latency_s=latency_s,
|
|
53
|
+
model=model, usage=usage, questions=questions, answers=answers,
|
|
54
|
+
acting=acting(), shadow=shadow(), provider=provider)
|
|
55
|
+
try:
|
|
56
|
+
with (Path(run_dir) / 'jev.jsonl').open('a') as handle:
|
|
57
|
+
handle.write(json.dumps(entry) + '\n')
|
|
58
|
+
handle.flush()
|
|
59
|
+
except OSError:
|
|
60
|
+
pass
|
|
61
|
+
if not ok:
|
|
62
|
+
_skip(run_dir, site, reason)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Not decisions about a real run: replays of recorded answers, the setup key probe, and
|
|
66
|
+
# backtests over history. Kept out of the run dataset so it only holds live judgments.
|
|
67
|
+
_DATASET_SKIP_SITES = ('setup-probe',)
|
|
68
|
+
_DATASET_SKIP_PREFIXES = ('jev-backtest',)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _dataset(site, key, canonical, ok, reason, latency, model, usage, qids, answers, run_dir, extra=None) -> None:
|
|
72
|
+
"""Keep the full request (state and questions) with its answers in the run dataset.
|
|
73
|
+
|
|
74
|
+
jev.jsonl keeps only the request's sha256, so a judgment could never be replayed
|
|
75
|
+
against another model, or used to train one. The blob's original sha IS that key,
|
|
76
|
+
so the two records join. Fail-open: never raises, never prints.
|
|
77
|
+
"""
|
|
78
|
+
if os.environ.get('FORGE_JEV_FIXTURES') or site in _DATASET_SKIP_SITES or site.startswith(_DATASET_SKIP_PREFIXES):
|
|
79
|
+
return
|
|
80
|
+
try:
|
|
81
|
+
scripts = str(Path(__file__).resolve().parent.parent)
|
|
82
|
+
if scripts not in sys.path:
|
|
83
|
+
sys.path.insert(0, scripts)
|
|
84
|
+
from forge_dataset.capture import jev
|
|
85
|
+
jev(site=site, key=key, canonical=canonical, ok=ok, reason=reason, latency=latency, model=model,
|
|
86
|
+
usage=usage, questions=qids, answers=answers, run_dir=run_dir, acting=acting(), shadow=shadow(),
|
|
87
|
+
extra=extra)
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _canonical(value) -> bytes:
|
|
93
|
+
return json.dumps(value, sort_keys=True, separators=(',', ':')).encode()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _probability(value) -> bool:
|
|
97
|
+
return (isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
98
|
+
and math.isfinite(value) and 0.0 <= value <= 1.0)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def valid_answer(answer, question: dict) -> bool:
|
|
102
|
+
"""An answer of the question's own type that a caller can read without guarding."""
|
|
103
|
+
if not isinstance(answer, dict) or answer.get('type') != question.get('type'):
|
|
104
|
+
return False
|
|
105
|
+
kind = question['type']
|
|
106
|
+
if kind == 'noul':
|
|
107
|
+
return _probability(answer.get('noul'))
|
|
108
|
+
if kind == 'choice':
|
|
109
|
+
if answer.get('choice') not in (question.get('criteria') or {}):
|
|
110
|
+
return False
|
|
111
|
+
found = answer.get('probabilities')
|
|
112
|
+
return found is None or (isinstance(found, dict) and all(_probability(v) for v in found.values()))
|
|
113
|
+
if kind == 'score':
|
|
114
|
+
value = answer.get('score')
|
|
115
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _tokens(text: str) -> int:
|
|
120
|
+
# Conservative: forge's states are code and JSON, which run 3.3-4 characters per token,
|
|
121
|
+
# so dividing by 3 over-counts. Over-counting only means an extra chunk.
|
|
122
|
+
return (len(text) + 2) // 3
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def chunks(state, questions: dict, model: str) -> list[dict] | None:
|
|
126
|
+
"""Questions packed into requests Cloudflare will evaluate whole, or None if none fits.
|
|
127
|
+
|
|
128
|
+
Each request carries the full state, at most CLOUDFLARE_MAX_QUESTIONS questions, and
|
|
129
|
+
stays under 90% of the model's context, which counts state and questions together.
|
|
130
|
+
Past that the service cuts the state silently, so a request that cannot fit even one
|
|
131
|
+
question is refused here instead of being answered on evidence it never saw.
|
|
132
|
+
"""
|
|
133
|
+
budget = int(CLOUDFLARE_CONTEXT.get(model, CLOUDFLARE_CONTEXT[CLOUDFLARE_MODEL]) * 0.9)
|
|
134
|
+
base = _tokens(_canonical(state).decode())
|
|
135
|
+
out, current, cost = [], {}, base
|
|
136
|
+
for qid, question in questions.items():
|
|
137
|
+
need = _tokens(_canonical({qid: question}).decode()) + 8
|
|
138
|
+
if base + need > budget:
|
|
139
|
+
return None
|
|
140
|
+
if current and (len(current) >= CLOUDFLARE_MAX_QUESTIONS or cost + need > budget):
|
|
141
|
+
out.append(current)
|
|
142
|
+
current, cost = {}, base
|
|
143
|
+
current[qid] = question
|
|
144
|
+
cost += need
|
|
145
|
+
if current:
|
|
146
|
+
out.append(current)
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _error_codes(raw: bytes) -> list:
|
|
151
|
+
try:
|
|
152
|
+
envelope = json.loads(raw.decode(errors='replace'))
|
|
153
|
+
except ValueError:
|
|
154
|
+
return []
|
|
155
|
+
errors = envelope.get('errors') if isinstance(envelope, dict) else None
|
|
156
|
+
return [e.get('code') for e in errors or [] if isinstance(e, dict)]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _retry_after(error) -> float | None:
|
|
160
|
+
try:
|
|
161
|
+
value = float(error.headers.get('Retry-After'))
|
|
162
|
+
except (AttributeError, TypeError, ValueError):
|
|
163
|
+
return None
|
|
164
|
+
return value if math.isfinite(value) and value >= 0 else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _cloudflare_post(url: str, body: bytes, token: str, deadline_at: float, hidden: list) -> tuple:
|
|
168
|
+
"""One request to Workers AI: (result dict, None) or (None, reason)."""
|
|
169
|
+
backoffs = iter(_BACKOFFS)
|
|
170
|
+
reason = 'timeout'
|
|
171
|
+
while True:
|
|
172
|
+
remaining = deadline_at - time.time()
|
|
173
|
+
if remaining <= 0:
|
|
174
|
+
return None, reason
|
|
175
|
+
request = urllib.request.Request(url, data=body, method='POST', headers={
|
|
176
|
+
'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'})
|
|
177
|
+
wait = None
|
|
178
|
+
try:
|
|
179
|
+
with urllib.request.urlopen(request, timeout=remaining) as response:
|
|
180
|
+
raw = response.read()
|
|
181
|
+
try:
|
|
182
|
+
envelope = json.loads(raw.decode())
|
|
183
|
+
except ValueError:
|
|
184
|
+
return None, 'malformed'
|
|
185
|
+
if not isinstance(envelope, dict):
|
|
186
|
+
return None, 'malformed'
|
|
187
|
+
if envelope.get('success') is False:
|
|
188
|
+
codes = _error_codes(raw)
|
|
189
|
+
final = next((_CF_FINAL[c] for c in codes if c in _CF_FINAL), None)
|
|
190
|
+
return None, final or 'cloudflare: ' + ','.join(str(c) for c in codes)
|
|
191
|
+
result = envelope.get('result') if 'result' in envelope else envelope
|
|
192
|
+
return (result, None) if isinstance(result, dict) else (None, 'malformed')
|
|
193
|
+
except urllib.error.HTTPError as error:
|
|
194
|
+
try:
|
|
195
|
+
raw = error.read(_ERROR_BODY_BYTES)
|
|
196
|
+
except OSError:
|
|
197
|
+
raw = b''
|
|
198
|
+
codes = _error_codes(raw)
|
|
199
|
+
final = next((_CF_FINAL[c] for c in codes if c in _CF_FINAL), None)
|
|
200
|
+
if final:
|
|
201
|
+
return None, final
|
|
202
|
+
if error.code in (401, 403):
|
|
203
|
+
return None, 'auth'
|
|
204
|
+
# Redacted before it is cut, so a token straddling the cut leaves no prefix.
|
|
205
|
+
reason = 'http-%d: %s' % (error.code, redact(raw.decode(errors='replace'), *hidden)[:200])
|
|
206
|
+
if not (error.code in _RETRYABLE_CODES or error.code >= 500 or set(codes) & set(_CF_RETRY)):
|
|
207
|
+
return None, reason
|
|
208
|
+
wait = _retry_after(error)
|
|
209
|
+
except urllib.error.URLError as error:
|
|
210
|
+
reason = redact('network: ' + str(error.reason), *hidden)
|
|
211
|
+
except (TimeoutError, ValueError, OSError) as error:
|
|
212
|
+
return None, redact('network: ' + str(error), *hidden)
|
|
213
|
+
step = next(backoffs, None)
|
|
214
|
+
remaining = deadline_at - time.time()
|
|
215
|
+
if step is None or remaining <= 0:
|
|
216
|
+
return None, reason
|
|
217
|
+
time.sleep(min(max(step, wait or 0.0), remaining))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _cloudflare_ask(state, questions: dict, provider: dict, deadline_at: float, hidden: list) -> tuple:
|
|
221
|
+
"""(result, None, chunk hashes) or (None, reason, chunk hashes); chunks run concurrently."""
|
|
222
|
+
parts = chunks(state, questions, provider['model'])
|
|
223
|
+
if parts is None:
|
|
224
|
+
return None, 'too-large', []
|
|
225
|
+
url = (provider.get('url') or CLOUDFLARE_URL).format(account=provider['account_id'], model=provider['model'])
|
|
226
|
+
bodies = [_canonical(dict(model=provider['model'], state=state, questions=part)) for part in parts]
|
|
227
|
+
hashes = [hashlib.sha256(body).hexdigest() for body in bodies]
|
|
228
|
+
results, reasons = [None] * len(bodies), ['timeout'] * len(bodies)
|
|
229
|
+
gate = threading.BoundedSemaphore(_CF_CONCURRENCY)
|
|
230
|
+
|
|
231
|
+
def work(i):
|
|
232
|
+
with gate:
|
|
233
|
+
if time.time() < deadline_at:
|
|
234
|
+
results[i], reasons[i] = _cloudflare_post(url, bodies[i], provider['token'], deadline_at, hidden)
|
|
235
|
+
|
|
236
|
+
if len(bodies) == 1:
|
|
237
|
+
work(0)
|
|
238
|
+
else:
|
|
239
|
+
# Daemon threads: a stalled chunk past the deadline must not hold the process open.
|
|
240
|
+
threads = [threading.Thread(target=work, args=(i,), daemon=True) for i in range(len(bodies))]
|
|
241
|
+
for thread in threads:
|
|
242
|
+
thread.start()
|
|
243
|
+
for thread in threads:
|
|
244
|
+
thread.join(max(0.0, deadline_at - time.time()))
|
|
245
|
+
answers, usage, model = {}, {}, None
|
|
246
|
+
for part, result, reason in zip(parts, results, reasons):
|
|
247
|
+
if result is None:
|
|
248
|
+
return None, reason or 'timeout', hashes
|
|
249
|
+
got = result.get('answers')
|
|
250
|
+
if not isinstance(got, dict) or not all(valid_answer(got.get(q), part[q]) for q in part):
|
|
251
|
+
return None, 'malformed', hashes
|
|
252
|
+
answers.update({q: got[q] for q in part})
|
|
253
|
+
for name, value in (result.get('usage') or {}).items():
|
|
254
|
+
if isinstance(value, (int, float)):
|
|
255
|
+
usage[name] = usage.get(name, 0) + value
|
|
256
|
+
model = result.get('model') or model
|
|
257
|
+
return dict(answers=answers, usage=usage or None, model=model or provider['model']), None, hashes
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def ask(state, questions: dict, *, site: str, run_dir=None, config: dict | None = None,
|
|
261
|
+
deadline_s: float | None = None, context: dict | None = None) -> dict | None:
|
|
262
|
+
"""One judgment, or None. `context` (task, stage, attempt, assignment) is recorded with
|
|
263
|
+
the request in the run dataset, for sites whose run dir does not say which task it is."""
|
|
264
|
+
config = config or load_config()
|
|
265
|
+
provider = provider_for(site, config)
|
|
266
|
+
remote = cloudflare(config) if provider == 'cloudflare' else None
|
|
267
|
+
if provider == 'cloudflare':
|
|
268
|
+
key = remote['token'] if remote and remote['account_id'] else None
|
|
269
|
+
model = remote['model'] if remote else CLOUDFLARE_MODEL
|
|
270
|
+
else:
|
|
271
|
+
key = api_key(config)
|
|
272
|
+
model = config.get('model', DEFAULTS['model'])
|
|
273
|
+
qids = list(questions)
|
|
274
|
+
started = time.time()
|
|
275
|
+
extra = dict(context or {}, provider=provider, occurrence=uuid.uuid4().hex)
|
|
276
|
+
|
|
277
|
+
def done(ok, reason, result, model=None, usage=None, latency=None):
|
|
278
|
+
latency = latency if latency is not None else time.time() - started
|
|
279
|
+
answers = result['answers'] if result else None
|
|
280
|
+
_log(run_dir, site, fixture_key, ok, reason, latency, model, usage, qids, answers, provider)
|
|
281
|
+
if canonical is not None:
|
|
282
|
+
_dataset(site, fixture_key, canonical, ok, reason, latency, model, usage, qids, answers, run_dir,
|
|
283
|
+
extra)
|
|
284
|
+
return result
|
|
285
|
+
|
|
286
|
+
fixture_key = canonical = None
|
|
287
|
+
body = dict(state=state, model=model, questions=questions)
|
|
288
|
+
try:
|
|
289
|
+
canonical = _canonical(body)
|
|
290
|
+
except (TypeError, ValueError) as error:
|
|
291
|
+
# A caller that built state out of something unserializable gets today's behaviour,
|
|
292
|
+
# not a traceback from inside a dispatch.
|
|
293
|
+
return done(False, 'unserializable-state: ' + str(error), None)
|
|
294
|
+
fixture_key = hashlib.sha256(canonical).hexdigest()
|
|
295
|
+
|
|
296
|
+
if not key:
|
|
297
|
+
return done(False, 'no-key', None)
|
|
298
|
+
|
|
299
|
+
fixtures_dir = os.environ.get('FORGE_JEV_FIXTURES')
|
|
300
|
+
if fixtures_dir:
|
|
301
|
+
try:
|
|
302
|
+
payload = json.loads((Path(fixtures_dir) / (fixture_key + '.json')).read_text())
|
|
303
|
+
except (OSError, ValueError):
|
|
304
|
+
strict = bool(os.environ.get('FORGE_JEV_FIXTURES_STRICT'))
|
|
305
|
+
reason = ('fixture-miss:' + fixture_key) if strict else 'fixture-miss'
|
|
306
|
+
if strict:
|
|
307
|
+
print('forge jev: ' + reason, file=sys.stderr)
|
|
308
|
+
return done(False, reason, None)
|
|
309
|
+
if not (isinstance(payload, dict) and isinstance(payload.get('answers'), dict)):
|
|
310
|
+
return done(False, 'malformed', None)
|
|
311
|
+
result = dict(answers=payload['answers'], usage=payload.get('usage'),
|
|
312
|
+
model=payload.get('model', model), latency_s=0.0, site=site)
|
|
313
|
+
return done(True, None, result, model=result['model'], usage=result['usage'], latency=0.0)
|
|
314
|
+
|
|
315
|
+
budget = deadline_s if deadline_s is not None else config.get('deadline_s', DEFAULTS['deadline_s'])
|
|
316
|
+
deadline_at = started + budget
|
|
317
|
+
hidden = secrets(config)
|
|
318
|
+
if provider == 'cloudflare':
|
|
319
|
+
payload, reason, extra['chunks'] = _cloudflare_ask(state, questions, remote, deadline_at, hidden)
|
|
320
|
+
if payload is None:
|
|
321
|
+
return done(False, reason, None)
|
|
322
|
+
else:
|
|
323
|
+
url = config.get('endpoint', DEFAULTS['endpoint'])
|
|
324
|
+
backoffs = iter(_BACKOFFS)
|
|
325
|
+
reason = 'timeout'
|
|
326
|
+
while True:
|
|
327
|
+
remaining = deadline_at - time.time()
|
|
328
|
+
if remaining <= 0:
|
|
329
|
+
return done(False, reason, None)
|
|
330
|
+
request = urllib.request.Request(url, data=canonical, method='POST', headers={
|
|
331
|
+
'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json'})
|
|
332
|
+
try:
|
|
333
|
+
with urllib.request.urlopen(request, timeout=remaining) as response:
|
|
334
|
+
payload = json.loads(response.read().decode())
|
|
335
|
+
break
|
|
336
|
+
except urllib.error.HTTPError as error:
|
|
337
|
+
# Redacted before it is cut, so a key straddling the cut leaves no prefix.
|
|
338
|
+
detail = redact(error.read().decode(errors='replace'), key)[:200]
|
|
339
|
+
reason = f'http-{error.code}: {detail}'
|
|
340
|
+
if error.code not in _RETRYABLE_CODES:
|
|
341
|
+
return done(False, reason, None)
|
|
342
|
+
except urllib.error.URLError as error:
|
|
343
|
+
reason = redact('network: ' + str(error.reason), key)
|
|
344
|
+
except (TimeoutError, ValueError) as error:
|
|
345
|
+
return done(False, redact('network: ' + str(error), key), None)
|
|
346
|
+
delay = next(backoffs, None)
|
|
347
|
+
remaining = deadline_at - time.time()
|
|
348
|
+
if delay is None or remaining <= 0:
|
|
349
|
+
return done(False, reason, None)
|
|
350
|
+
time.sleep(min(delay, remaining))
|
|
351
|
+
|
|
352
|
+
if not (isinstance(payload, dict) and isinstance(payload.get('answers'), dict)):
|
|
353
|
+
return done(False, 'malformed', None)
|
|
354
|
+
|
|
355
|
+
record_dir = os.environ.get('FORGE_JEV_RECORD')
|
|
356
|
+
if record_dir:
|
|
357
|
+
try:
|
|
358
|
+
write_json(Path(record_dir) / (fixture_key + '.json'), payload)
|
|
359
|
+
except OSError:
|
|
360
|
+
pass # recording is a convenience; never fail a call that already succeeded
|
|
361
|
+
|
|
362
|
+
latency = time.time() - started
|
|
363
|
+
result = dict(answers=payload['answers'], usage=payload.get('usage'),
|
|
364
|
+
model=payload.get('model', model), latency_s=latency, site=site)
|
|
365
|
+
return done(True, None, result, model=result['model'], usage=result['usage'], latency=latency)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def noul(result, qid: str) -> float | None:
|
|
369
|
+
answer = (result or {}).get('answers', {}).get(qid) if result else None
|
|
370
|
+
return answer.get('noul') if answer and answer.get('type') == 'noul' else None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def choice(result, qid: str) -> tuple[str | None, float]:
|
|
374
|
+
answer = (result or {}).get('answers', {}).get(qid) if result else None
|
|
375
|
+
if not answer or answer.get('type') != 'choice':
|
|
376
|
+
return (None, 0.0)
|
|
377
|
+
return (answer.get('choice'), answer.get('confidence', 0.0))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def probabilities(result, qid: str) -> dict[str, float]:
|
|
381
|
+
"""A Choice's full distribution over its options, {} when the response omits it.
|
|
382
|
+
|
|
383
|
+
`choice()` returns only the winner and its confidence, and for a selection among
|
|
384
|
+
several ACCEPTABLE options that confidence is the wrong quantity: it measures
|
|
385
|
+
"which one", not "any of them". Two commands that both run the suite split the mass
|
|
386
|
+
and depress it, which is a tie, not doubt.
|
|
387
|
+
"""
|
|
388
|
+
answer = (result or {}).get('answers', {}).get(qid) if result else None
|
|
389
|
+
if not answer or answer.get('type') != 'choice':
|
|
390
|
+
return {}
|
|
391
|
+
found = answer.get('probabilities')
|
|
392
|
+
if not isinstance(found, dict):
|
|
393
|
+
return {}
|
|
394
|
+
out = {}
|
|
395
|
+
for option, value in found.items():
|
|
396
|
+
try:
|
|
397
|
+
out[str(option)] = float(value)
|
|
398
|
+
except (TypeError, ValueError):
|
|
399
|
+
continue
|
|
400
|
+
return out
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def score(result, qid: str) -> tuple[float | None, float]:
|
|
404
|
+
answer = (result or {}).get('answers', {}).get(qid) if result else None
|
|
405
|
+
if not answer or answer.get('type') != 'score':
|
|
406
|
+
return (None, 0.0)
|
|
407
|
+
return (answer.get('score'), answer.get('confidence', 0.0))
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def normalized(result, qid: str) -> float | None:
|
|
411
|
+
answer = (result or {}).get('answers', {}).get(qid) if result else None
|
|
412
|
+
if not answer or answer.get('type') != 'score':
|
|
413
|
+
return None
|
|
414
|
+
legend = answer.get('legend') or {}
|
|
415
|
+
value = answer.get('score')
|
|
416
|
+
if value is None or len(legend) < 2:
|
|
417
|
+
return None
|
|
418
|
+
return value / (len(legend) - 1)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Git-history corpus extractor: labeled (change -> tests touched) examples for Jev calibration."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
# Path segment for tests/, test/, spec/, __tests__/, e2e/, or a filename convention
|
|
9
|
+
# (_test., .test., .spec., test_ prefix). Covers the Python and TypeScript repos this runs against.
|
|
10
|
+
TEST_PATTERN = re.compile(
|
|
11
|
+
r'(?:^|/)(?:tests?|specs?|__tests__|e2e)/'
|
|
12
|
+
r'|(?:^|/)test_[^/]*$'
|
|
13
|
+
r'|[^/]*(?:_test|\.test|\.spec)\.[^/]+$'
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Generated/vendored/binary paths carry no signal about human test selection.
|
|
17
|
+
VENDOR_PATTERN = re.compile(
|
|
18
|
+
r'(?:^|/)(?:node_modules|vendor|dist|build|\.next|coverage|__pycache__)/'
|
|
19
|
+
r'|\.min\.js$'
|
|
20
|
+
r'|(?:^|/)package-lock\.json$'
|
|
21
|
+
r'|(?:^|/)yarn\.lock$'
|
|
22
|
+
r'|(?:^|/)poetry\.lock$'
|
|
23
|
+
r'|\.snap$'
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_SEP = '\x1f' # unit separator: safe delimiter for a commit-header line
|
|
27
|
+
# \x1e (record separator) LEADS each commit's header, so splitting on it cleanly isolates
|
|
28
|
+
# "previous commit's file list" from "next commit's header" -- a trailing separator would
|
|
29
|
+
# instead glue this commit's file list to the next commit's header in the same split token.
|
|
30
|
+
# Combined with `git log -z`, git NUL-terminates the format text itself (in place of the
|
|
31
|
+
# newline it would otherwise emit) before the --name-only listing, and NUL-terminates each
|
|
32
|
+
# filename too -- so a multi-line commit body (which freely contains '\n') can never be
|
|
33
|
+
# confused with a newline-joined file list. NUL cannot appear in a commit message or path.
|
|
34
|
+
_FORMAT = '\x1e' + _SEP.join(('%H', '%P', '%s', '%b'))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_test(path: str) -> bool:
|
|
38
|
+
return bool(TEST_PATTERN.search(path))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_vendor(path: str) -> bool:
|
|
42
|
+
return bool(VENDOR_PATTERN.search(path))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def classify(paths: list[str]) -> tuple[list[str], list[str]]:
|
|
46
|
+
"""Split into (source, test), dropping vendor paths from both."""
|
|
47
|
+
source, test = [], []
|
|
48
|
+
for path in paths:
|
|
49
|
+
if _is_vendor(path):
|
|
50
|
+
continue
|
|
51
|
+
(test if is_test(path) else source).append(path)
|
|
52
|
+
return source, test
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _run(repo, argv: list[str]) -> str:
|
|
56
|
+
result = subprocess.run(['git', '-C', str(repo), *argv], stdout=subprocess.PIPE,
|
|
57
|
+
stderr=subprocess.PIPE, timeout=120)
|
|
58
|
+
if result.returncode:
|
|
59
|
+
return ''
|
|
60
|
+
return result.stdout.decode(errors='replace')
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parent_tree_tests(repo, parent: str, cache: dict[str, set[str]]) -> set[str]:
|
|
64
|
+
"""Test files present in the tree AT the parent commit (empty set if parent has none, or lookup fails)."""
|
|
65
|
+
if parent in cache:
|
|
66
|
+
return cache[parent]
|
|
67
|
+
out = _run(repo, ['ls-tree', '-r', '--name-only', parent])
|
|
68
|
+
lines = [line for line in out.split('\n') if line]
|
|
69
|
+
tests = {line for line in lines if is_test(line) and not _is_vendor(line)}
|
|
70
|
+
cache[parent] = tests
|
|
71
|
+
return tests
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def commits(repo, *, limit: int | None = None, max_files: int = 60) -> Iterator[dict]:
|
|
75
|
+
"""Yield one corpus record per usable commit, newest first."""
|
|
76
|
+
log = _run(repo, ['log', '--no-merges', '--name-only', '-z', f'--format={_FORMAT}', 'HEAD'])
|
|
77
|
+
if not log:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
tree_cache: dict[str, set[str]] = {}
|
|
81
|
+
yielded = 0
|
|
82
|
+
for block in log.split('\x1e'):
|
|
83
|
+
if not block:
|
|
84
|
+
continue
|
|
85
|
+
header, sep, filelist = block.partition('\x00')
|
|
86
|
+
if not sep:
|
|
87
|
+
continue
|
|
88
|
+
parts = header.split(_SEP)
|
|
89
|
+
if len(parts) < 3:
|
|
90
|
+
continue
|
|
91
|
+
sha, parents, subject = parts[0], parts[1], parts[2]
|
|
92
|
+
body = (parts[3].rstrip('\n') if len(parts) > 3 else '')
|
|
93
|
+
parent_list = parents.split()
|
|
94
|
+
if len(parent_list) != 1:
|
|
95
|
+
continue # no parent (root commit) or already excluded by --no-merges; be defensive anyway
|
|
96
|
+
parent = parent_list[0]
|
|
97
|
+
|
|
98
|
+
changed = [f for f in filelist.lstrip('\n').split('\x00') if f]
|
|
99
|
+
if not changed or len(changed) > max_files:
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
source_files, test_files = classify(changed)
|
|
103
|
+
if not source_files or not test_files:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
candidate_tests = _parent_tree_tests(repo, parent, tree_cache)
|
|
107
|
+
if not candidate_tests:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
n_test_created = 0
|
|
111
|
+
kept_tests = []
|
|
112
|
+
for path in test_files:
|
|
113
|
+
if path in candidate_tests:
|
|
114
|
+
kept_tests.append(path)
|
|
115
|
+
else:
|
|
116
|
+
n_test_created += 1
|
|
117
|
+
if not kept_tests:
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
candidates = sorted(candidate_tests)
|
|
121
|
+
record = dict(
|
|
122
|
+
repo=str(repo),
|
|
123
|
+
sha=sha,
|
|
124
|
+
parent=parent,
|
|
125
|
+
subject=subject,
|
|
126
|
+
body=body,
|
|
127
|
+
source_files=source_files,
|
|
128
|
+
test_files=sorted(kept_tests),
|
|
129
|
+
all_changed=changed,
|
|
130
|
+
candidates=candidates,
|
|
131
|
+
stats=dict(n_source=len(source_files), n_test=len(kept_tests),
|
|
132
|
+
n_candidates=len(candidates), n_test_created=n_test_created),
|
|
133
|
+
)
|
|
134
|
+
yield record
|
|
135
|
+
yielded += 1
|
|
136
|
+
if limit is not None and yielded >= limit:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def stats(records: list[dict]) -> dict:
|
|
141
|
+
"""Aggregate counts for a dry-run summary."""
|
|
142
|
+
n = len(records)
|
|
143
|
+
total_source = sum(r['stats']['n_source'] for r in records)
|
|
144
|
+
total_test = sum(r['stats']['n_test'] for r in records)
|
|
145
|
+
total_candidates = sum(r['stats']['n_candidates'] for r in records)
|
|
146
|
+
total_created = sum(r['stats']['n_test_created'] for r in records)
|
|
147
|
+
return dict(
|
|
148
|
+
n_commits=n,
|
|
149
|
+
total_source_files=total_source,
|
|
150
|
+
total_test_files=total_test,
|
|
151
|
+
total_candidates=total_candidates,
|
|
152
|
+
total_test_created=total_created,
|
|
153
|
+
avg_candidates=(total_candidates / n) if n else 0.0,
|
|
154
|
+
avg_source_per_commit=(total_source / n) if n else 0.0,
|
|
155
|
+
avg_test_per_commit=(total_test / n) if n else 0.0,
|
|
156
|
+
)
|