@giamme/forge 0.0.0-stage → 0.9.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 +205 -0
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/SKILL.md +123 -0
- package/VERSION +1 -0
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +69 -0
- package/docs/commands.md +315 -0
- package/docs/dataset.md +20 -0
- package/docs/decomposed-runs.md +268 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +92 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +231 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +79 -0
- package/package.json +27 -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 +365 -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 +671 -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-runs.py +121 -0
- package/scripts/forge-runtime.py +586 -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 +240 -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 +84 -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 +347 -0
- package/ui/THIRD_PARTY_NOTICES.md +177 -0
- package/ui/docs/api.md +970 -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 +214 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +582 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1424 -0
- package/ui/forge_ui/server.py +312 -0
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +1079 -0
- package/ui/forge_ui/static/cloud3d.js +647 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +397 -0
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +235 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +67 -0
- package/ui/forge_ui/static/iso.js +816 -0
- package/ui/forge_ui/static/leaderboard.js +311 -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 +562 -0
- package/ui/forge_ui/static/runmodel.js +667 -0
- package/ui/forge_ui/static/scene3d.js +1471 -0
- package/ui/forge_ui/static/search.js +120 -0
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +1073 -0
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Post-review annotation of a FAIL.
|
|
2
|
+
|
|
3
|
+
Reads a reviewer's FAIL and says whether it looks sound. It cannot change the verdict,
|
|
4
|
+
and no caller is given a way to. Forge's second invariant is reviewer independence: a
|
|
5
|
+
model that can talk another model out of a FAIL is worth less than no reviewer at all,
|
|
6
|
+
because what it produces is a confident PASS on code nobody checked.
|
|
7
|
+
|
|
8
|
+
So the output is an annotation for a human, and the status file is never touched. The
|
|
9
|
+
asymmetry is deliberate in the other direction too -- nothing here ever annotates a PASS
|
|
10
|
+
into looking suspect, because a PASS that gets merged is the outcome that needs the
|
|
11
|
+
reviewer's judgement left alone.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import threshold
|
|
18
|
+
from .client import ask, noul
|
|
19
|
+
from .questions import failure_is_correctness, findings_cite_the_diff
|
|
20
|
+
|
|
21
|
+
# A diff large enough to exceed this is one no reviewer read carefully either. Truncating
|
|
22
|
+
# is honest: the annotation is advisory, and a partial read is flagged in the output.
|
|
23
|
+
DIFF_CHARS = 24000
|
|
24
|
+
REVIEW_CHARS = 12000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read(path, limit: int) -> tuple[str, bool]:
|
|
28
|
+
try:
|
|
29
|
+
text = Path(path).read_text(errors='replace')
|
|
30
|
+
except OSError:
|
|
31
|
+
return '', False
|
|
32
|
+
return text[:limit], len(text) > limit
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def annotate_failure(*, review_path, diff_path, run_dir=None, config: dict | None = None):
|
|
36
|
+
"""Judge a FAIL. Returns None when no judgment could be made.
|
|
37
|
+
|
|
38
|
+
Both questions travel in one request. The result carries `suspect` when either
|
|
39
|
+
probability falls below `gate_warn`, which is the only thing a caller should branch
|
|
40
|
+
on -- and the only branch available is what to print.
|
|
41
|
+
"""
|
|
42
|
+
review, review_truncated = _read(review_path, REVIEW_CHARS)
|
|
43
|
+
diff, diff_truncated = _read(diff_path, DIFF_CHARS)
|
|
44
|
+
if not review.strip() or not diff.strip():
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
questions = dict(correctness=failure_is_correctness(), citation=findings_cite_the_diff())
|
|
48
|
+
state = dict(review=review, diff=diff,
|
|
49
|
+
truncated=dict(review=review_truncated, diff=diff_truncated))
|
|
50
|
+
result = ask(state, questions, site='jev-review-triage', run_dir=run_dir, config=config)
|
|
51
|
+
if result is None:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
correctness = noul(result, 'correctness')
|
|
55
|
+
citation = noul(result, 'citation')
|
|
56
|
+
if correctness is None and citation is None:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
warn_at = threshold('gate_warn', config=config)
|
|
60
|
+
reasons = []
|
|
61
|
+
citation_bad = citation is not None and citation < warn_at
|
|
62
|
+
if citation_bad:
|
|
63
|
+
reasons.append('the review cites code that may not be in this diff')
|
|
64
|
+
elif correctness is not None and correctness < warn_at:
|
|
65
|
+
# Only when the citations hold up. A review that points at a file the diff does
|
|
66
|
+
# not contain scores low on correctness too, but reporting that as "these are
|
|
67
|
+
# style preferences" describes the wrong problem: nothing can be said about the
|
|
68
|
+
# correctness of code that is not there. Measured on a hallucinated review --
|
|
69
|
+
# citation 0.02, correctness 0.45 -- where both reasons printed and one was wrong.
|
|
70
|
+
reasons.append('the findings look like style preferences rather than correctness bugs')
|
|
71
|
+
return dict(correctness=correctness, citation=citation, suspect=bool(reasons),
|
|
72
|
+
reasons=reasons, truncated=review_truncated or diff_truncated)
|
|
73
|
+
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Plan-time difficulty scoring.
|
|
2
|
+
|
|
3
|
+
Jev rates five properties of a task; **this file**, not the model, turns them into a
|
|
4
|
+
tier. That split is the whole design. Weights below can be re-tuned against accrued
|
|
5
|
+
outcomes without spending a single request, because re-weighting replays recorded
|
|
6
|
+
scores rather than re-asking.
|
|
7
|
+
|
|
8
|
+
Nothing here writes to tasks.tsv. Scoring is advisory at best and silent in shadow
|
|
9
|
+
mode; see references/jev.md for how the result reaches (or does not reach) a decision.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import enabled, threshold
|
|
16
|
+
from .client import ask, noul, score
|
|
17
|
+
from .questions import (blast_radius, design_judgment, independently_verifiable,
|
|
18
|
+
prompt_adequacy, spec_clarity, state_subtlety, test_coverage)
|
|
19
|
+
|
|
20
|
+
# Weights sum to 1.0. design_judgment carries the most because decompose.md defines
|
|
21
|
+
# difficulty by it first ("rated ... by what the task demands of the model"), and
|
|
22
|
+
# state_subtlety comes second because that same table names concurrency explicitly as a
|
|
23
|
+
# thing that makes a ten-line diff `high`.
|
|
24
|
+
#
|
|
25
|
+
# test_coverage is inverted -- good coverage LOWERS the tier -- and weighted lightly on
|
|
26
|
+
# purpose. Coverage does not reduce the judgment a task demands; it only reduces what a
|
|
27
|
+
# mistake costs. Letting it dominate would route a genuinely hard change to a cheap
|
|
28
|
+
# model because the area happens to be well tested.
|
|
29
|
+
WEIGHTS = dict(design_judgment=0.35, state_subtlety=0.25, blast_radius=0.20,
|
|
30
|
+
spec_clarity=0.15, test_coverage=-0.05)
|
|
31
|
+
|
|
32
|
+
QUESTIONS = dict(design_judgment=design_judgment, state_subtlety=state_subtlety,
|
|
33
|
+
blast_radius=blast_radius, spec_clarity=spec_clarity,
|
|
34
|
+
test_coverage=test_coverage)
|
|
35
|
+
|
|
36
|
+
# Composite cut points. A task exactly on a boundary is the case most likely to be wrong
|
|
37
|
+
# in either direction, so BOUNDARY_BAND either side of a cut escalates a tier: paying
|
|
38
|
+
# for a stronger model on an ambiguous task is the cheap mistake, and under-routing a
|
|
39
|
+
# hard task is the expensive one.
|
|
40
|
+
LOW_MAX = 0.33
|
|
41
|
+
MEDIUM_MAX = 0.63
|
|
42
|
+
BOUNDARY_BAND = 0.05
|
|
43
|
+
|
|
44
|
+
# Below this, the judgment is too uncertain to act on in either direction, so it
|
|
45
|
+
# escalates for the same reason a boundary does.
|
|
46
|
+
CONFIDENCE_FLOOR = 0.55
|
|
47
|
+
|
|
48
|
+
FILE_EXCERPT_CHARS = 600
|
|
49
|
+
MAX_EXCERPT_FILES = 6
|
|
50
|
+
MEMORY_CHARS = 4000
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _normalized(result, qid: str, levels: int):
|
|
54
|
+
"""A Score's position mapped onto 0.0-1.0, with its confidence.
|
|
55
|
+
|
|
56
|
+
Score returns a probability-weighted position across ordered levels, so a task
|
|
57
|
+
sitting between 'some' and 'substantial' lands between them rather than being forced
|
|
58
|
+
onto one. Dividing by (levels - 1) makes rubrics with different level counts
|
|
59
|
+
comparable before they are weighted against each other.
|
|
60
|
+
"""
|
|
61
|
+
position, confidence = score(result, qid)
|
|
62
|
+
if position is None or levels < 2:
|
|
63
|
+
return None, None
|
|
64
|
+
return max(0.0, min(1.0, float(position) / (levels - 1))), confidence
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def tier_for(composite: float) -> str:
|
|
68
|
+
if composite < LOW_MAX:
|
|
69
|
+
return 'low'
|
|
70
|
+
if composite < MEDIUM_MAX:
|
|
71
|
+
return 'medium'
|
|
72
|
+
return 'high'
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _escalate(tier: str) -> str:
|
|
76
|
+
return dict(low='medium', medium='high', high='high')[tier]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _near_boundary(composite: float) -> bool:
|
|
80
|
+
"""Is this close enough to a cut, from BELOW, that it should round up to it?
|
|
81
|
+
|
|
82
|
+
Only from below. A composite just above a cut is already on the higher side of that
|
|
83
|
+
boundary, and escalating it again promotes it a whole tier past the line it was
|
|
84
|
+
merely near -- measured on a real plan, a task at 0.373 sat 0.043 above LOW_MAX,
|
|
85
|
+
scored `medium`, and was escalated to `high`. Rounding up to a boundary is a
|
|
86
|
+
tie-break; jumping the next one is not a tie-break at all.
|
|
87
|
+
"""
|
|
88
|
+
# Strictly below: at exactly the cut, tier_for already returns the higher tier.
|
|
89
|
+
return any(0.0 < cut - composite <= BOUNDARY_BAND for cut in (LOW_MAX, MEDIUM_MAX))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def combine(dimensions: dict) -> dict | None:
|
|
93
|
+
"""Weighted composite -> tier, plus why it may have been escalated.
|
|
94
|
+
|
|
95
|
+
`dimensions` maps a rubric name to (normalized_position, confidence). A missing
|
|
96
|
+
dimension is not fatal: the remaining weights are renormalized, because a partial
|
|
97
|
+
answer that is honest about being partial beats refusing to say anything.
|
|
98
|
+
"""
|
|
99
|
+
usable = {name: value for name, value in dimensions.items()
|
|
100
|
+
if value and value[0] is not None}
|
|
101
|
+
if not usable:
|
|
102
|
+
return None
|
|
103
|
+
total_weight = sum(abs(WEIGHTS[name]) for name in usable)
|
|
104
|
+
if not total_weight:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
# test_coverage's weight is negative, so its contribution is inverted here: a
|
|
108
|
+
# well-covered surface pulls the composite down.
|
|
109
|
+
raw = 0.0
|
|
110
|
+
for name, (position, _confidence) in usable.items():
|
|
111
|
+
weight = WEIGHTS[name]
|
|
112
|
+
raw += (1.0 - position) * abs(weight) if weight < 0 else position * weight
|
|
113
|
+
composite = max(0.0, min(1.0, raw / total_weight))
|
|
114
|
+
|
|
115
|
+
# Confidence is weighted by the same weights as the composite, because that is what
|
|
116
|
+
# it is the confidence OF. Measured: taking the minimum instead let one low-weight
|
|
117
|
+
# dimension veto a well-determined answer -- on a real task, blast_radius came back
|
|
118
|
+
# at 0.0 while design_judgment, which carries 0.35, was 0.94, and the minimum
|
|
119
|
+
# reported 0.0 for a composite that was mostly settled. The weakest dimension is
|
|
120
|
+
# still reported separately as `weakest`, since it is the one worth looking at.
|
|
121
|
+
scored = [(name, value) for name, value in usable.items() if value[1] is not None]
|
|
122
|
+
confidence = weakest = None
|
|
123
|
+
if scored:
|
|
124
|
+
weight_sum = sum(abs(WEIGHTS[name]) for name, _ in scored)
|
|
125
|
+
confidence = round(sum(abs(WEIGHTS[name]) * value[1]
|
|
126
|
+
for name, value in scored) / weight_sum, 3)
|
|
127
|
+
weakest = min(scored, key=lambda item: item[1][1])[0]
|
|
128
|
+
|
|
129
|
+
tier = tier_for(composite)
|
|
130
|
+
escalated = None
|
|
131
|
+
if _near_boundary(composite):
|
|
132
|
+
escalated = 'boundary'
|
|
133
|
+
elif confidence is not None and confidence < CONFIDENCE_FLOOR:
|
|
134
|
+
escalated = 'low-confidence'
|
|
135
|
+
if escalated:
|
|
136
|
+
tier = _escalate(tier)
|
|
137
|
+
return dict(tier=tier, composite=round(composite, 3), confidence=confidence,
|
|
138
|
+
weakest=weakest, escalated=escalated, base_tier=tier_for(composite),
|
|
139
|
+
dimensions={name: round(value[0], 3) for name, value in usable.items()},
|
|
140
|
+
missing=sorted(set(WEIGHTS) - set(usable)))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _excerpt(repo: Path, files: str) -> dict:
|
|
144
|
+
out = {}
|
|
145
|
+
for rel in (files or '').replace(',', ' ').split():
|
|
146
|
+
if len(out) >= MAX_EXCERPT_FILES:
|
|
147
|
+
break
|
|
148
|
+
path = repo / rel
|
|
149
|
+
if not path.is_file():
|
|
150
|
+
# A file the task will CREATE is normal and informative in itself.
|
|
151
|
+
out[rel] = '(does not exist yet)'
|
|
152
|
+
continue
|
|
153
|
+
try:
|
|
154
|
+
out[rel] = path.read_text(errors='replace')[:FILE_EXCERPT_CHARS]
|
|
155
|
+
except OSError:
|
|
156
|
+
continue
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _memory(repo: Path) -> str:
|
|
161
|
+
path = repo / '.forge' / 'memory.md'
|
|
162
|
+
try:
|
|
163
|
+
return path.read_text(errors='replace')[:MEMORY_CHARS] if path.is_file() else ''
|
|
164
|
+
except OSError:
|
|
165
|
+
return ''
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# The other tasks in the plan, as sent to the model. Capped because a large plan would
|
|
169
|
+
# otherwise dominate the payload, and truncated per field for the same reason: the
|
|
170
|
+
# question these answer is "does this task need one of those to land first", which needs
|
|
171
|
+
# their identity and file scope, not their full text.
|
|
172
|
+
MAX_SIBLINGS = 40
|
|
173
|
+
SIBLING_TITLE_CHARS = 120
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _siblings(siblings, task_id: str) -> list[dict]:
|
|
177
|
+
"""Compact view of the rest of the plan, excluding the task being scored."""
|
|
178
|
+
out = []
|
|
179
|
+
for sibling in siblings or []:
|
|
180
|
+
if sibling.get('id') == task_id:
|
|
181
|
+
continue
|
|
182
|
+
out.append(dict(id=sibling.get('id', ''),
|
|
183
|
+
title=str(sibling.get('title', ''))[:SIBLING_TITLE_CHARS],
|
|
184
|
+
files=sibling.get('files', ''),
|
|
185
|
+
deps=sibling.get('deps', '')))
|
|
186
|
+
if len(out) >= MAX_SIBLINGS:
|
|
187
|
+
break
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def score_task(repo, *, goal: str, task_id: str, title: str, files: str,
|
|
192
|
+
approach: str = '', prompt: str = '', declared_difficulty: str = '',
|
|
193
|
+
deps: str = '', siblings: list[dict] | None = None,
|
|
194
|
+
run_dir=None, config: dict | None = None) -> dict | None:
|
|
195
|
+
"""One request per task. All five rubrics evaluate in parallel server-side.
|
|
196
|
+
|
|
197
|
+
Returns None whenever anything at all goes wrong, because a plan that cannot be
|
|
198
|
+
scored must still be a plan.
|
|
199
|
+
"""
|
|
200
|
+
repo_path = Path(repo)
|
|
201
|
+
questions = {name: builder() for name, builder in QUESTIONS.items()}
|
|
202
|
+
# `prompt` is the task's requirements -- tasks/<id>/prompt.md, the text the dwarf is
|
|
203
|
+
# dispatched with and the reviewer reads. It was missing from this state until a real
|
|
204
|
+
# plan was scored, and its absence was not visible from any unit test: every rubric
|
|
205
|
+
# still answered, plausibly, about the one-line title.
|
|
206
|
+
#
|
|
207
|
+
# It mattered most to the gate that exists to catch under-specified tasks. Asked
|
|
208
|
+
# whether "the task states requirements specific enough that a reviewer could judge
|
|
209
|
+
# correctness", with only a title in state, the honest answer is no -- so a 460-word
|
|
210
|
+
# spec with a declared output shape and named edge cases scored 0.23 and tripped its
|
|
211
|
+
# own warning. The gate could not have done anything else; it was never shown the
|
|
212
|
+
# requirements it was asked about.
|
|
213
|
+
# The rest of the plan. independently_verifiable asks whether confirming this task
|
|
214
|
+
# needs "work declared in a different task" -- and until now no different task was
|
|
215
|
+
# ever in state, so the model could only guess whether such a task existed. That is
|
|
216
|
+
# why its two classes overlapped (0.34-0.56 unverifiable against 0.43-0.90
|
|
217
|
+
# verifiable) and no threshold divided them: same failure as prompt.md, a rubric
|
|
218
|
+
# scored against evidence it was never shown.
|
|
219
|
+
plan_tasks = _siblings(siblings, task_id)
|
|
220
|
+
state = dict(goal=goal, task=dict(id=task_id, title=title, files=files, deps=deps),
|
|
221
|
+
prompt=prompt, approach=approach, plan_tasks=plan_tasks,
|
|
222
|
+
file_excerpts=_excerpt(repo_path, files), memory=_memory(repo_path))
|
|
223
|
+
# The planner's own rating is deliberately NOT sent. Jev is here to be a second,
|
|
224
|
+
# independent opinion on the same evidence; showing it the answer first would buy an
|
|
225
|
+
# agreement rate instead of a judgment.
|
|
226
|
+
# Phase 4's plan-time gates need exactly this state, and questions in one request
|
|
227
|
+
# evaluate in parallel, so folding them in here costs no extra round trip -- the
|
|
228
|
+
# alternative is a second request per task carrying an identical payload.
|
|
229
|
+
# (An effort question rode along here until 2026-10-17: it carried no signal beyond the
|
|
230
|
+
# tier -- within-tier AUC 0.50-0.54, 81% of answers xhigh or higher -- and never acted.
|
|
231
|
+
# Effort is now measured the way models are, by exploring pools that name efforts.)
|
|
232
|
+
|
|
233
|
+
gate_ids = {}
|
|
234
|
+
if enabled('gates', config=config):
|
|
235
|
+
gate_ids = dict(prompt_adequacy=prompt_adequacy,
|
|
236
|
+
independently_verifiable=independently_verifiable)
|
|
237
|
+
for name, builder in gate_ids.items():
|
|
238
|
+
questions[name] = builder()
|
|
239
|
+
|
|
240
|
+
result = ask(state, questions, site='jev-score-plan', run_dir=run_dir, config=config)
|
|
241
|
+
if result is None:
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
dimensions = {}
|
|
245
|
+
for name in QUESTIONS:
|
|
246
|
+
dimensions[name] = _normalized(result, name, len(questions[name]['criteria']))
|
|
247
|
+
combined = combine(dimensions)
|
|
248
|
+
if combined is None:
|
|
249
|
+
return None
|
|
250
|
+
combined.update(id=task_id, declared=declared_difficulty)
|
|
251
|
+
|
|
252
|
+
# Warnings, not gates: recorded below the threshold and silent above it. A LOW
|
|
253
|
+
# probability is what is worth saying out loud here.
|
|
254
|
+
#
|
|
255
|
+
# Each rubric gets its own threshold because they are not on one scale, which is the
|
|
256
|
+
# same trap `tests_act` set earlier. Measured over 15 hand-labelled task prompts:
|
|
257
|
+
# genuinely vague prompts ("add tests", "make it faster") all landed at 0.04 while
|
|
258
|
+
# specific ones ran 0.55-0.87, and task fragments that need a sibling to land first
|
|
259
|
+
# scored 0.11-0.24 against 0.51-0.72 for self-contained work. The shared gate_warn
|
|
260
|
+
# of 0.60 -- which IS right for drift, where it was measured -- would have warned on
|
|
261
|
+
# 5 of 9 and 5 of 11 perfectly good tasks.
|
|
262
|
+
warn_levels = dict(prompt_adequacy=threshold('prompt_warn', config=config),
|
|
263
|
+
independently_verifiable=threshold('verifiable_warn', config=config))
|
|
264
|
+
warnings = {}
|
|
265
|
+
for name in gate_ids:
|
|
266
|
+
probability = noul(result, name)
|
|
267
|
+
if probability is not None and probability < warn_levels[name]:
|
|
268
|
+
warnings[name] = round(probability, 3)
|
|
269
|
+
if warnings:
|
|
270
|
+
combined['warnings'] = warnings
|
|
271
|
+
return combined
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Run the tests a change most likely broke, early, in a throwaway copy.
|
|
2
|
+
|
|
3
|
+
Read the deferral note in references/jev.md first: there is no safe window to run tests
|
|
4
|
+
inside a task worktree. An artifact created before the diff is captured reaches QA as if
|
|
5
|
+
the dwarf wrote it; one created after trips the fingerprint re-checks in `do_task` and
|
|
6
|
+
`merge_task`, which write INVALIDATED and block the merge. So this never touches the
|
|
7
|
+
worktree. It exports the reviewed commit to a disposable directory and runs there.
|
|
8
|
+
|
|
9
|
+
**This is early feedback, not verification, and not a speed-up.** The full suite still
|
|
10
|
+
runs at integration exactly as before, so this ADDS a run rather than replacing one. What
|
|
11
|
+
it buys is that a reviewer learns "your diff breaks test_x" while it can still act on
|
|
12
|
+
that, instead of the failure surfacing after the wave. Anyone reading this expecting the
|
|
13
|
+
wall-clock lever the plan described should know the lever was not available: it required
|
|
14
|
+
replacing the full verification, which the fingerprint rules forbid.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from . import threshold
|
|
25
|
+
from .client import ask, noul
|
|
26
|
+
from .questions import test_relevance
|
|
27
|
+
|
|
28
|
+
CANDIDATE_CAP = 100
|
|
29
|
+
HEADER_LINES = 12
|
|
30
|
+
|
|
31
|
+
# Two conditions, not one. Matching "anything under tests/" pulled in README.md, JSON
|
|
32
|
+
# fixtures and benchmark output -- files no runner will execute, each costing a question
|
|
33
|
+
# and a slot under the cap.
|
|
34
|
+
_TEST_PATH_RE = re.compile(r'(^|/)(tests?|spec|specs|__tests__)/'
|
|
35
|
+
r'|(^|/)test_[^/]*$|_test\.[^/]+$|\.(test|spec)\.[^/]+$')
|
|
36
|
+
_CODE_SUFFIXES = ('.py', '.go', '.js', '.jsx', '.ts', '.tsx', '.rb', '.rs', '.java',
|
|
37
|
+
'.kt', '.php', '.sh', '.mjs', '.cjs')
|
|
38
|
+
|
|
39
|
+
# Copied rather than rebuilt when present. Rebuilding a dependency tree per task would
|
|
40
|
+
# cost far more than the feedback is worth; a symlink keeps it near-free.
|
|
41
|
+
_DEP_DIRS = ('node_modules', '.venv', 'venv', 'vendor')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def candidate_tests(repo) -> list[str]:
|
|
45
|
+
"""Tracked test files, sorted and capped, from the repo's own index."""
|
|
46
|
+
try:
|
|
47
|
+
out = subprocess.run(['git', '-C', str(repo), 'ls-files', '-z'],
|
|
48
|
+
capture_output=True, text=True, timeout=20)
|
|
49
|
+
except (OSError, subprocess.SubprocessError):
|
|
50
|
+
return []
|
|
51
|
+
if out.returncode != 0:
|
|
52
|
+
return []
|
|
53
|
+
found = sorted(name for name in out.stdout.split('\0')
|
|
54
|
+
if name and name.endswith(_CODE_SUFFIXES) and _TEST_PATH_RE.search(name))
|
|
55
|
+
return found[:CANDIDATE_CAP]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _header(repo: Path, rel: str) -> str:
|
|
59
|
+
try:
|
|
60
|
+
with (repo / rel).open(errors='replace') as handle:
|
|
61
|
+
return ''.join(next(handle, '') for _ in range(HEADER_LINES))
|
|
62
|
+
except OSError:
|
|
63
|
+
return ''
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def select(repo, *, task: str, changed: list, candidates: list, run_dir=None,
|
|
67
|
+
config: dict | None = None):
|
|
68
|
+
"""The tests worth running for this change, most likely first.
|
|
69
|
+
|
|
70
|
+
Returns None when no judgment could be made. The threshold is `tests_act`'s sibling
|
|
71
|
+
`gate_warn` deliberately NOT reused here -- see the caller, which uses the measured
|
|
72
|
+
per-repo value when one exists and otherwise runs nothing rather than guess.
|
|
73
|
+
"""
|
|
74
|
+
candidates = [c for c in (candidates or []) if c]
|
|
75
|
+
if not candidates or not (task or '').strip():
|
|
76
|
+
return None
|
|
77
|
+
repo_path = Path(repo)
|
|
78
|
+
questions = {f't{i}': test_relevance(name, _header(repo_path, name))
|
|
79
|
+
for i, name in enumerate(candidates)}
|
|
80
|
+
state = dict(task=task, changed_files=list(changed or []))
|
|
81
|
+
result = ask(state, questions, site='jev-test-subset', run_dir=run_dir, config=config)
|
|
82
|
+
if result is None:
|
|
83
|
+
return None
|
|
84
|
+
scored = []
|
|
85
|
+
for i, name in enumerate(candidates):
|
|
86
|
+
probability = noul(result, f't{i}')
|
|
87
|
+
if probability is not None:
|
|
88
|
+
scored.append((probability, name))
|
|
89
|
+
scored.sort(reverse=True)
|
|
90
|
+
return scored
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def export(repo, commit: str, destination) -> bool:
|
|
94
|
+
"""Copy one commit's tree into a throwaway directory. Never touches the worktree."""
|
|
95
|
+
destination = Path(destination)
|
|
96
|
+
try:
|
|
97
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
archive = subprocess.run(['git', '-C', str(repo), 'archive', commit],
|
|
99
|
+
capture_output=True, timeout=120)
|
|
100
|
+
if archive.returncode != 0:
|
|
101
|
+
return False
|
|
102
|
+
extract = subprocess.run(['tar', '-x', '-C', str(destination)],
|
|
103
|
+
input=archive.stdout, capture_output=True, timeout=120)
|
|
104
|
+
if extract.returncode != 0:
|
|
105
|
+
return False
|
|
106
|
+
except (OSError, subprocess.SubprocessError):
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
# Dependencies are not in the tree. Link them so the export can actually run; a
|
|
110
|
+
# missing link simply means the command fails and the caller reports nothing.
|
|
111
|
+
for name in _DEP_DIRS:
|
|
112
|
+
source = Path(repo) / name
|
|
113
|
+
target = destination / name
|
|
114
|
+
if source.is_dir() and not target.exists():
|
|
115
|
+
try:
|
|
116
|
+
os.symlink(source, target)
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cleanup(destination) -> None:
|
|
123
|
+
shutil.rmtree(str(destination), ignore_errors=True)
|