@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,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
+ )