@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,244 @@
|
|
|
1
|
+
"""Plan-time semantic wave coupling.
|
|
2
|
+
|
|
3
|
+
`compute_waves` bounds parallelism structurally: two tasks share a wave only when their
|
|
4
|
+
declared `files` are disjoint. Disjointness is blind to the conflict where task A changes
|
|
5
|
+
an interface task B consumes -- both diffs pass review against their own baseline, and the
|
|
6
|
+
merge is broken (see questions.wave_coupling). This asks about every pair `compute_waves`
|
|
7
|
+
would actually run concurrently, before the wave runs, so the warning still lands while the
|
|
8
|
+
plan can change.
|
|
9
|
+
|
|
10
|
+
Nothing here writes to tasks.tsv or waves.tsv. Scoring is advisory and silent in shadow
|
|
11
|
+
mode, exactly like routing.py and gates.py.
|
|
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 forward_dependency, wave_coupling
|
|
20
|
+
|
|
21
|
+
# A wave of 12 tasks is 66 pairs; a plan-time gate must not become the slowest thing in
|
|
22
|
+
# `forge plan`. Sorted before truncation (see pairs()) so the same plan always produces
|
|
23
|
+
# the same capped set.
|
|
24
|
+
MAX_PAIRS = 60
|
|
25
|
+
|
|
26
|
+
# How many pairs a plan may warn about, however many clear the threshold.
|
|
27
|
+
#
|
|
28
|
+
# Rank is the signal here, not the absolute value. Over 11 pairs with known outcomes the
|
|
29
|
+
# genuinely coupled ones took 4 of the top 5 places, but the absolute scores moved with
|
|
30
|
+
# the plan: the same rubric put the whole distribution at 0.09-0.27 in one measurement
|
|
31
|
+
# and 0.20-0.38 on the live 8-task plan. At a fixed 0.20 that live plan warned about 12
|
|
32
|
+
# of its 21 pairs, and a gate that fires on more than half of everything teaches people
|
|
33
|
+
# to ignore it -- which is the argument this file's own threshold comment makes about
|
|
34
|
+
# using one number for several rubrics.
|
|
35
|
+
#
|
|
36
|
+
# So the threshold decides whether anything is worth saying and the ordering decides
|
|
37
|
+
# what gets said. Three is what fits in a plan summary a human will actually read.
|
|
38
|
+
MAX_WARNINGS = 3
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Cross-wave pairs get their own budget rather than sharing MAX_PAIRS: a plan with one
|
|
42
|
+
# big first wave would otherwise spend the whole allowance inside it and never ask the
|
|
43
|
+
# ordering question at all, which is the one that shipped a defect.
|
|
44
|
+
MAX_FORWARD_PAIRS = 40
|
|
45
|
+
|
|
46
|
+
PROMPT_CHARS = 2500
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_tasks(plan: Path) -> dict[str, dict]:
|
|
50
|
+
"""id -> {id, title, files, deps, difficulty, prompt}, skipping comments and malformed rows."""
|
|
51
|
+
path = plan / 'tasks.tsv'
|
|
52
|
+
tasks = {}
|
|
53
|
+
try:
|
|
54
|
+
lines = path.read_text(errors='replace').splitlines()
|
|
55
|
+
except OSError:
|
|
56
|
+
return tasks
|
|
57
|
+
for line in lines:
|
|
58
|
+
if not line.strip() or line.startswith('#'):
|
|
59
|
+
continue
|
|
60
|
+
parts = line.split('\t')
|
|
61
|
+
if len(parts) < 7:
|
|
62
|
+
continue
|
|
63
|
+
task_id, deps, difficulty, files, _dwarf, _qa, title = parts[:7]
|
|
64
|
+
if not task_id:
|
|
65
|
+
continue
|
|
66
|
+
# The requirements, not the implementation. An interface dependency between two
|
|
67
|
+
# tasks is written down here and nowhere else -- certainly not in a one-line
|
|
68
|
+
# title, which is all this rubric used to get.
|
|
69
|
+
prompt = ''
|
|
70
|
+
prompt_file = plan / 'tasks' / task_id / 'prompt.md'
|
|
71
|
+
try:
|
|
72
|
+
if prompt_file.is_file():
|
|
73
|
+
prompt = prompt_file.read_text(errors='replace')[:PROMPT_CHARS]
|
|
74
|
+
except OSError:
|
|
75
|
+
prompt = ''
|
|
76
|
+
tasks[task_id] = dict(id=task_id, title=title, files=files, deps=deps,
|
|
77
|
+
difficulty=difficulty, prompt=prompt)
|
|
78
|
+
return tasks
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_waves(plan: Path) -> dict[str, list[str]]:
|
|
82
|
+
"""wave number (as it appears in waves.tsv) -> ordered list of task ids."""
|
|
83
|
+
path = plan / 'waves.tsv'
|
|
84
|
+
waves: dict[str, list[str]] = {}
|
|
85
|
+
try:
|
|
86
|
+
lines = path.read_text(errors='replace').splitlines()
|
|
87
|
+
except OSError:
|
|
88
|
+
return waves
|
|
89
|
+
for line in lines:
|
|
90
|
+
parts = line.split('\t')
|
|
91
|
+
if len(parts) < 2:
|
|
92
|
+
continue
|
|
93
|
+
wave, task_id = parts[0], parts[1]
|
|
94
|
+
if not wave or not task_id:
|
|
95
|
+
continue
|
|
96
|
+
waves.setdefault(wave, []).append(task_id)
|
|
97
|
+
return waves
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def pairs(tasks: dict, waves: dict) -> list[tuple[str, str]]:
|
|
101
|
+
"""Every same-wave task pair with both ids known, capped at MAX_PAIRS.
|
|
102
|
+
|
|
103
|
+
Sorted before truncation, same reasoning as gates.candidates: an unstable slice would
|
|
104
|
+
make the same plan warn about a different subset of pairs on two runs.
|
|
105
|
+
"""
|
|
106
|
+
found = []
|
|
107
|
+
for ids in waves.values():
|
|
108
|
+
ordered = sorted(i for i in ids if i in tasks)
|
|
109
|
+
for i, first in enumerate(ordered):
|
|
110
|
+
for second in ordered[i + 1:]:
|
|
111
|
+
found.append((first, second))
|
|
112
|
+
found.sort()
|
|
113
|
+
return found[:MAX_PAIRS]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _wave_order(waves: dict) -> dict[str, int]:
|
|
117
|
+
"""Task id -> wave position. Waves are numbered as strings in waves.tsv; sort them
|
|
118
|
+
numerically where possible so '10' follows '9' rather than '1'."""
|
|
119
|
+
def key(name):
|
|
120
|
+
try:
|
|
121
|
+
return (0, int(name))
|
|
122
|
+
except ValueError:
|
|
123
|
+
return (1, name)
|
|
124
|
+
|
|
125
|
+
order = {}
|
|
126
|
+
for position, wave in enumerate(sorted(waves, key=key)):
|
|
127
|
+
for task_id in waves[wave]:
|
|
128
|
+
order[task_id] = position
|
|
129
|
+
return order
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def forward_pairs(tasks: dict, waves: dict) -> list[tuple[str, str]]:
|
|
133
|
+
"""Ordered (earlier, later) pairs across waves that nothing forces to be safe.
|
|
134
|
+
|
|
135
|
+
`pairs()` only looks inside a wave, so "task A is written against an interface task B
|
|
136
|
+
builds later" was structurally outside this gate's reach -- and that is what shipped:
|
|
137
|
+
jevscope's `readme` ran in wave 1 documenting the CLI that wave 2 built, passed its
|
|
138
|
+
own review, and described an interface nobody ever wrote.
|
|
139
|
+
|
|
140
|
+
A declared dependency is excluded, in the direction that matters: if LATER is named
|
|
141
|
+
in EARLIER's deps, forge would not have placed EARLIER first, so the pair cannot
|
|
142
|
+
arise. Nothing else about wave placement makes the order safe.
|
|
143
|
+
"""
|
|
144
|
+
order = _wave_order(waves)
|
|
145
|
+
found = []
|
|
146
|
+
for earlier in sorted(tasks):
|
|
147
|
+
for later in sorted(tasks):
|
|
148
|
+
if earlier == later:
|
|
149
|
+
continue
|
|
150
|
+
if earlier not in order or later not in order:
|
|
151
|
+
continue
|
|
152
|
+
if order[earlier] >= order[later]:
|
|
153
|
+
continue
|
|
154
|
+
declared = {d.strip() for d in str(tasks[earlier].get('deps', '')).split(',')}
|
|
155
|
+
if later in declared:
|
|
156
|
+
continue
|
|
157
|
+
found.append((earlier, later))
|
|
158
|
+
found.sort()
|
|
159
|
+
return found[:MAX_FORWARD_PAIRS]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def forward_dependencies(repo, *, tasks, waves, run_dir=None, config: dict | None = None):
|
|
163
|
+
"""Earlier-wave tasks Jev thinks were written against work that lands later.
|
|
164
|
+
|
|
165
|
+
Same contract as coupled_pairs: [(earlier, later, probability), ...] highest first,
|
|
166
|
+
[] for "nothing to say", None for "no judgment could be made".
|
|
167
|
+
"""
|
|
168
|
+
by_id = {t['id']: t for t in tasks if isinstance(t, dict) and t.get('id')}
|
|
169
|
+
candidates = forward_pairs(by_id, waves)
|
|
170
|
+
if not candidates:
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
questions = {}
|
|
174
|
+
for i, (earlier, later) in enumerate(candidates):
|
|
175
|
+
earlier_desc = f"{earlier} ({by_id[earlier]['title']}) files: {by_id[earlier]['files']}"
|
|
176
|
+
later_desc = f"{later} ({by_id[later]['title']}) files: {by_id[later]['files']}"
|
|
177
|
+
questions[f'f{i}'] = forward_dependency(earlier_desc, later_desc)
|
|
178
|
+
|
|
179
|
+
involved = sorted({task_id for pair in candidates for task_id in pair})
|
|
180
|
+
state = dict(tasks=[dict(id=by_id[i]['id'], title=by_id[i]['title'],
|
|
181
|
+
files=by_id[i]['files'],
|
|
182
|
+
requirements=by_id[i].get('prompt', ''))
|
|
183
|
+
for i in involved])
|
|
184
|
+
result = ask(state, questions, site='jev-forward', run_dir=run_dir, config=config)
|
|
185
|
+
if result is None:
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
warn_at = threshold('forward_warn', config=config)
|
|
189
|
+
found = []
|
|
190
|
+
for i, (earlier, later) in enumerate(candidates):
|
|
191
|
+
probability = noul(result, f'f{i}')
|
|
192
|
+
if probability is not None and probability >= warn_at:
|
|
193
|
+
found.append((earlier, later, probability))
|
|
194
|
+
found.sort(key=lambda row: (-row[2], row[0], row[1]))
|
|
195
|
+
return found[:MAX_WARNINGS]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def coupled_pairs(repo, *, tasks, waves, run_dir=None, config: dict | None = None):
|
|
199
|
+
"""Same-wave task pairs Jev thinks would still conflict once merged.
|
|
200
|
+
|
|
201
|
+
`tasks` is a list of dicts with id/title/files/difficulty; `waves` maps a wave number
|
|
202
|
+
to the list of task ids placed in it -- the same shapes `compute_waves` produces.
|
|
203
|
+
Returns [(id_a, id_b, probability), ...] at or above threshold('gate_warn'), highest
|
|
204
|
+
probability first. Returns [] when there is nothing to warn about and None when no
|
|
205
|
+
judgment could be made, so a caller can tell "no coupling found" from "Jev did not
|
|
206
|
+
answer".
|
|
207
|
+
"""
|
|
208
|
+
by_id = {t['id']: t for t in tasks if isinstance(t, dict) and t.get('id')}
|
|
209
|
+
candidate_pairs = pairs(by_id, waves)
|
|
210
|
+
if not candidate_pairs:
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
questions = {}
|
|
214
|
+
for i, (first, second) in enumerate(candidate_pairs):
|
|
215
|
+
first_desc = f"{first} ({by_id[first]['title']}) files: {by_id[first]['files']}"
|
|
216
|
+
second_desc = f"{second} ({by_id[second]['title']}) files: {by_id[second]['files']}"
|
|
217
|
+
questions[f'p{i}'] = wave_coupling(first_desc, second_desc)
|
|
218
|
+
|
|
219
|
+
# No file CONTENTS in state -- the rubric asks about interfaces between tasks, not
|
|
220
|
+
# implementations, and sending diffs nobody wrote yet would only add noise and cost.
|
|
221
|
+
# The task's requirements are a different thing, and they are where an interface
|
|
222
|
+
# dependency is actually written down. Without them this rubric had no signal at all:
|
|
223
|
+
# measured over 11 pairs with known outcomes, the two that produced a defect that
|
|
224
|
+
# really shipped scored LOWEST of everything, below pairs that were unrelated. With
|
|
225
|
+
# them, 4 of the 5 coupled pairs rank above every independent one.
|
|
226
|
+
#
|
|
227
|
+
# Only the tasks appearing in a candidate pair are sent, not the whole plan.
|
|
228
|
+
involved = sorted({task_id for pair in candidate_pairs for task_id in pair})
|
|
229
|
+
state = dict(tasks=[dict(id=by_id[i]['id'], title=by_id[i]['title'],
|
|
230
|
+
files=by_id[i]['files'],
|
|
231
|
+
requirements=by_id[i].get('prompt', ''))
|
|
232
|
+
for i in involved])
|
|
233
|
+
result = ask(state, questions, site='jev-coupling', run_dir=run_dir, config=config)
|
|
234
|
+
if result is None:
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
warn_at = threshold('coupling_warn', config=config)
|
|
238
|
+
found = []
|
|
239
|
+
for i, (first, second) in enumerate(candidate_pairs):
|
|
240
|
+
probability = noul(result, f'p{i}')
|
|
241
|
+
if probability is not None and probability >= warn_at:
|
|
242
|
+
found.append((first, second, round(probability, 3)))
|
|
243
|
+
found.sort(key=lambda item: item[2], reverse=True)
|
|
244
|
+
return found[:MAX_WARNINGS]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Plain numeric features of a task, for a decision model that reads a routing state.
|
|
2
|
+
|
|
3
|
+
A task's size is the strongest regime signal in forge's run history: the median prompt of
|
|
4
|
+
the newest runs is twice the older ones' and their first-pass rate is half. A model reading
|
|
5
|
+
prose and a 40-entry sibling list can only estimate that, so the state spells it out.
|
|
6
|
+
|
|
7
|
+
The dataset export adds this block to the routing state it trains on. Hosted Jev's live
|
|
8
|
+
request does not carry it (changing that request changes every fixture key), so a local
|
|
9
|
+
model wired in later must send the same block, with the exact plan size and decompose
|
|
10
|
+
level: the live state caps siblings at 40 and has no level. No I/O here; callers pass values.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _count(value) -> int:
|
|
16
|
+
if isinstance(value, (list, tuple)):
|
|
17
|
+
return len([v for v in value if v])
|
|
18
|
+
return len([w for w in str(value or '').replace(',', ' ').split() if w and w != '-'])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def task_size(*, prompt: str, declared_files, plan_tasks: int, deps, decompose_level: str | None) -> dict:
|
|
22
|
+
return dict(prompt_chars=len(prompt or ''), declared_files=_count(declared_files),
|
|
23
|
+
plan_tasks=int(plan_tasks), deps=_count(deps),
|
|
24
|
+
decompose_level=decompose_level if decompose_level in ('low', 'medium', 'high') else None)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Plan-time `files` drift prediction.
|
|
2
|
+
|
|
3
|
+
`decompose.md` calls the `files` column "a promise, not a prediction": a task that edits
|
|
4
|
+
a file it never declared breaks wave disjointness, which is the only thing bounding
|
|
5
|
+
parallelism. Forge already detects this, but afterwards, in `drift.txt` -- by which point
|
|
6
|
+
the wave has run. Asking at plan time converts a post-mortem into a warning while the
|
|
7
|
+
plan can still change.
|
|
8
|
+
|
|
9
|
+
Measured before shipping (see references/jev.md): at the default `gate_warn` of 0.60 this
|
|
10
|
+
rubric scored precision 1.000 on one repo and 0.934 on another, over 50 commits. Precision
|
|
11
|
+
is the number that matters here -- a warning that fires on files nobody edits teaches
|
|
12
|
+
people to ignore every future warning.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import threshold
|
|
20
|
+
from .client import ask, noul
|
|
21
|
+
from .questions import drift_prediction
|
|
22
|
+
|
|
23
|
+
# One request per task holds this many questions comfortably, and a plan-time gate must
|
|
24
|
+
# not turn into the slowest thing in `forge plan`.
|
|
25
|
+
CANDIDATE_CAP = 120
|
|
26
|
+
|
|
27
|
+
# Files nobody means when they say "did this task edit something it did not declare".
|
|
28
|
+
_SKIP_PREFIXES = ('.git/', '.forge/', 'node_modules/', 'vendor/', 'target/', 'dist/',
|
|
29
|
+
'build/', '.venv/', 'venv/', '__pycache__/')
|
|
30
|
+
_SKIP_SUFFIXES = ('.lock', '.min.js', '.map', '.png', '.jpg', '.jpeg', '.gif', '.svg',
|
|
31
|
+
'.ico', '.pdf', '.zip', '.woff', '.woff2', '.ttf')
|
|
32
|
+
# Lockfiles whose name the suffix list cannot catch. They are generated, enormous, and
|
|
33
|
+
# genuinely do change whenever dependencies do, which is what would make a drift warning
|
|
34
|
+
# about them both technically true and completely useless.
|
|
35
|
+
_SKIP_NAMES = ('package-lock.json', 'npm-shrinkwrap.json', 'composer.lock',
|
|
36
|
+
'Gemfile.lock', 'go.sum', 'pnpm-lock.yaml', 'yarn.lock')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _tracked(repo: Path) -> list[str]:
|
|
40
|
+
try:
|
|
41
|
+
out = subprocess.run(['git', '-C', str(repo), 'ls-files', '-z'],
|
|
42
|
+
capture_output=True, text=True, timeout=20)
|
|
43
|
+
except (OSError, subprocess.SubprocessError):
|
|
44
|
+
return []
|
|
45
|
+
if out.returncode != 0:
|
|
46
|
+
return []
|
|
47
|
+
return [name for name in out.stdout.split('\0') if name]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def candidates(repo, declared: str) -> list[str]:
|
|
51
|
+
"""Tracked files the task does NOT declare, capped.
|
|
52
|
+
|
|
53
|
+
Sorted before truncation so the slice is deterministic: an unstable candidate set
|
|
54
|
+
would make the same plan warn differently on two runs.
|
|
55
|
+
"""
|
|
56
|
+
owned = set((declared or '').replace(',', ' ').split())
|
|
57
|
+
found = []
|
|
58
|
+
for name in sorted(_tracked(Path(repo))):
|
|
59
|
+
if name in owned:
|
|
60
|
+
continue
|
|
61
|
+
if name.startswith(_SKIP_PREFIXES) or name.endswith(_SKIP_SUFFIXES):
|
|
62
|
+
continue
|
|
63
|
+
if name.rsplit('/', 1)[-1] in _SKIP_NAMES:
|
|
64
|
+
continue
|
|
65
|
+
found.append(name)
|
|
66
|
+
if len(found) >= CANDIDATE_CAP:
|
|
67
|
+
break
|
|
68
|
+
return found
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def predict_drift(repo, *, goal: str, task_id: str, title: str, declared: str,
|
|
72
|
+
approach: str = '', prompt: str = '', run_dir=None,
|
|
73
|
+
config: dict | None = None):
|
|
74
|
+
"""Undeclared files this task looks likely to edit, highest probability first.
|
|
75
|
+
|
|
76
|
+
Returns [] when there is nothing to say and None when no judgment could be made, so
|
|
77
|
+
a caller can tell "no drift predicted" from "Jev did not answer".
|
|
78
|
+
"""
|
|
79
|
+
pool = candidates(repo, declared)
|
|
80
|
+
if not pool:
|
|
81
|
+
return []
|
|
82
|
+
questions = {f'f{i}': drift_prediction(name) for i, name in enumerate(pool)}
|
|
83
|
+
# Which files a task will really touch is stated in its requirements far more often
|
|
84
|
+
# than in its title -- "extend tests/test_events.py" is in the prompt, not the title.
|
|
85
|
+
state = dict(goal=goal, task=dict(id=task_id, title=title, declared_files=declared),
|
|
86
|
+
prompt=prompt, approach=approach)
|
|
87
|
+
result = ask(state, questions, site='jev-drift', run_dir=run_dir, config=config)
|
|
88
|
+
if result is None:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
# drift_warn reads gate_warn until a model has its own measured value: a cutoff fitted
|
|
92
|
+
# to one model's probabilities is not a cutoff on another's.
|
|
93
|
+
warn_at = threshold('drift_warn', config=config, model=result.get('model'))
|
|
94
|
+
predicted = []
|
|
95
|
+
for i, name in enumerate(pool):
|
|
96
|
+
probability = noul(result, f'f{i}')
|
|
97
|
+
if probability is not None and probability >= warn_at:
|
|
98
|
+
predicted.append((name, round(probability, 3)))
|
|
99
|
+
predicted.sort(key=lambda item: item[1], reverse=True)
|
|
100
|
+
return predicted
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Curation of what reaches `.forge/memory.md`.
|
|
2
|
+
|
|
3
|
+
The stakes are asymmetric, and that shapes every default here. `memory.md` is injected
|
|
4
|
+
into every dispatch of every future run, so a wrong fact is paid for forever and gets
|
|
5
|
+
corrected only when a human happens to read it. The ledger is append-only and never
|
|
6
|
+
injected, so **nothing recorded is ever lost** -- this gates injection, not recording.
|
|
7
|
+
|
|
8
|
+
That asymmetry is why each judgment below fails toward today's behaviour rather than
|
|
9
|
+
toward acting: an unreachable API, a missing key, a malformed answer and a disabled
|
|
10
|
+
capability all leave the model's own category and key exactly as they were.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from . import threshold
|
|
15
|
+
from .client import ask, choice, noul
|
|
16
|
+
from .questions import memory_category, memory_durable, memory_same_fact
|
|
17
|
+
|
|
18
|
+
CATEGORIES = ('verify', 'trap', 'finding')
|
|
19
|
+
|
|
20
|
+
# Enough context to match a paraphrase without turning one dispatch's bookkeeping into
|
|
21
|
+
# the largest request in the run.
|
|
22
|
+
MAX_EXISTING = 40
|
|
23
|
+
MAX_TEXT = 500
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def curate(*, category: str, text: str, existing=None, run_dir=None,
|
|
27
|
+
config: dict | None = None) -> dict | None:
|
|
28
|
+
"""Three judgments in one request: is it a duplicate, is it durable, is it filed right.
|
|
29
|
+
|
|
30
|
+
Returns None when nothing could be judged, which callers must treat as "keep what the
|
|
31
|
+
model said". The returned dict carries only *suggestions*; `key` is None unless a
|
|
32
|
+
paraphrase was matched confidently enough to merge.
|
|
33
|
+
"""
|
|
34
|
+
text = (text or '').strip()[:MAX_TEXT]
|
|
35
|
+
if not text:
|
|
36
|
+
return None
|
|
37
|
+
entries = [line for line in (existing or []) if line][:MAX_EXISTING]
|
|
38
|
+
|
|
39
|
+
questions = dict(durable=memory_durable(text), category=memory_category(text))
|
|
40
|
+
if entries:
|
|
41
|
+
# Choice needs at least two options; with no existing entries there is nothing
|
|
42
|
+
# to be a duplicate of, so the question is not worth asking.
|
|
43
|
+
questions['same'] = memory_same_fact(text, entries)
|
|
44
|
+
|
|
45
|
+
result = ask(dict(recorded=text, existing=entries), questions,
|
|
46
|
+
site='jev-memory-curate', run_dir=run_dir, config=config)
|
|
47
|
+
if result is None:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
durable = noul(result, 'durable')
|
|
51
|
+
suggested, category_confidence = choice(result, 'category')
|
|
52
|
+
matched, match_confidence = (choice(result, 'same') if entries else (None, None))
|
|
53
|
+
|
|
54
|
+
out = dict(durable=durable, category=category, key=None, matched=None,
|
|
55
|
+
suggested_category=suggested, category_confidence=category_confidence)
|
|
56
|
+
|
|
57
|
+
# A paraphrase only merges above the threshold. Merging two facts that merely look
|
|
58
|
+
# alike loses one of them permanently, while a duplicate is only untidy -- so the
|
|
59
|
+
# bar sits on the side of keeping both.
|
|
60
|
+
if (matched and matched != 'none' and match_confidence is not None
|
|
61
|
+
and match_confidence >= threshold('memory_merge', config=config)):
|
|
62
|
+
out['matched'] = matched
|
|
63
|
+
out['match_confidence'] = match_confidence
|
|
64
|
+
|
|
65
|
+
# Category is corrected only toward a real category. 'none' means the model could not
|
|
66
|
+
# place it, which is a reason to leave the recorded category alone, not to discard it.
|
|
67
|
+
if (suggested in CATEGORIES and suggested != category
|
|
68
|
+
and category_confidence is not None
|
|
69
|
+
and category_confidence >= threshold('memory_recategorize', config=config)):
|
|
70
|
+
out['category'] = suggested
|
|
71
|
+
|
|
72
|
+
# The quality gate. Silence is the safe direction: a rejected line still lands in the
|
|
73
|
+
# ledger in full, so the only thing lost is injection into future prompts.
|
|
74
|
+
out['injectable'] = not (durable is not None and durable < threshold('gate_warn', config=config))
|
|
75
|
+
|
|
76
|
+
# A line that merged onto an existing entry is exempt, and the two judgments have to
|
|
77
|
+
# be read together to see why. `rebuild` skips a non-injectable row before it counts
|
|
78
|
+
# anything, and a `finding` needs two DISTINCT runs before it is promoted -- which is
|
|
79
|
+
# the whole reason dedup exists, since two runs wording one fact differently would
|
|
80
|
+
# otherwise never reach two. So a restatement judged not durable is dropped from the
|
|
81
|
+
# count of the fact it restates, defeating dedup in exactly the case it was built for.
|
|
82
|
+
#
|
|
83
|
+
# Measured: paraphrases score 0.44-0.83 on durability against 0.82-0.83 for the same
|
|
84
|
+
# facts stated fresh -- a restatement reads as less durable than the thing it
|
|
85
|
+
# restates, which is the wrong question. The entry it matched is already in
|
|
86
|
+
# memory.md, so its durability was settled when that entry was written; a line
|
|
87
|
+
# matching it is by definition not the one-off observation this gate is for.
|
|
88
|
+
if out['matched']:
|
|
89
|
+
out['injectable'] = True
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# The number of facts a single dispatch is likely to act on. The existing 40-line / 4KB
|
|
94
|
+
# cap stays the outer bound -- this only narrows within it, and never adds a line.
|
|
95
|
+
SLICE_KEEP = 8
|
|
96
|
+
FILE_CHARS = 4000
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def still_true(*, text: str, anchor_path, run_dir=None, config: dict | None = None):
|
|
100
|
+
"""Has this fact gone false while the file it points at still exists?
|
|
101
|
+
|
|
102
|
+
Path-anchor staleness already drops an entry whose file was deleted. This catches
|
|
103
|
+
the other case: the file is still there and the fact about it stopped being true.
|
|
104
|
+
Returns None when nothing could be judged, which must be read as "keep the entry" --
|
|
105
|
+
dropping a fact on a failed request would quietly erase memory over time.
|
|
106
|
+
"""
|
|
107
|
+
from .questions import memory_still_true
|
|
108
|
+
from pathlib import Path
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
content = Path(anchor_path).read_text(errors='replace')[:FILE_CHARS]
|
|
112
|
+
except OSError:
|
|
113
|
+
return None
|
|
114
|
+
if not (text or '').strip():
|
|
115
|
+
return None
|
|
116
|
+
result = ask(dict(fact=text, file=str(anchor_path)),
|
|
117
|
+
dict(still=memory_still_true(text, content)),
|
|
118
|
+
site='jev-memory-stale', run_dir=run_dir, config=config)
|
|
119
|
+
if result is None:
|
|
120
|
+
return None
|
|
121
|
+
probability = noul(result, 'still')
|
|
122
|
+
if probability is None:
|
|
123
|
+
return None
|
|
124
|
+
# Its own threshold, well below gate_warn. Measured over six facts against real
|
|
125
|
+
# files: ones that had genuinely gone false scored 0.03-0.10, ones still true scored
|
|
126
|
+
# 0.47-0.96. gate_warn's 0.60 sits inside the true range and would have deleted a
|
|
127
|
+
# fact the file still supports. Dropping a fact is irreversible and silent, while
|
|
128
|
+
# keeping a stale one costs a line until someone notices, so the bar sits low enough
|
|
129
|
+
# that only a confident "this went false" acts.
|
|
130
|
+
return dict(probability=probability,
|
|
131
|
+
stale=probability < threshold('stale_drop', config=config))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def relevant_lines(*, task: str, lines: list, keep: int = SLICE_KEEP,
|
|
135
|
+
run_dir=None, config: dict | None = None):
|
|
136
|
+
"""The subset of memory worth spending prompt space on for THIS task.
|
|
137
|
+
|
|
138
|
+
Returns None when no judgment could be made, so the caller injects everything it
|
|
139
|
+
would have injected before. Never returns more lines than it was given, and never
|
|
140
|
+
reorders them -- the section structure above these lines is what gives them meaning.
|
|
141
|
+
"""
|
|
142
|
+
from .questions import memory_relevant
|
|
143
|
+
|
|
144
|
+
lines = [line for line in (lines or []) if line.strip()]
|
|
145
|
+
if len(lines) <= keep:
|
|
146
|
+
# Nothing to narrow, and asking would cost a request to learn that.
|
|
147
|
+
return None
|
|
148
|
+
questions = {f'l{i}': memory_relevant(line, task) for i, line in enumerate(lines)}
|
|
149
|
+
result = ask(dict(task=task, facts=lines), questions, site='jev-memory-slice',
|
|
150
|
+
run_dir=run_dir, config=config)
|
|
151
|
+
if result is None:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
scored = []
|
|
155
|
+
for i, line in enumerate(lines):
|
|
156
|
+
probability = noul(result, f'l{i}')
|
|
157
|
+
if probability is None:
|
|
158
|
+
# An unscored line is kept: absence of a judgment is not evidence against.
|
|
159
|
+
probability = 1.0
|
|
160
|
+
scored.append((probability, i, line))
|
|
161
|
+
scored.sort(key=lambda item: (-item[0], item[1]))
|
|
162
|
+
chosen = {index for _p, index, _line in scored[:keep]}
|
|
163
|
+
return [line for i, line in enumerate(lines) if i in chosen]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""The state forge's outcome rubrics (first_pass, retry_pass) are asked on.
|
|
2
|
+
|
|
3
|
+
One assembler for both sides of the measurement: the export rebuilds this state from
|
|
4
|
+
stored events, live forge builds it from a plan dir. Each side hands over evidence it
|
|
5
|
+
has already read; this only caps and arranges it, so the state a model is scored on and
|
|
6
|
+
the state it answers in production cannot drift apart in shape. Pure: no file reads.
|
|
7
|
+
|
|
8
|
+
The caps are the export's (`forge dataset export`), because those are the states
|
|
9
|
+
Clef was measured on: goal and prompt are NOT cut here, unlike score-plan's request.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
FILE_EXCERPT_CHARS = 600
|
|
14
|
+
MAX_EXCERPT_FILES = 6
|
|
15
|
+
MEMORY_CHARS = 4000
|
|
16
|
+
MAX_SIBLINGS = 40
|
|
17
|
+
SIBLING_TITLE_CHARS = 120
|
|
18
|
+
REVIEW_CHARS = 12000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def siblings(tasks, task_id: str) -> list[dict]:
|
|
22
|
+
"""The rest of the plan, in plan order: [{id, title, files, deps}] without `task_id`."""
|
|
23
|
+
out = []
|
|
24
|
+
for row in tasks:
|
|
25
|
+
if row.get('id') == task_id:
|
|
26
|
+
continue
|
|
27
|
+
out.append(dict(id=row.get('id'), title=str(row.get('title', ''))[:SIBLING_TITLE_CHARS],
|
|
28
|
+
files=row.get('files', ''), deps=row.get('deps', '')))
|
|
29
|
+
if len(out) >= MAX_SIBLINGS:
|
|
30
|
+
break
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def assemble(*, goal: str, task_id: str, title: str, files: str, deps: str, prompt: str, approach: str,
|
|
35
|
+
plan_tasks: list, file_excerpts: dict, memory: str, dwarf: dict, reviewer: dict, size: dict) -> dict:
|
|
36
|
+
"""The first-pass state: the task as planned, the plan around it, who runs and reviews it."""
|
|
37
|
+
# Values pass through as given: coercing a missing title to '' would change the body,
|
|
38
|
+
# and with it the case id, of every exported case that had none.
|
|
39
|
+
return dict(goal=goal, task=dict(id=task_id, title=title, files=files, deps=deps),
|
|
40
|
+
prompt=prompt, approach=approach, plan_tasks=plan_tasks,
|
|
41
|
+
file_excerpts=file_excerpts, memory=(memory or '')[:MEMORY_CHARS],
|
|
42
|
+
dwarf=dwarf, reviewer=reviewer, size=size)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def retry(state: dict, *, attempt: int, previous_review: str) -> dict:
|
|
46
|
+
"""The retry-pass state: the first-pass state plus which try this is and what it must fix."""
|
|
47
|
+
review = previous_review or ''
|
|
48
|
+
return dict(state, attempt=attempt, previous_review=review[:REVIEW_CHARS],
|
|
49
|
+
previous_review_truncated=len(review) > REVIEW_CHARS)
|