flozi00 commited on
Commit
e2e8afb
·
verified ·
1 Parent(s): a8fbc2f

zero-gpu: run LFM2.5-24B-A2B on the xlarge slice from a mounted volume

Browse files
BUILD.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
- "built_at": "2026-10-27T17:07:51",
3
- "commit": "173cb13",
4
- "files": 49,
5
  "with_store": false,
6
  "with_card": true
7
  }
 
1
  {
2
+ "built_at": "2026-10-29T13:31:34",
3
+ "commit": "c778551",
4
+ "files": 53,
5
  "with_store": false,
6
  "with_card": true
7
  }
README.md CHANGED
@@ -1,146 +1,153 @@
1
- ---
2
- title: traceforge
3
- emoji: 🧭
4
- colorFrom: indigo
5
- colorTo: green
6
- sdk: gradio
7
- sdk_version: 6.30.0
8
- app_file: app.py
9
- pinned: false
10
- license: other
11
- short_description: Turn real agent traces into ideal-world references
12
- ---
13
-
14
- # traceforge
15
-
16
- Turn a real agent trace into its **ideal-world reference**: the same work,
17
- with the dead ends removed, gated for structure and grounding.
18
-
19
- This Space is the portable face of the traceforge pipeline. The pipeline
20
- itself is unchanged — triage labels every message, a windowed rewriter
21
- produces the ideal path, and a judge admits only traces that pass the six
22
- pillars. The UI hides that machinery behind three actions.
23
-
24
- ## Submit a trace
25
-
26
- Upload a JSON/JSONL trace (traceforge native, OpenAI chat, or a bare message
27
- list) or paste it. The app computes a **content hash** of the seed and
28
- queues it.
29
-
30
- **One seed, one output.** The hash covers the conversation content — message
31
- text, tool calls and the set of tools the agent had — and excludes session
32
- ids, timestamps, cwd and tool-call ids. Submitting the same trace twice
33
- returns the existing output instead of creating a second one. That is what
34
- makes the output dataset a set, not a log.
35
-
36
- ## Sponsor a dataset
37
-
38
- Upload a JSONL of traces. Every row is hashed; new seeds are queued and
39
- attributed to the sponsor name you give, known seeds are skipped. Processed
40
- rows join the public output dataset. The dashboard shows the sponsor
41
- leaderboard.
42
-
43
- ## Dashboard
44
-
45
- Public statistics over this Space's corpus and, when bundled, the reference
46
- corpus: seeds, outputs, queue, failure rate, task mix, languages, runtime
47
- distribution, and **time sensitivity**.
48
-
49
- ## No API key? ZeroGPU runs the model for you
50
-
51
- The Space has two modes:
52
-
53
- - **Remote endpoint** — set `TRACEFORGE_BASE_URL` and `TRACEFORGE_LLM_KEY`
54
- in the Space settings. Fast, any trace size.
55
- - **Local ZeroGPU** — with no endpoint configured, the Space runs
56
- `Qwen/Qwen2.5-3B-Instruct` on Hugging Face ZeroGPU. The GPU is attached
57
- only while a call runs, and the time is charged to **your** HF account's
58
- daily ZeroGPU quota, not to the Space owner: 2 min/day anonymous, 5 min
59
- free account, 40 min PRO. Idle time costs nothing.
60
-
61
- Local mode is for small traces (up to 24 KB) — a 3B model on a 120 s GPU
62
- slice cannot rewrite a 300 KB trace inside a free daily quota. Larger
63
- traces need a remote endpoint.
64
-
65
- Local mode gates the rewrite structurally, for grounding, and for identity,
66
- then rejects a rewrite that is identical to the seed. The LLM judge is off
67
- there: a 3B judge invents violations and often omits `answer_quality`, so
68
- it adds noise rather than signal. A remote endpoint keeps the full judge.
69
-
70
- A seed that already reads as ideal comes back as **no change** rather than
71
- an output or a failure — the pipeline had nothing to edit.
72
-
73
- ## Time sensitivity
74
-
75
- A trace whose correct answer is a fact that changes over time is a bad
76
- training target when the agent had no way to look that fact up. Train on it
77
- and the model learns to *assert* the fact from its weights — "the current
78
- president is X" — which is wrong the moment X changes.
79
-
80
- The same question is a good target when the agent had a retrieval tool. The
81
- ideal path calls the tool and answers from its result, so what the model
82
- imitates is the procedure (search, read, answer), not the fact. The
83
- procedure does not decay.
84
-
85
- | class | meaning |
86
- |---|---|
87
- | `time_sensitive` | temporal fact, no retrieval tool — the answer decays |
88
- | `time_anchored` | temporal fact, but a retrieval tool can fetch it |
89
- | `not_time_sensitive` | no time-dependent fact in the task |
90
-
91
- `time_anchored` carries a sub-flag: whether the tool was actually **called**.
92
- A temporal task with a search tool available but never used is anchored in
93
- principle — the ideal rewrite should make the agent call it — but the raw
94
- trace answered from weights, so it needs a rewrite rather than a pass.
95
-
96
- The detector is deliberately conservative: a false `time_sensitive` throws
97
- away a good trace, so a pattern must name the **fact** (an officeholder, a
98
- price, a released version, the weather), not just a temporal word. "The
99
- current branch" and "the latest fix round" are local artifacts of the
100
- session, not decaying facts.
101
-
102
- ## Configuration
103
-
104
- Set these as Space secrets / variables:
105
-
106
- | variable | meaning |
107
- |---|---|
108
- | `TRACEFORGE_BASE_URL` | OpenAI-compatible endpoint (`…/v1`) |
109
- | `TRACEFORGE_LLM_KEY` | bearer token |
110
- | `TRACEFORGE_MODEL` | writer/judge model id |
111
- | `TRACEFORGE_TRIAGE_MODEL` | cheap labeler (default: `TRACEFORGE_MODEL`) |
112
- | `TRACEFORGE_JUDGE_MODEL` | judge override (default: `TRACEFORGE_MODEL`) |
113
- | `TRACEFORGE_DATA_DIR` | store root (default `./data/space`) |
114
- | `TRACEFORGE_SPACE_WORKERS` | parallel pipeline workers (default 1) |
115
-
116
- Without an endpoint the app still starts and the dashboard still works; the
117
- Submit tab reports that the pipeline is not configured instead of hanging.
118
-
119
- ## Storage
120
-
121
- The store is content-addressed on disk:
122
-
123
- ```
124
- data/space/index.jsonl append-only journal (last row per hash wins)
125
- data/space/seeds/<hash>.json the seed exactly as submitted
126
- data/space/outputs/<hash>.json the improved trace
127
- ```
128
-
129
- A Hugging Face Space's disk is ephemeral unless persistent storage is
130
- attached. For a durable public corpus, mount persistent storage at
131
- `/data` and set `TRACEFORGE_DATA_DIR=/data/space`, or sync the store to a
132
- dataset repo.
133
-
134
- ## Local use
135
-
136
- ```bash
137
- pip install -e ".[webui]"
138
- python app.py # http://127.0.0.1:7860
139
- traceforge space serve --port 7860
140
- traceforge space hash trace.json # print the seed hash
141
- traceforge space stats # store census
142
- ```
143
-
144
- ## License
145
-
146
- Private research corpus. All rights reserved.
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: traceforge
3
+ emoji: 🧭
4
+ colorFrom: indigo
5
+ colorTo: green
6
+ sdk: gradio
7
+ sdk_version: 6.30.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: other
11
+ short_description: Turn real agent traces into ideal-world references
12
+ ---
13
+
14
+ # traceforge
15
+
16
+ Turn a real agent trace into its **ideal-world reference**: the same work,
17
+ with the dead ends removed, gated for structure and grounding.
18
+
19
+ This Space is the portable face of the traceforge pipeline. The pipeline
20
+ itself is unchanged — triage labels every message, a windowed rewriter
21
+ produces the ideal path, and a judge admits only traces that pass the six
22
+ pillars. The UI hides that machinery behind three actions.
23
+
24
+ ## Submit a trace
25
+
26
+ Upload a JSON/JSONL trace (traceforge native, OpenAI chat, or a bare message
27
+ list) or paste it. The app computes a **content hash** of the seed and
28
+ queues it.
29
+
30
+ **One seed, one output.** The hash covers the conversation content — message
31
+ text, tool calls and the set of tools the agent had — and excludes session
32
+ ids, timestamps, cwd and tool-call ids. Submitting the same trace twice
33
+ returns the existing output instead of creating a second one. That is what
34
+ makes the output dataset a set, not a log.
35
+
36
+ ## Sponsor a dataset
37
+
38
+ Upload a JSONL of traces. Every row is hashed; new seeds are queued and
39
+ attributed to the sponsor name you give, known seeds are skipped. Processed
40
+ rows join the public output dataset. The dashboard shows the sponsor
41
+ leaderboard.
42
+
43
+ ## Dashboard
44
+
45
+ Public statistics over this Space's corpus and, when bundled, the reference
46
+ corpus: seeds, outputs, queue, failure rate, task mix, languages, runtime
47
+ distribution, and **time sensitivity**.
48
+
49
+ ## No API key? ZeroGPU runs the model for you
50
+
51
+ The Space has two modes:
52
+
53
+ - **Remote endpoint** — set `TRACEFORGE_BASE_URL` and `TRACEFORGE_LLM_KEY`
54
+ in the Space settings. Fast, any trace size.
55
+ - **Local ZeroGPU** — with no endpoint configured, the Space runs
56
+ `LiquidAI/LFM2.5-24B-A2B` (24B total, 2.45B active) on Hugging Face
57
+ ZeroGPU. The GPU is attached only while a call runs, and the time is
58
+ charged to **your** HF account's daily ZeroGPU quota, not to the Space
59
+ owner: 2 min/day anonymous, 5 min free account, 40 min PRO. Idle time
60
+ costs nothing.
61
+
62
+ The 24B checkpoint is 48 GB of bf16 weights, so local mode asks for the
63
+ 96 GB `xlarge` slice — the default 48 GB `large` slice is the weights and
64
+ nothing else — and xlarge spends **2x quota per second**. The weights are
65
+ also mounted as a read-only volume rather than downloaded, because 48 GB
66
+ does not fit the Space's 50 GB ephemeral disk.
67
+
68
+ Local mode is for small traces (up to 24 KB) — one 120 s slice cannot
69
+ rewrite a 300 KB trace inside a free daily quota. Larger traces need a
70
+ remote endpoint.
71
+
72
+ Local mode gates the rewrite structurally, for grounding, and for identity,
73
+ then rejects a rewrite that is identical to the seed. The LLM judge is off
74
+ there: it would double the quota cost per trace. A remote endpoint keeps
75
+ the full judge.
76
+
77
+ A seed that already reads as ideal comes back as **no change** rather than
78
+ an output or a failure — the pipeline had nothing to edit.
79
+
80
+ ## Time sensitivity
81
+
82
+ A trace whose correct answer is a fact that changes over time is a bad
83
+ training target when the agent had no way to look that fact up. Train on it
84
+ and the model learns to *assert* the fact from its weights — "the current
85
+ president is X" — which is wrong the moment X changes.
86
+
87
+ The same question is a good target when the agent had a retrieval tool. The
88
+ ideal path calls the tool and answers from its result, so what the model
89
+ imitates is the procedure (search, read, answer), not the fact. The
90
+ procedure does not decay.
91
+
92
+ | class | meaning |
93
+ |---|---|
94
+ | `time_sensitive` | temporal fact, no retrieval tool — the answer decays |
95
+ | `time_anchored` | temporal fact, but a retrieval tool can fetch it |
96
+ | `not_time_sensitive` | no time-dependent fact in the task |
97
+
98
+ `time_anchored` carries a sub-flag: whether the tool was actually **called**.
99
+ A temporal task with a search tool available but never used is anchored in
100
+ principle — the ideal rewrite should make the agent call it — but the raw
101
+ trace answered from weights, so it needs a rewrite rather than a pass.
102
+
103
+ The detector is deliberately conservative: a false `time_sensitive` throws
104
+ away a good trace, so a pattern must name the **fact** (an officeholder, a
105
+ price, a released version, the weather), not just a temporal word. "The
106
+ current branch" and "the latest fix round" are local artifacts of the
107
+ session, not decaying facts.
108
+
109
+ ## Configuration
110
+
111
+ Set these as Space secrets / variables:
112
+
113
+ | variable | meaning |
114
+ |---|---|
115
+ | `TRACEFORGE_BASE_URL` | OpenAI-compatible endpoint (`…/v1`) |
116
+ | `TRACEFORGE_LLM_KEY` | bearer token |
117
+ | `TRACEFORGE_MODEL` | writer/judge model id |
118
+ | `TRACEFORGE_TRIAGE_MODEL` | cheap labeler (default: `TRACEFORGE_MODEL`) |
119
+ | `TRACEFORGE_JUDGE_MODEL` | judge override (default: `TRACEFORGE_MODEL`) |
120
+ | `TRACEFORGE_DATA_DIR` | store root (default `./data/space`) |
121
+ | `TRACEFORGE_SPACE_WORKERS` | parallel pipeline workers (default 1) |
122
+
123
+ Without an endpoint the app still starts and the dashboard still works; the
124
+ Submit tab reports that the pipeline is not configured instead of hanging.
125
+
126
+ ## Storage
127
+
128
+ The store is content-addressed on disk:
129
+
130
+ ```
131
+ data/space/index.jsonl append-only journal (last row per hash wins)
132
+ data/space/seeds/<hash>.json the seed exactly as submitted
133
+ data/space/outputs/<hash>.json the improved trace
134
+ ```
135
+
136
+ A Hugging Face Space's disk is ephemeral unless persistent storage is
137
+ attached. For a durable public corpus, mount persistent storage at
138
+ `/data` and set `TRACEFORGE_DATA_DIR=/data/space`, or sync the store to a
139
+ dataset repo.
140
+
141
+ ## Local use
142
+
143
+ ```bash
144
+ pip install -e ".[webui]"
145
+ python app.py # http://127.0.0.1:7860
146
+ traceforge space serve --port 7860
147
+ traceforge space hash trace.json # print the seed hash
148
+ traceforge space stats # store census
149
+ ```
150
+
151
+ ## License
152
+
153
+ Private research corpus. All rights reserved.
data/dataset/dataset_card.json CHANGED
@@ -1,12 +1,12 @@
1
  {
2
- "traces": 3924,
3
  "languages": {
4
- "en": 3769,
5
- "de": 155
6
  },
7
  "language_pct": {
8
- "en": 96.0,
9
- "de": 4.0
10
  },
11
  "topic_language_matrix": {
12
  "languages": [
@@ -15,99 +15,99 @@
15
  ],
16
  "rows": {
17
  "review": {
18
- "en": 1202,
19
- "de": 63,
20
- "total": 1265
21
  },
22
  "implement": {
23
- "en": 657,
24
- "de": 39,
25
- "total": 696
26
  },
27
  "debug": {
28
- "en": 634,
29
- "de": 22,
30
- "total": 656
31
  },
32
  "research": {
33
- "en": 521,
34
- "de": 6,
35
- "total": 527
36
  },
37
  "other": {
38
- "en": 283,
39
- "de": 4,
40
- "total": 287
41
  },
42
  "test": {
43
- "en": 149,
44
- "de": 11,
45
- "total": 160
46
  },
47
  "ops": {
48
- "en": 105,
49
- "de": 2,
50
- "total": 107
51
- },
52
- "data-analysis": {
53
- "en": 83,
54
  "de": 4,
55
- "total": 87
56
  },
57
  "refactor": {
58
- "en": 76,
59
  "de": 3,
60
- "total": 79
 
 
 
 
 
61
  },
62
  "creative": {
63
- "en": 49,
64
  "de": 0,
65
- "total": 49
66
  },
67
  "office-docs": {
68
  "en": 10,
69
- "de": 1,
70
- "total": 11
71
  }
72
  },
73
  "totals": {
74
- "en": 3769,
75
- "de": 155,
76
- "total": 3924
77
  }
78
  },
79
  "tools_and_prompt": {
80
  "tools": {
81
- "rows": 3924,
82
- "with_tools": 3923,
83
- "pct": 100.0,
84
  "by_source": {
85
- "derived-from-observed-calls": 3509,
86
- "claude-request-shape": 414,
87
- "none": 1
88
  }
89
  },
90
  "system_prompt": {
91
- "rows": 3924,
92
- "with_system": 1726,
93
- "pct": 44.0,
94
  "by_source": {
95
- "none": 2198,
96
  "raw-system-row": 663,
97
- "claude-request-shape": 414,
98
- "codex-base-instructions": 355,
99
  "in-trace": 292,
100
- "hermes-system-prompts": 2
101
  }
102
  }
103
  },
104
  "provenance": {
105
  "basis": "session-id prefix (the improver overwrites meta.source)",
106
  "counts": {
107
- "real-session": 1723,
108
- "subagent": 737,
109
  "nemotron-agentic-ka": 581,
110
- "split-segment": 509,
111
  "nemotron-agentic-tc": 370,
112
  "nemotron-swe-3c63eaaa": 1,
113
  "nemotron-swe-6ca796dd": 1,
@@ -115,11 +115,11 @@
115
  "nemotron-swe-e8ff278b": 1
116
  },
117
  "pct": {
118
- "real-session": 43.9,
119
- "subagent": 18.8,
120
- "nemotron-agentic-ka": 14.8,
121
- "split-segment": 13.0,
122
- "nemotron-agentic-tc": 9.4,
123
  "nemotron-swe-3c63eaaa": 0.0,
124
  "nemotron-swe-6ca796dd": 0.0,
125
  "nemotron-swe-bc9ee6b7": 0.0,
@@ -127,105 +127,105 @@
127
  }
128
  },
129
  "agent_roles": {
130
- "orchestrator": 3187,
131
- "subagent": 737
132
  },
133
  "tasks": {
134
- "review": 1265,
135
- "implement": 696,
136
- "debug": 656,
137
- "research": 527,
138
- "other": 287,
139
- "test": 160,
140
- "ops": 107,
141
- "data-analysis": 87,
142
- "refactor": 79,
143
- "creative": 49,
144
- "office-docs": 11
145
  },
146
  "messages": {
147
- "total": 130671,
148
  "by_role": {
149
- "system": 1726,
150
- "user": 4345,
151
- "assistant": 63391,
152
- "tool": 61209
153
  },
154
  "per_trace": {
155
- "mean": 33.3,
156
- "median": 22.0,
157
- "p90": 71,
158
- "max": 252
159
  }
160
  },
161
  "tool_calls": {
162
- "total": 61209,
163
  "per_trace": {
164
- "mean": 15.6,
165
- "median": 10.0,
166
- "p90": 34,
167
- "max": 125
168
  },
169
- "traces_with_none": 1,
170
- "distinct_tools": 553,
171
  "top_tools": {
172
- "Bash": 22977,
173
- "exec": 11517,
174
- "terminal": 3660,
175
- "read_file": 2835,
176
- "Read": 2673,
177
- "Edit": 2210,
178
- "search_files": 1716,
179
- "execute_code": 1581,
180
- "tool_call": 1511,
181
- "Grep": 939,
182
- "skill_view": 861,
183
- "patch": 738,
 
184
  "unlock_discoverable_agent_tool": 457,
185
- "call_discoverable_agent_tool": 457,
186
- "wait": 411
187
  }
188
  },
189
  "user_turns": {
190
  "per_trace": {
191
  "mean": 1.1,
192
- "median": 1.0,
193
  "p90": 1,
194
  "max": 18
195
  },
196
- "single_turn_traces": 3617,
197
- "multi_turn_traces": 307
198
  },
199
  "tokens": {
200
  "method": "chars/4 (prose proxy); scale by ~1.15 for a central estimate",
201
  "budget": 131072,
202
- "chars_total": 318237136,
203
- "est_total": 79559284,
204
  "per_trace": {
205
- "mean": 20274.7,
206
- "median": 16510.5,
207
- "p90": 46090,
208
  "max": 121769
209
  },
210
  "fits_budget": {
211
  "4096": {
212
- "traces": 1026,
213
- "pct": 26.1
214
  },
215
  "8192": {
216
- "traces": 1264,
217
- "pct": 32.2
218
  },
219
  "16384": {
220
- "traces": 1950,
221
- "pct": 49.7
222
  },
223
  "65536": {
224
- "traces": 3875,
225
- "pct": 98.8
226
  },
227
  "131072": {
228
- "traces": 3924,
229
  "pct": 100.0
230
  }
231
  }
@@ -234,69 +234,69 @@
234
  "supervision": {
235
  "contract": "loss on assistant spans only; tool/user/system spans are masked context",
236
  "chars_by_role": {
237
- "system": 12435083,
238
- "user": 5887077,
239
- "assistant": 12168420,
240
- "tool": 287746556
241
  },
242
- "assistant_share_pct": 3.8,
243
- "tool_share_pct": 90.4
244
  },
245
  "difficulty": {
246
  "basis": "tool calls in the ideal path (proxy, not a score)",
247
  "bands": {
248
  "D1": {
249
  "label": "one-shot / single edit",
250
- "traces": 111,
251
- "pct": 2.8
252
  },
253
  "D2": {
254
  "label": "short loop",
255
- "traces": 680,
256
- "pct": 17.3
257
  },
258
  "D3": {
259
  "label": "multi-step",
260
- "traces": 1253,
261
- "pct": 31.9
262
  },
263
  "D4": {
264
  "label": "long session",
265
- "traces": 857,
266
- "pct": 21.8
267
  },
268
  "D5": {
269
  "label": "very long session",
270
- "traces": 1023,
271
- "pct": 26.1
272
  }
273
  },
274
  "tool_calls": {
275
- "mean": 15.6,
276
- "median": 10.0,
277
- "p90": 34,
278
- "max": 125
279
  },
280
  "messages": {
281
- "mean": 33.3,
282
- "median": 22.0,
283
- "p90": 71,
284
- "max": 252
285
  },
286
  "est_tokens": {
287
- "mean": 20274.7,
288
- "median": 16510.5,
289
- "p90": 46090,
290
  "max": 121769
291
  }
292
  },
293
  "safety_filter": {
294
  "applied": true,
295
- "traces_in": 4155,
296
- "traces_kept": 3924,
297
- "traces_excluded": 231,
298
- "excluded_secret": 207,
299
- "excluded_personal": 24,
300
  "rule": "drop any trace carrying credential material (passwords, tokens, private keys, connection strings with real credentials) or third-party personal data (private legal correspondence, home addresses, phone numbers, IBANs). Code, internal hostnames, business contact addresses and the owner's own identity stay.",
301
  "report": "safety_report.json"
302
  },
@@ -304,7 +304,7 @@
304
  "classes": {
305
  "time_sensitive": 13,
306
  "time_anchored": 12,
307
- "not_time_sensitive": 3899
308
  },
309
  "labels": {
310
  "time_sensitive": "answer decays: temporal fact, no retrieval tool",
 
1
  {
2
+ "traces": 4563,
3
  "languages": {
4
+ "en": 4370,
5
+ "de": 193
6
  },
7
  "language_pct": {
8
+ "en": 95.8,
9
+ "de": 4.2
10
  },
11
  "topic_language_matrix": {
12
  "languages": [
 
15
  ],
16
  "rows": {
17
  "review": {
18
+ "en": 1399,
19
+ "de": 80,
20
+ "total": 1479
21
  },
22
  "implement": {
23
+ "en": 876,
24
+ "de": 43,
25
+ "total": 919
26
  },
27
  "debug": {
28
+ "en": 720,
29
+ "de": 26,
30
+ "total": 746
31
  },
32
  "research": {
33
+ "en": 552,
34
+ "de": 7,
35
+ "total": 559
36
  },
37
  "other": {
38
+ "en": 291,
39
+ "de": 6,
40
+ "total": 297
41
  },
42
  "test": {
43
+ "en": 174,
44
+ "de": 15,
45
+ "total": 189
46
  },
47
  "ops": {
48
+ "en": 108,
 
 
 
 
 
49
  "de": 4,
50
+ "total": 112
51
  },
52
  "refactor": {
53
+ "en": 99,
54
  "de": 3,
55
+ "total": 102
56
+ },
57
+ "data-analysis": {
58
+ "en": 90,
59
+ "de": 6,
60
+ "total": 96
61
  },
62
  "creative": {
63
+ "en": 51,
64
  "de": 0,
65
+ "total": 51
66
  },
67
  "office-docs": {
68
  "en": 10,
69
+ "de": 3,
70
+ "total": 13
71
  }
72
  },
73
  "totals": {
74
+ "en": 4370,
75
+ "de": 193,
76
+ "total": 4563
77
  }
78
  },
79
  "tools_and_prompt": {
80
  "tools": {
81
+ "rows": 4563,
82
+ "with_tools": 4557,
83
+ "pct": 99.9,
84
  "by_source": {
85
+ "derived-from-observed-calls": 4098,
86
+ "claude-request-shape": 459,
87
+ "none": 6
88
  }
89
  },
90
  "system_prompt": {
91
+ "rows": 4563,
92
+ "with_system": 1826,
93
+ "pct": 40.0,
94
  "by_source": {
95
+ "none": 2737,
96
  "raw-system-row": 663,
97
+ "claude-request-shape": 459,
98
+ "codex-base-instructions": 408,
99
  "in-trace": 292,
100
+ "hermes-system-prompts": 4
101
  }
102
  }
103
  },
104
  "provenance": {
105
  "basis": "session-id prefix (the improver overwrites meta.source)",
106
  "counts": {
107
+ "real-session": 2187,
108
+ "subagent": 913,
109
  "nemotron-agentic-ka": 581,
110
+ "split-segment": 508,
111
  "nemotron-agentic-tc": 370,
112
  "nemotron-swe-3c63eaaa": 1,
113
  "nemotron-swe-6ca796dd": 1,
 
115
  "nemotron-swe-e8ff278b": 1
116
  },
117
  "pct": {
118
+ "real-session": 47.9,
119
+ "subagent": 20.0,
120
+ "nemotron-agentic-ka": 12.7,
121
+ "split-segment": 11.1,
122
+ "nemotron-agentic-tc": 8.1,
123
  "nemotron-swe-3c63eaaa": 0.0,
124
  "nemotron-swe-6ca796dd": 0.0,
125
  "nemotron-swe-bc9ee6b7": 0.0,
 
127
  }
128
  },
129
  "agent_roles": {
130
+ "orchestrator": 3650,
131
+ "subagent": 913
132
  },
133
  "tasks": {
134
+ "review": 1479,
135
+ "implement": 919,
136
+ "debug": 746,
137
+ "research": 559,
138
+ "other": 297,
139
+ "test": 189,
140
+ "ops": 112,
141
+ "refactor": 102,
142
+ "data-analysis": 96,
143
+ "creative": 51,
144
+ "office-docs": 13
145
  },
146
  "messages": {
147
+ "total": 152211,
148
  "by_role": {
149
+ "user": 5045,
150
+ "assistant": 73803,
151
+ "tool": 71537,
152
+ "system": 1826
153
  },
154
  "per_trace": {
155
+ "mean": 33.4,
156
+ "median": 23,
157
+ "p90": 68,
158
+ "max": 274
159
  }
160
  },
161
  "tool_calls": {
162
+ "total": 71537,
163
  "per_trace": {
164
+ "mean": 15.7,
165
+ "median": 10,
166
+ "p90": 33,
167
+ "max": 143
168
  },
169
+ "traces_with_none": 6,
170
+ "distinct_tools": 556,
171
  "top_tools": {
172
+ "Bash": 26609,
173
+ "exec": 13574,
174
+ "terminal": 4730,
175
+ "read_file": 3641,
176
+ "Read": 3012,
177
+ "Edit": 2493,
178
+ "search_files": 2247,
179
+ "execute_code": 1970,
180
+ "tool_call": 1679,
181
+ "Grep": 1007,
182
+ "skill_view": 966,
183
+ "patch": 875,
184
+ "wait": 489,
185
  "unlock_discoverable_agent_tool": 457,
186
+ "call_discoverable_agent_tool": 457
 
187
  }
188
  },
189
  "user_turns": {
190
  "per_trace": {
191
  "mean": 1.1,
192
+ "median": 1,
193
  "p90": 1,
194
  "max": 18
195
  },
196
+ "single_turn_traces": 4211,
197
+ "multi_turn_traces": 352
198
  },
199
  "tokens": {
200
  "method": "chars/4 (prose proxy); scale by ~1.15 for a central estimate",
201
  "budget": 131072,
202
+ "chars_total": 374416612,
203
+ "est_total": 93604153,
204
  "per_trace": {
205
+ "mean": 20513.4,
206
+ "median": 16365,
207
+ "p90": 46390,
208
  "max": 121769
209
  },
210
  "fits_budget": {
211
  "4096": {
212
+ "traces": 1139,
213
+ "pct": 25.0
214
  },
215
  "8192": {
216
+ "traces": 1463,
217
+ "pct": 32.1
218
  },
219
  "16384": {
220
+ "traces": 2282,
221
+ "pct": 50.0
222
  },
223
  "65536": {
224
+ "traces": 4514,
225
+ "pct": 98.9
226
  },
227
  "131072": {
228
+ "traces": 4563,
229
  "pct": 100.0
230
  }
231
  }
 
234
  "supervision": {
235
  "contract": "loss on assistant spans only; tool/user/system spans are masked context",
236
  "chars_by_role": {
237
+ "user": 7445197,
238
+ "assistant": 13944527,
239
+ "tool": 339128629,
240
+ "system": 13898259
241
  },
242
+ "assistant_share_pct": 3.7,
243
+ "tool_share_pct": 90.6
244
  },
245
  "difficulty": {
246
  "basis": "tool calls in the ideal path (proxy, not a score)",
247
  "bands": {
248
  "D1": {
249
  "label": "one-shot / single edit",
250
+ "traces": 136,
251
+ "pct": 3.0
252
  },
253
  "D2": {
254
  "label": "short loop",
255
+ "traces": 786,
256
+ "pct": 17.2
257
  },
258
  "D3": {
259
  "label": "multi-step",
260
+ "traces": 1384,
261
+ "pct": 30.3
262
  },
263
  "D4": {
264
  "label": "long session",
265
+ "traces": 1057,
266
+ "pct": 23.2
267
  },
268
  "D5": {
269
  "label": "very long session",
270
+ "traces": 1200,
271
+ "pct": 26.3
272
  }
273
  },
274
  "tool_calls": {
275
+ "mean": 15.7,
276
+ "median": 10,
277
+ "p90": 33,
278
+ "max": 143
279
  },
280
  "messages": {
281
+ "mean": 33.4,
282
+ "median": 23,
283
+ "p90": 68,
284
+ "max": 274
285
  },
286
  "est_tokens": {
287
+ "mean": 20513.4,
288
+ "median": 16365,
289
+ "p90": 46390,
290
  "max": 121769
291
  }
292
  },
293
  "safety_filter": {
294
  "applied": true,
295
+ "traces_in": 4831,
296
+ "traces_kept": 4563,
297
+ "traces_excluded": 268,
298
+ "excluded_secret": 239,
299
+ "excluded_personal": 29,
300
  "rule": "drop any trace carrying credential material (passwords, tokens, private keys, connection strings with real credentials) or third-party personal data (private legal correspondence, home addresses, phone numbers, IBANs). Code, internal hostnames, business contact addresses and the owner's own identity stay.",
301
  "report": "safety_report.json"
302
  },
 
304
  "classes": {
305
  "time_sensitive": 13,
306
  "time_anchored": 12,
307
+ "not_time_sensitive": 4538
308
  },
309
  "labels": {
310
  "time_sensitive": "answer decays: temporal fact, no retrieval tool",
requirements.txt CHANGED
@@ -1,6 +1,6 @@
1
- gradio>=6.0
2
- pyyaml>=6
3
- huggingface_hub>=0.24
4
- spaces>=0.2
5
- transformers>=4.44
6
- accelerate>=0.33
 
1
+ gradio>=6.0
2
+ pyyaml>=6
3
+ huggingface_hub>=0.24
4
+ spaces>=0.2
5
+ transformers>=5.0
6
+ accelerate>=0.33
traceforge/balance.py ADDED
@@ -0,0 +1,833 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Corpus balance: the target mix, the measured mix, and the gap plan.
2
+
3
+ The seed pool is not a sample of the deployment distribution. It is whatever
4
+ the owner's harnesses happened to record, and that shape is biased in three
5
+ ways that matter for training:
6
+
7
+ language ~94% English. German is 5.9% of Common Crawl against English's
8
+ 45.9%, and the base models saw even less (LLaMA-2: 0.17% de),
9
+ so a corpus that mirrors the web under-serves German twice over.
10
+ task `review` is a third of the corpus because review subagents are
11
+ cheap to spawn, not because a third of real work is read-only.
12
+ difficulty long sessions dominate; the short end is thin.
13
+
14
+ This module makes the target explicit and measurable:
15
+
16
+ policy the target shares per axis (config/balance.yaml, editable)
17
+ census the measured counts per axis and per (language, task) cell
18
+ report actual vs target, per axis and joint, plus a balance score
19
+ plan the cells to GENERATE, largest deficit first
20
+ select a deterministic downsample of over-target cells, so the shipped
21
+ mix can be balanced today without waiting for generation
22
+
23
+ The joint target is the product of the marginals: target(de, debug) =
24
+ P(de) * P(debug). That is the independence assumption, and it is deliberate —
25
+ it forces German coverage into every task bucket instead of letting a 30%
26
+ German share be 30% review.
27
+
28
+ Nothing here calls a model or touches the network. It reads exported rows
29
+ (``{"messages", "meta"}``) or ``Trace`` objects and returns plain dicts, so
30
+ the CLI, the dashboard and the tests all share one implementation.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import collections
35
+ import hashlib
36
+ import json
37
+ import os
38
+ from dataclasses import dataclass, field
39
+ from typing import Any, Iterable, Optional
40
+
41
+ from traceforge.classify import TASKS, detect_language
42
+ from traceforge.difficulty import BANDS, band, count_calls
43
+
44
+ # --------------------------------------------------------------- the policy
45
+
46
+ # Measured 2026-10-28 on the 4,463-trace export, and the evidence behind each
47
+ # number is in docs/BALANCE.md. Short version:
48
+ # language Kew et al. 2024 (Findings EMNLP) find 12.5-18.75% non-English
49
+ # sufficient to elicit cross-lingual generalisation and IO
50
+ # language agreement; 30% is comfortably above that band and
51
+ # ~5x the web-natural German share, which compensates for the
52
+ # pretraining deficit. The owner's stated acceptable split.
53
+ # task coding stays the majority (56%) but `review` drops from 33% to
54
+ # 10%: it is read-only, so it teaches inspection and not action,
55
+ # and its current share is a sampling artifact of review
56
+ # subagents. Non-coding buckets get real coverage because the
57
+ # proof harness showed coverage gaps surface as regressions.
58
+ # difficulty a fat middle with a long-horizon tail: D1 is near-useless
59
+ # (OpenThoughts-Agent: filtering <5 turns is the single largest
60
+ # data win), D4+D5 stay at 48% because long-horizon is where
61
+ # agents actually fail.
62
+ DEFAULT_POLICY: dict[str, Any] = {
63
+ "language": {"en": 0.70, "de": 0.30},
64
+ "task": {
65
+ "debug": 0.16,
66
+ "implement": 0.16,
67
+ "test": 0.08,
68
+ "refactor": 0.06,
69
+ "review": 0.10,
70
+ "research": 0.12,
71
+ "data-analysis": 0.09,
72
+ "ops": 0.08,
73
+ "office-docs": 0.06,
74
+ "creative": 0.04,
75
+ "other": 0.05,
76
+ },
77
+ "difficulty": {"D1": 0.02, "D2": 0.18, "D3": 0.32, "D4": 0.28, "D5": 0.20},
78
+ # Harness: the three harnesses the owner actually deploys into, balanced
79
+ # against each other. Measured 2026-10-29: claude 32.5%, codex 24.9%,
80
+ # hermes 17.4%, nemotron 19.5%. A corpus that is a third claude is not
81
+ # "mixed" — it teaches one harness's tool vocabulary and error strings.
82
+ # nemotron keeps a 1% residual: it is real agentic data, but it is not a
83
+ # deployment target, so it must not be a fifth of the corpus.
84
+ "harness": {
85
+ "claude-code": 0.32,
86
+ "codex": 0.32,
87
+ "hermes": 0.32,
88
+ "nemotron": 0.02,
89
+ "other": 0.02,
90
+ },
91
+ # A share within this many points of target counts as on-target. 0.02 is
92
+ # ~90 traces at the current corpus size: tight enough to be a goal, loose
93
+ # enough that a single generation wave does not flip the verdict.
94
+ "tolerance": 0.02,
95
+ }
96
+
97
+ POLICY_PATH = os.path.join("config", "balance.yaml")
98
+
99
+ # Languages the corpus targets. Kept next to the policy so adding one is a
100
+ # one-line change here plus a share in config/balance.yaml, not a hunt through
101
+ # the validator.
102
+ LANGUAGES: tuple[str, ...] = ("en", "de")
103
+
104
+ # Harness families. The raw `source` values are per-READER ("claude-sidechain",
105
+ # "nemotron-agentic-ka", "tui"), which is too fine to balance against: what
106
+ # matters for robustness is the harness the model will actually be dropped
107
+ # into. Measured 2026-10-29 on 4,890 promoted traces: codex 24.9%,
108
+ # claude-sidechain 19.1%, claude-code 13.4%, nemotron-ka 11.9%, tui 9.1%,
109
+ # desktop 8.3%, nemotron-tc 7.6%, no-raw 5.5% — i.e. one harness family
110
+ # (claude) at 32.5% and hermes at 17.4%, so a corpus that looks "mixed" is
111
+ # really claude-heavy.
112
+ HARNESSES: tuple[str, ...] = ("claude-code", "codex", "hermes", "nemotron", "other")
113
+
114
+ _HARNESS_OF_SOURCE: dict[str, str] = {
115
+ "claude-code": "claude-code",
116
+ "claude-sidechain": "claude-code",
117
+ "codex": "codex",
118
+ "tui": "hermes",
119
+ "desktop": "hermes",
120
+ "hermes": "hermes",
121
+ "nemotron-agentic-ka": "nemotron",
122
+ "nemotron-agentic-tc": "nemotron",
123
+ "nemotron-swe": "nemotron",
124
+ "nemotron": "nemotron",
125
+ }
126
+
127
+
128
+ def harness_of(source: Optional[str]) -> str:
129
+ """Map a raw `source` value onto a harness family.
130
+
131
+ Unknown values land in "other" rather than being dropped: a new reader
132
+ must show up in the report as an imbalance, not vanish from it.
133
+ """
134
+ return _HARNESS_OF_SOURCE.get((source or "").strip().lower(), "other")
135
+
136
+
137
+ @dataclass
138
+ class BalancePolicy:
139
+ """Target shares per axis. Shares must sum to 1.0 per axis."""
140
+
141
+ language: dict[str, float] = field(default_factory=dict)
142
+ task: dict[str, float] = field(default_factory=dict)
143
+ difficulty: dict[str, float] = field(default_factory=dict)
144
+ harness: dict[str, float] = field(default_factory=dict)
145
+ tolerance: float = 0.02
146
+ source: str = "builtin"
147
+
148
+ @classmethod
149
+ def load(cls, path: Optional[str] = None) -> "BalancePolicy":
150
+ """Load a YAML policy, falling back to the built-in defaults.
151
+
152
+ A missing file is not an error: the defaults ARE the policy until the
153
+ owner edits one. A malformed file IS an error — silently training
154
+ against a typo'd target is worse than refusing to start.
155
+ """
156
+ if not path:
157
+ return cls.from_dict(DEFAULT_POLICY, source="builtin")
158
+ if not os.path.exists(path):
159
+ return cls.from_dict(DEFAULT_POLICY, source="builtin")
160
+ import yaml # optional dependency, same as Config.load
161
+
162
+ with open(path, "r", encoding="utf-8") as f:
163
+ data = yaml.safe_load(f) or {}
164
+ return cls.from_dict(data, source=path)
165
+
166
+ @classmethod
167
+ def from_dict(cls, data: dict[str, Any], source: str = "dict") -> "BalancePolicy":
168
+ unknown_axes = set(data) - {"language", "task", "difficulty", "harness",
169
+ "tolerance"}
170
+ if unknown_axes:
171
+ # A typo'd axis name ("tasks:") would otherwise be ignored and the
172
+ # built-in default silently used — training against a target the
173
+ # owner never wrote is worse than refusing to start.
174
+ raise ValueError(
175
+ f"balance policy: unknown axis {sorted(unknown_axes)}; "
176
+ f"expected language / task / difficulty / harness / tolerance"
177
+ )
178
+ merged = {
179
+ "language": dict(DEFAULT_POLICY["language"]),
180
+ "task": dict(DEFAULT_POLICY["task"]),
181
+ "difficulty": dict(DEFAULT_POLICY["difficulty"]),
182
+ "harness": dict(DEFAULT_POLICY["harness"]),
183
+ "tolerance": DEFAULT_POLICY["tolerance"],
184
+ }
185
+ for key in ("language", "task", "difficulty", "harness"):
186
+ if key in data and data[key]:
187
+ merged[key] = {str(k): float(v) for k, v in data[key].items()}
188
+ if "tolerance" in data:
189
+ merged["tolerance"] = float(data["tolerance"])
190
+ pol = cls(
191
+ language=merged["language"],
192
+ task=merged["task"],
193
+ difficulty=merged["difficulty"],
194
+ harness=merged["harness"],
195
+ tolerance=merged["tolerance"],
196
+ source=source,
197
+ )
198
+ pol.validate()
199
+ return pol
200
+
201
+ def validate(self) -> None:
202
+ for axis, shares in (
203
+ ("language", self.language),
204
+ ("task", self.task),
205
+ ("difficulty", self.difficulty),
206
+ ("harness", self.harness),
207
+ ):
208
+ if not shares:
209
+ raise ValueError(f"balance policy: empty {axis} axis")
210
+ negative = {k: v for k, v in shares.items() if v < 0}
211
+ if negative:
212
+ # {"en": 1.2, "de": -0.2} sums to 1.0 and would otherwise pass,
213
+ # producing a negative target that silently disables the cell.
214
+ raise ValueError(
215
+ f"balance policy: negative {axis} share(s) {negative}"
216
+ )
217
+ total = sum(shares.values())
218
+ if abs(total - 1.0) > 0.01:
219
+ raise ValueError(
220
+ f"balance policy: {axis} shares sum to {total:.3f}, "
221
+ f"expected 1.0"
222
+ )
223
+ unknown = set(shares) - set(_known_keys(axis))
224
+ if unknown:
225
+ raise ValueError(
226
+ f"balance policy: unknown {axis} key(s) {sorted(unknown)}; "
227
+ f"known: {sorted(_known_keys(axis))}"
228
+ )
229
+
230
+ def to_dict(self) -> dict[str, Any]:
231
+ return {
232
+ "language": dict(self.language),
233
+ "task": dict(self.task),
234
+ "difficulty": dict(self.difficulty),
235
+ "harness": dict(self.harness),
236
+ "tolerance": self.tolerance,
237
+ "source": self.source,
238
+ }
239
+
240
+ def joint_target(self, language: str, task: str) -> float:
241
+ """Target share of one (language, task) cell: the product of marginals."""
242
+ return self.language.get(language, 0.0) * self.task.get(task, 0.0)
243
+
244
+
245
+ def _known_keys(axis: str) -> tuple[str, ...]:
246
+ if axis == "language":
247
+ return LANGUAGES
248
+ if axis == "task":
249
+ return tuple(TASKS)
250
+ if axis == "harness":
251
+ return HARNESSES
252
+ return tuple(code for code, _, _, _ in BANDS)
253
+
254
+
255
+ # ------------------------------------------------------------------ census
256
+
257
+
258
+ def _record_fields(rec: Any, labels: Optional[dict[str, str]] = None):
259
+ """(session_id, language, task, difficulty, harness) for one record.
260
+
261
+ Accepts an exported row (``{"messages", "meta"}``) or a ``Trace``. The
262
+ language comes from ``meta.language`` when the exporter wrote it and from
263
+ the first user row otherwise — the same detector the P5 gate uses, so the
264
+ balance report and the gate cannot disagree about what a German trace is.
265
+
266
+ The harness comes from ``meta.harness`` (written by the exporter from
267
+ ``Trace.harness``), falling back to ``meta.source`` for rows exported
268
+ before that field existed. A promoted trace's ``source`` is
269
+ "traceforge-splice", which is why the fallback alone is not enough.
270
+ """
271
+ if hasattr(rec, "to_dict"):
272
+ rec = rec.to_dict()
273
+ meta = rec.get("meta") or {}
274
+ msgs = rec.get("messages") or []
275
+ # Two shapes reach this function: an exported row ({"messages", "meta"})
276
+ # and Trace.to_dict() (flat: session_id / task at the top level). Reading
277
+ # only "meta" silently put every Trace in the "unknown" cell, which made
278
+ # the export cap drop the whole corpus — so accept both.
279
+ sid = (meta.get("traceforge_session_id") or meta.get("session_id")
280
+ or rec.get("session_id") or "")
281
+ lang = meta.get("language") or rec.get("language")
282
+ if not lang:
283
+ first_user = next(
284
+ (m.get("content") or "" for m in msgs if m.get("role") == "user"), ""
285
+ )
286
+ lang = detect_language(first_user)
287
+ task = labels.get(sid) if labels else None
288
+ if not task:
289
+ task = meta.get("task") or rec.get("task")
290
+ task = task or "unknown"
291
+ diff = band(count_calls(rec))
292
+ harness = harness_of(
293
+ meta.get("harness") or rec.get("harness")
294
+ or meta.get("source") or rec.get("source"))
295
+ return sid, lang, task, diff, harness
296
+
297
+
298
+ def load_labels(path: str) -> dict[str, str]:
299
+ """sid -> task from ``data/tasks.jsonl`` (last row per sid wins)."""
300
+ labels: dict[str, str] = {}
301
+ if not os.path.exists(path):
302
+ return labels
303
+ with open(path, "r", encoding="utf-8") as f:
304
+ for line in f:
305
+ line = line.strip()
306
+ if not line:
307
+ continue
308
+ try:
309
+ row = json.loads(line)
310
+ except json.JSONDecodeError:
311
+ continue
312
+ sid = row.get("session_id")
313
+ if sid and row.get("task"):
314
+ labels[sid] = row["task"]
315
+ return labels
316
+
317
+
318
+ def census(
319
+ records: Iterable[Any], labels: Optional[dict[str, str]] = None
320
+ ) -> dict[str, Any]:
321
+ """Count records per axis and per (language, task) / (language, difficulty)."""
322
+ lang: collections.Counter = collections.Counter()
323
+ task: collections.Counter = collections.Counter()
324
+ diff: collections.Counter = collections.Counter()
325
+ harn: collections.Counter = collections.Counter()
326
+ lt: collections.Counter = collections.Counter()
327
+ ld: collections.Counter = collections.Counter()
328
+ td: collections.Counter = collections.Counter()
329
+ n = 0
330
+ for rec in records:
331
+ _, lg, tk, d, hs = _record_fields(rec, labels)
332
+ n += 1
333
+ lang[lg] += 1
334
+ task[tk] += 1
335
+ diff[d] += 1
336
+ harn[hs] += 1
337
+ lt[f"{lg}|{tk}"] += 1
338
+ ld[f"{lg}|{d}"] += 1
339
+ td[f"{tk}|{d}"] += 1
340
+ return {
341
+ "n": n,
342
+ "language": dict(lang),
343
+ "task": dict(task),
344
+ "difficulty": dict(diff),
345
+ "harness": dict(harn),
346
+ "language_task": dict(lt),
347
+ "language_difficulty": dict(ld),
348
+ "task_difficulty": dict(td),
349
+ "unlabeled": int(task.get("unknown", 0)),
350
+ }
351
+
352
+
353
+ # ------------------------------------------------------------------ report
354
+
355
+
356
+ def _axis_rows(
357
+ counts: dict[str, int], targets: dict[str, float], n: int, tolerance: float
358
+ ) -> list[dict[str, Any]]:
359
+ """One row per key: actual, target, delta, verdict."""
360
+ keys = list(targets) + [k for k in counts if k not in targets]
361
+ rows = []
362
+ for key in keys:
363
+ actual_n = int(counts.get(key, 0))
364
+ share = actual_n / n if n else 0.0
365
+ target = float(targets.get(key, 0.0))
366
+ target_n = target * n
367
+ delta = actual_n - target_n
368
+ if target == 0.0:
369
+ status = "extra" if actual_n else "ok"
370
+ elif abs(share - target) <= tolerance:
371
+ status = "ok"
372
+ elif share < target:
373
+ status = "under"
374
+ else:
375
+ status = "over"
376
+ rows.append({
377
+ "key": key,
378
+ "n": actual_n,
379
+ "share": round(share, 4),
380
+ "target": round(target, 4),
381
+ "target_n": round(target_n, 1),
382
+ "delta_n": round(delta, 1),
383
+ "status": status,
384
+ })
385
+ rows.sort(key=lambda r: (-r["target"], r["key"]))
386
+ return rows
387
+
388
+
389
+ def _tvd(rows: list[dict[str, Any]]) -> float:
390
+ """Total variation distance between actual and target shares."""
391
+ return round(0.5 * sum(abs(r["share"] - r["target"]) for r in rows), 4)
392
+
393
+
394
+ def report(census_data: dict[str, Any], policy: BalancePolicy) -> dict[str, Any]:
395
+ """Actual vs target per axis and joint, plus a single balance score.
396
+
397
+ ``balance_score`` = 1 - TVD over the (language, task) grid, so 1.0 is a
398
+ perfectly balanced corpus and 0.0 is maximally skewed. Unlabeled traces
399
+ are excluded from the score (they are a labeling defect, not a mix
400
+ property) and reported separately.
401
+ """
402
+ n = int(census_data.get("n") or 0)
403
+ tol = policy.tolerance
404
+ axes = {
405
+ "language": _axis_rows(census_data.get("language", {}),
406
+ policy.language, n, tol),
407
+ "task": _axis_rows(census_data.get("task", {}),
408
+ policy.task, n, tol),
409
+ "difficulty": _axis_rows(census_data.get("difficulty", {}),
410
+ policy.difficulty, n, tol),
411
+ "harness": _axis_rows(census_data.get("harness", {}),
412
+ policy.harness, n, tol),
413
+ }
414
+ # joint grid: every (language, task) cell the policy names, plus any
415
+ # observed cell the policy does not (reported as "extra", target 0)
416
+ lt_counts = census_data.get("language_task", {})
417
+ joint = []
418
+ for lg, p_lg in policy.language.items():
419
+ for tk, p_tk in policy.task.items():
420
+ key = f"{lg}|{tk}"
421
+ actual_n = int(lt_counts.get(key, 0))
422
+ target = p_lg * p_tk
423
+ share = actual_n / n if n else 0.0
424
+ delta = actual_n - target * n
425
+ if abs(share - target) <= tol:
426
+ status = "ok"
427
+ elif share < target:
428
+ status = "under"
429
+ else:
430
+ status = "over"
431
+ joint.append({
432
+ "language": lg, "task": tk, "key": key,
433
+ "n": actual_n, "share": round(share, 4),
434
+ "target": round(target, 4),
435
+ "target_n": round(target * n, 1),
436
+ "delta_n": round(delta, 1), "status": status,
437
+ })
438
+ for key, actual_n in lt_counts.items():
439
+ lg, _, tk = key.partition("|")
440
+ if lg in policy.language and tk in policy.task:
441
+ continue
442
+ joint.append({
443
+ "language": lg, "task": tk, "key": key, "n": int(actual_n),
444
+ "share": round(actual_n / n, 4) if n else 0.0,
445
+ "target": 0.0, "target_n": 0.0, "delta_n": float(actual_n),
446
+ "status": "extra",
447
+ })
448
+ joint.sort(key=lambda r: (-r["target"], r["language"], r["task"]))
449
+ scored = [r for r in joint if r["target"] > 0]
450
+ return {
451
+ "n": n,
452
+ "policy_source": policy.source,
453
+ "tolerance": tol,
454
+ "balance_score": round(1.0 - _tvd(scored), 4),
455
+ "axes": {axis: {"tvd": _tvd(rows), "rows": rows}
456
+ for axis, rows in axes.items()},
457
+ "joint": {"tvd": _tvd(scored), "rows": joint},
458
+ "unlabeled": int(census_data.get("unlabeled") or 0),
459
+ }
460
+
461
+
462
+ # -------------------------------------------------------------------- plan
463
+
464
+
465
+ def plan(
466
+ report_data: dict[str, Any],
467
+ policy: BalancePolicy,
468
+ limit: Optional[int] = None,
469
+ min_need: float = 1.0,
470
+ ) -> list[dict[str, Any]]:
471
+ """The generation work order: under-target cells, largest deficit first.
472
+
473
+ Each row is one (language, task) cell with the number of traces needed to
474
+ reach its target at the CURRENT corpus size, plus the difficulty mix to
475
+ draw from. Cells already at or over target are omitted — generating more
476
+ of an over-represented cell makes the corpus worse, not better.
477
+ """
478
+ n = int(report_data.get("n") or 0)
479
+ diff_rows = report_data["axes"]["difficulty"]["rows"]
480
+ # the difficulty deficit, normalised: how much of the missing mass each
481
+ # band should absorb. A band already over target contributes 0.
482
+ deficit = {r["key"]: max(0.0, r["target_n"] - r["n"]) for r in diff_rows}
483
+ total_deficit = sum(deficit.values())
484
+ diff_mix = ({k: round(v / total_deficit, 4) for k, v in deficit.items()}
485
+ if total_deficit > 0 else dict(policy.difficulty))
486
+ rows = []
487
+ for cell in report_data["joint"]["rows"]:
488
+ if cell["status"] != "under":
489
+ continue
490
+ need = cell["target_n"] - cell["n"]
491
+ if need < min_need:
492
+ continue
493
+ rows.append({
494
+ "language": cell["language"],
495
+ "task": cell["task"],
496
+ "need": int(round(need)),
497
+ "have": cell["n"],
498
+ "target_n": cell["target_n"],
499
+ "share": cell["share"],
500
+ "target": cell["target"],
501
+ "difficulty_mix": diff_mix,
502
+ })
503
+ rows.sort(key=lambda r: (-r["need"], r["language"], r["task"]))
504
+ if limit:
505
+ rows = rows[:limit]
506
+ return rows
507
+
508
+
509
+ def plan_total(rows: list[dict[str, Any]]) -> int:
510
+ return int(sum(r["need"] for r in rows))
511
+
512
+
513
+ def converge(
514
+ census_data: dict[str, Any],
515
+ policy: BalancePolicy,
516
+ ) -> dict[str, Any]:
517
+ """The smallest corpus that is balanced by GENERATION ALONE.
518
+
519
+ Adding traces can never fix an over-target cell — it only dilutes it. So
520
+ the generation-only fixed point is the size at which the most
521
+ over-represented cell finally falls to its target share:
522
+
523
+ N* = max over cells of have(cell) / target(cell)
524
+
525
+ That number is usually brutal (a cell at 33% against a 10% target needs
526
+ the corpus to grow 3.3x), which is the argument for capping the
527
+ over-target cells instead — see ``fill_to``.
528
+ """
529
+ n = int(census_data.get("n") or 0)
530
+ lt = census_data.get("language_task") or {}
531
+ worst = 0.0
532
+ binding = None
533
+ for key, have in lt.items():
534
+ lg, _, tk = key.partition("|")
535
+ target = policy.joint_target(lg, tk)
536
+ if target <= 0 or not have:
537
+ continue
538
+ need_n = have / target
539
+ if need_n > worst:
540
+ worst, binding = need_n, key
541
+ grow_to = int(-(-worst // 1)) # ceil
542
+ return {
543
+ "grow_to": grow_to,
544
+ "added": max(0, grow_to - n),
545
+ "binding_cell": binding,
546
+ "binding_share": round(lt.get(binding, 0) / n, 4) if binding and n else 0.0,
547
+ "binding_target": round(policy.joint_target(*binding.split("|")), 4)
548
+ if binding else 0.0,
549
+ }
550
+
551
+
552
+ def fill_to(
553
+ census_data: dict[str, Any],
554
+ policy: BalancePolicy,
555
+ target_n: Optional[int] = None,
556
+ ) -> dict[str, Any]:
557
+ """At a chosen final size: what to DROP and what to GENERATE.
558
+
559
+ This is the planner the pipeline actually uses, because it is the only
560
+ strategy that reaches the target at a sane corpus size: cap the
561
+ over-target cells (cheap, immediate, deterministic) and generate the
562
+ under-target ones (expensive, targeted). ``target_n`` defaults to the
563
+ current size, i.e. "balance what we have".
564
+ """
565
+ n = int(census_data.get("n") or 0)
566
+ final_n = int(target_n or n)
567
+ lt = census_data.get("language_task") or {}
568
+ drops: dict[str, int] = {}
569
+ adds: dict[str, int] = {}
570
+ for lg, p_lg in policy.language.items():
571
+ for tk, p_tk in policy.task.items():
572
+ key = f"{lg}|{tk}"
573
+ have = int(lt.get(key, 0))
574
+ target = p_lg * p_tk * final_n
575
+ if have > target + 0.5:
576
+ drops[key] = int(round(have - target))
577
+ elif have < target - 0.5:
578
+ adds[key] = int(round(target - have))
579
+ return {
580
+ "final_n": final_n,
581
+ "drop": dict(sorted(drops.items(), key=lambda kv: -kv[1])),
582
+ "drop_total": sum(drops.values()),
583
+ "add": dict(sorted(adds.items(), key=lambda kv: -kv[1])),
584
+ "add_total": sum(adds.values()),
585
+ "add_by_language": {
586
+ lg: sum(v for k, v in adds.items() if k.startswith(lg + "|"))
587
+ for lg in policy.language
588
+ },
589
+ }
590
+
591
+
592
+ # ---------------------------------------------------------------- selection
593
+
594
+
595
+ def _harness_caps(census_data: dict[str, Any], policy: BalancePolicy) -> dict[str, int]:
596
+ """How many records to drop from each over-target harness family.
597
+
598
+ Same arithmetic as ``fill_to`` but on the harness axis alone: an
599
+ over-target family is cut to its target share of the CURRENT corpus size,
600
+ an under-target family keeps everything. Dropping cannot fix an
601
+ under-target family — that is generation's job, and the report shows the
602
+ residual gap so the pull can be pointed at it.
603
+ """
604
+ n = int(census_data.get("n") or 0)
605
+ counts = census_data.get("harness", {}) or {}
606
+ caps: dict[str, int] = {}
607
+ for hs, share in policy.harness.items():
608
+ if hs == "other":
609
+ # "other" is unknown provenance, not a harness: a trace lands
610
+ # there when its raw file is gone or a reader is unmapped. Capping
611
+ # it would silently delete promoted traces over a bookkeeping
612
+ # defect, so it is reported (loudly, see `report`) and never cut.
613
+ continue
614
+ have = int(counts.get(hs, 0))
615
+ target = share * n
616
+ if have > target + 0.5:
617
+ caps[hs] = int(round(have - target))
618
+ return caps
619
+
620
+
621
+ def _stable_hash(text: str) -> str:
622
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
623
+
624
+
625
+ BALANCE_AXES: tuple[str, ...] = ("language", "task", "harness")
626
+
627
+
628
+ def select_balanced(
629
+ records: list[Any],
630
+ policy: BalancePolicy,
631
+ labels: Optional[dict[str, str]] = None,
632
+ axes: Optional[tuple[str, ...]] = None,
633
+ ) -> tuple[list[Any], dict[str, Any]]:
634
+ """Deterministically downsample over-target cells to hit the target mix.
635
+
636
+ Generation is the expensive half of rebalancing; capping the
637
+ over-represented cells is the cheap half, and it makes the SHIPPED mix
638
+ correct today. Selection inside a cell is by a stable hash of the session
639
+ id, so re-running after the corpus grows keeps prior choices stable and
640
+ never biases toward size, date or source.
641
+
642
+ Dropping alone can never fix an UNDER-target cell — it only shrinks the
643
+ corpus around it — so this uses the same cap as ``fill_to``: over-target
644
+ cells are cut to their target share of the current size, under-target
645
+ cells keep everything. The residual gap is what generation has to fill,
646
+ and the report shows it.
647
+
648
+ ``axes`` selects which caps to apply. The default applies all of them;
649
+ ``("harness",)`` applies only the harness cap, which is what you want when
650
+ the harness mix is the problem and the language/task mix is not — the
651
+ language/task caps are far more aggressive (they would drop 41% of the
652
+ current corpus) and applying them as a side effect of a harness fix would
653
+ be a surprise.
654
+
655
+ Returns (kept, stats). ``stats["dropped"]`` is per-cell, so a caller can
656
+ see exactly what the cap cost.
657
+ """
658
+ axes = tuple(axes) if axes else BALANCE_AXES
659
+ bad = set(axes) - set(BALANCE_AXES)
660
+ if bad:
661
+ raise ValueError(f"unknown balance axis {sorted(bad)}; "
662
+ f"expected {list(BALANCE_AXES)}")
663
+ cells: dict[str, list[Any]] = collections.defaultdict(list)
664
+ by_harness: dict[str, list[Any]] = collections.defaultdict(list)
665
+ keys: dict[int, str] = {}
666
+ for rec in records:
667
+ sid, lg, tk, _, hs = _record_fields(rec, labels)
668
+ cells[f"{lg}|{tk}"].append(rec)
669
+ by_harness[hs].append(rec)
670
+ keys[id(rec)] = sid or _stable_hash(json.dumps(rec, sort_keys=True)[:512])
671
+
672
+ # One cap, computed once. An earlier version re-normalised the target to
673
+ # the KEPT total each round, which shrinks the corpus geometrically and
674
+ # converges to zero (round(0.5) == 0) — it deleted the whole dataset.
675
+ cen = census(records, labels)
676
+ caps = fill_to(cen, policy)["drop"] if "language" in axes or "task" in axes else {}
677
+ # Harness is a second, independent cap: a record has to survive BOTH the
678
+ # (language, task) cap and its harness cap. Applying them as an
679
+ # intersection rather than one after the other keeps the arithmetic
680
+ # honest — sequential caps would re-count the already-dropped records and
681
+ # over-cut the corpus.
682
+ hcaps = _harness_caps(cen, policy) if "harness" in axes else {}
683
+
684
+ dropped_ids: set[int] = set()
685
+ dropped: dict[str, int] = {}
686
+ for key, items in cells.items():
687
+ cap = int(caps.get(key, 0))
688
+ if cap <= 0:
689
+ continue
690
+ ordered = sorted(items, key=lambda r: _stable_hash(keys[id(r)]))
691
+ for rec in ordered[max(0, len(items) - cap):]:
692
+ dropped_ids.add(id(rec))
693
+ dropped[key] = min(cap, len(items))
694
+ for hs, items in by_harness.items():
695
+ cap = int(hcaps.get(hs, 0))
696
+ if cap <= 0:
697
+ continue
698
+ ordered = sorted(items, key=lambda r: _stable_hash(keys[id(r)]))
699
+ for rec in ordered[max(0, len(items) - cap):]:
700
+ dropped_ids.add(id(rec))
701
+ dropped[f"harness:{hs}"] = min(cap, len(items))
702
+
703
+ kept = [rec for rec in records if id(rec) not in dropped_ids]
704
+ stats = {
705
+ "in": len(records),
706
+ "out": len(kept),
707
+ "dropped": dict(sorted(dropped.items(), key=lambda kv: -kv[1])),
708
+ "dropped_total": len(dropped_ids),
709
+ }
710
+ return kept, stats
711
+
712
+
713
+ # ------------------------------------------------------------------ output
714
+
715
+
716
+ def format_report(report_data: dict[str, Any], top: int = 0) -> str:
717
+ """Human-readable table for the CLI and the dashboard log."""
718
+ n = report_data["n"]
719
+ lines = [
720
+ f"corpus balance · n={n} · score {report_data['balance_score']:.3f} "
721
+ f"(1.0 = on target) · policy {report_data['policy_source']}",
722
+ ]
723
+ if report_data.get("unlabeled"):
724
+ lines.append(f" ! {report_data['unlabeled']} trace(s) carry no task "
725
+ f"label (excluded from the score)")
726
+ # A corpus with no harness provenance reports 100% "other" and the axis
727
+ # silently does nothing. Say so instead of printing a table that looks
728
+ # like a measurement.
729
+ hrows = report_data["axes"]["harness"]["rows"]
730
+ other = next((r for r in hrows if r["key"] == "other"), None)
731
+ if other and other["share"] > 0.10:
732
+ lines.append(
733
+ f" ! {other['n']} trace(s) ({other['share']*100:.1f}%) have no "
734
+ f"harness provenance — re-export, or run "
735
+ f"scripts/backfill_harness.py; the harness axis cannot balance "
736
+ f"what it cannot see"
737
+ )
738
+ for axis in ("language", "task", "difficulty", "harness"):
739
+ block = report_data["axes"][axis]
740
+ lines.append(f"\n {axis} (TVD {block['tvd']:.3f})")
741
+ rows = block["rows"]
742
+ if top:
743
+ rows = rows[:top]
744
+ for r in rows:
745
+ bar = _bar(r["share"], r["target"])
746
+ lines.append(
747
+ f" {r['key']:<14s} {r['n']:>6d} {r['share']*100:5.1f}% "
748
+ f"target {r['target']*100:5.1f}% {r['delta_n']:+8.0f} "
749
+ f"{bar} {r['status']}"
750
+ )
751
+ under = [r for r in report_data["joint"]["rows"] if r["status"] == "under"]
752
+ over = [r for r in report_data["joint"]["rows"] if r["status"] == "over"]
753
+ lines.append(
754
+ f"\n joint (language x task): {len(under)} cell(s) under target, "
755
+ f"{len(over)} over · TVD {report_data['joint']['tvd']:.3f}"
756
+ )
757
+ return "\n".join(lines)
758
+
759
+
760
+ def format_plan(
761
+ rows: list[dict[str, Any]],
762
+ limit: int = 20,
763
+ fill: Optional[dict[str, Any]] = None,
764
+ grow: Optional[dict[str, Any]] = None,
765
+ ) -> str:
766
+ if not rows:
767
+ return " plan: nothing to generate — every cell is at or over target"
768
+ total = plan_total(rows)
769
+ lines = [f" plan: {total} trace(s) across {len(rows)} cell(s) in the next "
770
+ f"wave, largest deficit first"]
771
+ if fill:
772
+ by_lang = ", ".join(f"{k} {v}" for k, v in fill["add_by_language"].items())
773
+ lines.append(
774
+ f" cap+fill at n={fill['final_n']}: drop {fill['drop_total']} "
775
+ f"over-target trace(s), generate {fill['add_total']} ({by_lang})"
776
+ )
777
+ if grow:
778
+ lines.append(
779
+ f" generation-only alternative: grow to n≈{grow['grow_to']} "
780
+ f"(+{grow['added']}), bound by {grow['binding_cell']} at "
781
+ f"{grow['binding_share']*100:.1f}% against a "
782
+ f"{grow['binding_target']*100:.1f}% target"
783
+ )
784
+ for r in rows[:limit]:
785
+ lines.append(
786
+ f" {r['language']}/{r['task']:<14s} need {r['need']:>5d} "
787
+ f"(have {r['have']}, target {r['target_n']:.0f})"
788
+ )
789
+ if len(rows) > limit:
790
+ lines.append(f" … {len(rows) - limit} more cell(s)")
791
+ return "\n".join(lines)
792
+
793
+
794
+ def _bar(share: float, target: float, width: int = 12) -> str:
795
+ """A tiny actual-vs-target bar: '#' actual, '.' target, '|' the target mark."""
796
+ if target <= 0:
797
+ return " " * width
798
+ scale = max(share, target)
799
+ a = int(round(width * share / scale)) if scale else 0
800
+ t = int(round(width * target / scale)) if scale else 0
801
+ cells = ["."] * width
802
+ for i in range(min(a, width)):
803
+ cells[i] = "#"
804
+ if 0 <= t < width:
805
+ cells[t] = "|"
806
+ return "".join(cells)
807
+
808
+
809
+ def write_report(
810
+ report_data: dict[str, Any],
811
+ path: str,
812
+ extra: Optional[dict[str, Any]] = None,
813
+ ) -> None:
814
+ """Persist the report for the dashboard (which must not import traceforge).
815
+
816
+ ``extra`` carries the plan / fill / grow blocks, so the dashboard can show
817
+ the work order without recomputing anything.
818
+ """
819
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
820
+ payload = dict(report_data)
821
+ if extra:
822
+ payload.update(extra)
823
+ payload["generated_at"] = _now()
824
+ tmp = path + ".tmp"
825
+ with open(tmp, "w", encoding="utf-8") as f:
826
+ json.dump(payload, f, indent=1, ensure_ascii=False)
827
+ os.replace(tmp, path)
828
+
829
+
830
+ def _now() -> str:
831
+ import datetime
832
+
833
+ return datetime.datetime.now().astimezone().isoformat(timespec="seconds")
traceforge/cli.py CHANGED
The diff for this file is too large to render. See raw diff
 
traceforge/config.py CHANGED
@@ -53,6 +53,12 @@ class Config:
53
  tool_result_chars: int = 1200
54
  tool_args_chars: int = 600
55
  include_reasoning: bool = False
 
 
 
 
 
 
56
 
57
  # ------------------------------------------------------ improver harness
58
  model: Optional[str] = None # None = hermes default profile model
@@ -135,6 +141,12 @@ class Config:
135
  rewrite_max_tokens: int = 8000 # per-window rewrite emit ceiling
136
  rewrite_timeout_s: int = 300 # per rewrite HTTP call
137
  judge_model: str = "" # empty = same as cfg.model
 
 
 
 
 
 
138
  judge_max_tokens: int = 3000
139
  judge_timeout_s: int = 300
140
  # The final-window judge must report answer_quality; a missing value
@@ -148,6 +160,17 @@ class Config:
148
  judge_enabled: bool = True
149
  allow_task_reframe: bool = True # intent-only FAIL + good answer ->
150
  # rewrite the task message once
 
 
 
 
 
 
 
 
 
 
 
151
 
152
  # --------------------------------------------------------------- extras
153
  raw_cache: bool = True # cache normalized raw traces under data/traces/raw
@@ -196,6 +219,11 @@ class Config:
196
  # separate candidate traces (MASS arXiv 2610.12176: multi-agent
197
  # trajectories are the densest training signal; see export._agent_role).
198
  claude_sidechains: bool = True
 
 
 
 
 
199
 
200
  def resolve(self, repo_root: str) -> "Config":
201
  self.state_db = self.state_db or os.path.join(self.hermes_home, "state.db")
@@ -221,6 +249,12 @@ class Config:
221
  seed_claude = os.path.join(repo_root, "harness-traces", "claude")
222
  if os.path.isdir(seed_claude):
223
  _add_unique(self.claude_projects_dirs, seed_claude)
 
 
 
 
 
 
224
  seed_codex = os.path.join(repo_root, "harness-traces", "codex")
225
  if os.path.isdir(seed_codex):
226
  _add_unique(self.codex_roots, seed_codex)
 
53
  tool_result_chars: int = 1200
54
  tool_args_chars: int = 600
55
  include_reasoning: bool = False
56
+ # Export the reasoning channel into the training JSONL. The rewriter now
57
+ # WRITES reasoning (the source harnesses only record summaries), so the
58
+ # channel is worth keeping — but it is a separate decision from whether
59
+ # the rewriter SEES the source reasoning (include_reasoning, still off:
60
+ # the source text is a duplicated header or a timeline summary).
61
+ export_reasoning: bool = True
62
 
63
  # ------------------------------------------------------ improver harness
64
  model: Optional[str] = None # None = hermes default profile model
 
141
  rewrite_max_tokens: int = 8000 # per-window rewrite emit ceiling
142
  rewrite_timeout_s: int = 300 # per rewrite HTTP call
143
  judge_model: str = "" # empty = same as cfg.model
144
+ # Rotating judge pool: when two or more ids are listed, every trace is
145
+ # judged by TWO of them (adjacent pairs, chosen deterministically from
146
+ # the session id) and passes only if both pass. Different families
147
+ # catch each other's blind spots, and the pool spreads the judge load
148
+ # across separate deployments instead of queueing behind the writer.
149
+ judge_models: list = field(default_factory=list)
150
  judge_max_tokens: int = 3000
151
  judge_timeout_s: int = 300
152
  # The final-window judge must report answer_quality; a missing value
 
160
  judge_enabled: bool = True
161
  allow_task_reframe: bool = True # intent-only FAIL + good answer ->
162
  # rewrite the task message once
163
+ # Dead-end distillation (2026-10-28, owner directive): a long session
164
+ # often tries several approaches, watches them fail, and lands on a
165
+ # different solution. The ideal rewrite routes around the failures (P2),
166
+ # so the negative knowledge dies with them. This stage compresses the
167
+ # dead ends into the final answer's reasoning channel: the promoted trace
168
+ # stays a clean one-shot solution and its reasoning carries "don't try X,
169
+ # it fails because Y". Grounded against the source's own tool results;
170
+ # an ungrounded block is dropped, never repaired.
171
+ distill: bool = True
172
+ distill_min_dead_ends: int = 2 # distinct failed calls to qualify
173
+ distill_max_chars: int = 1600 # block budget (reasoning cap is 1200)
174
 
175
  # --------------------------------------------------------------- extras
176
  raw_cache: bool = True # cache normalized raw traces under data/traces/raw
 
219
  # separate candidate traces (MASS arXiv 2610.12176: multi-agent
220
  # trajectories are the densest training signal; see export._agent_role).
221
  claude_sidechains: bool = True
222
+ # Where `traceforge synth run` materialises task workspaces. Empty =
223
+ # ~/traceforge-synth/ws, deliberately OUTSIDE the repo: the executor runs
224
+ # an agent with --yolo there, and a workspace under data/ would let a
225
+ # confused agent walk into the corpus.
226
+ synth_ws_root: str = ""
227
 
228
  def resolve(self, repo_root: str) -> "Config":
229
  self.state_db = self.state_db or os.path.join(self.hermes_home, "state.db")
 
249
  seed_claude = os.path.join(repo_root, "harness-traces", "claude")
250
  if os.path.isdir(seed_claude):
251
  _add_unique(self.claude_projects_dirs, seed_claude)
252
+ # Public session dumps imported by scripts/import_hf_traces.py.
253
+ # Kept in its own dir so the provenance of a seed is visible from
254
+ # its path: local sessions under claude/, community ones here.
255
+ seed_claude_hf = os.path.join(repo_root, "harness-traces", "claude-hf")
256
+ if os.path.isdir(seed_claude_hf):
257
+ _add_unique(self.claude_projects_dirs, seed_claude_hf)
258
  seed_codex = os.path.join(repo_root, "harness-traces", "codex")
259
  if os.path.isdir(seed_codex):
260
  _add_unique(self.codex_roots, seed_codex)
traceforge/defects.py CHANGED
@@ -38,6 +38,11 @@ DEFECTS: dict[str, tuple[str, str, str, str]] = {
38
  "D-TASK-05": ("harness_brief", "init",
39
  "the only opening user message is a harness dispatch brief",
40
  "unwrap the brief to the real task, or drop"),
 
 
 
 
 
41
  "D-TASK-02": ("reframe_failed", "init",
42
  "the task could not be reframed into a single intent",
43
  "usually several tasks in one session; split it"),
@@ -114,6 +119,16 @@ _RULES: list[tuple[str, re.Pattern[str]]] = [
114
  r"|infra error|failed for every window|rate limit"
115
  r"|\bHTTP (?:5\d\d|429)\b", re.I)),
116
  ("D-TASK-05", re.compile(r"harness dispatch brief|dispatch brief", re.I)),
 
 
 
 
 
 
 
 
 
 
117
  ("D-TASK-04", re.compile(r"no user task message|no user task", re.I)),
118
  ("D-TASK-02", re.compile(r"reframe", re.I)),
119
  ("D-VER-02", re.compile(r"fabricated tool result|shares no content"
@@ -122,7 +137,14 @@ _RULES: list[tuple[str, re.Pattern[str]]] = [
122
  ("D-FMT-03", re.compile(r"final_blob|final answer is a (?:file )?dump", re.I)),
123
  ("D-FMT-04", re.compile(r"self_talk|self-talk", re.I)),
124
  ("D-FMT-05", re.compile(r"judge_residue|judge residue", re.I)),
125
- ("D-FMT-06", re.compile(r"clip_marker|truncated", re.I)),
 
 
 
 
 
 
 
126
  ("D-SAFE-03", re.compile(r"identity_burn|co-authored-by", re.I)),
127
  ("D-REP-01", re.compile(r"max repair rounds|repair budget", re.I)),
128
  ("D-REP-03", re.compile(r"no writer artifact|writer produced nothing", re.I)),
 
38
  "D-TASK-05": ("harness_brief", "init",
39
  "the only opening user message is a harness dispatch brief",
40
  "unwrap the brief to the real task, or drop"),
41
+ "D-TASK-06": ("runtime_notice_task", "init",
42
+ "the opening user row is a harness runtime/process notice, "
43
+ "not a user request",
44
+ "the splice/reframe path picked a mid-session notification "
45
+ "as the task; re-attempt from the real task row"),
46
  "D-TASK-02": ("reframe_failed", "init",
47
  "the task could not be reframed into a single intent",
48
  "usually several tasks in one session; split it"),
 
119
  r"|infra error|failed for every window|rate limit"
120
  r"|\bHTTP (?:5\d\d|429)\b", re.I)),
121
  ("D-TASK-05", re.compile(r"harness dispatch brief|dispatch brief", re.I)),
122
+ # 2026-10-28: `splice left no user message` (3 records) matched no rule
123
+ # and landed in D-UNK-01. The raw had a task; the splice dropped the
124
+ # user row, so it is a structural/format loss, not a taskless trace.
125
+ ("D-FMT-01", re.compile(r"splice left no user message", re.I)),
126
+ # 2026-10-29: 9 of 4,747 promoted traces opened with a harness notice
127
+ # ("[System: The active model ... changed", "[IMPORTANT: Background
128
+ # process proc_... completed") as the task row; a 4,000-raw sample found
129
+ # none at the source, so the splice/reframe path fabricated the task.
130
+ # validate_structural now rejects it; this code keeps the census honest.
131
+ ("D-TASK-06", re.compile(r"runtime/process notice", re.I)),
132
  ("D-TASK-04", re.compile(r"no user task message|no user task", re.I)),
133
  ("D-TASK-02", re.compile(r"reframe", re.I)),
134
  ("D-VER-02", re.compile(r"fabricated tool result|shares no content"
 
137
  ("D-FMT-03", re.compile(r"final_blob|final answer is a (?:file )?dump", re.I)),
138
  ("D-FMT-04", re.compile(r"self_talk|self-talk", re.I)),
139
  ("D-FMT-05", re.compile(r"judge_residue|judge residue", re.I)),
140
+ # 2026-10-28: the bare `truncated` alternative was a CO-OCCURRENCE
141
+ # matcher, not a cause. Judge violation text says "truncated" constantly
142
+ # ("final answer is truncated mid-sentence", "tool output is truncated"),
143
+ # and D-FMT-06 is checked before D-REP-01, so 30 of 5,678 records whose
144
+ # only error was `max repair rounds exceeded` were coded clip_marker and
145
+ # the repair-exhausted share was understated. Match the clip marker
146
+ # itself, which is what the gate emits.
147
+ ("D-FMT-06", re.compile(r"clip_marker|chars clipped|\[TRUNC", re.I)),
148
  ("D-SAFE-03", re.compile(r"identity_burn|co-authored-by", re.I)),
149
  ("D-REP-01", re.compile(r"max repair rounds|repair budget", re.I)),
150
  ("D-REP-03", re.compile(r"no writer artifact|writer produced nothing", re.I)),
traceforge/distill.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Distill a long exploration trace's dead ends into its reasoning channel.
2
+
3
+ A real session often tries several approaches, watches them fail, and lands
4
+ on a different solution. The ideal-world rewrite routes around the failures
5
+ (P2: an ideal trace shows no failed call), so the negative knowledge — what
6
+ was tried, why it failed, what not to repeat — is deleted with them. That is
7
+ the most expensive knowledge in the trace: a model that only ever sees the
8
+ working path learns nothing about the dead ends it will meet again.
9
+
10
+ This stage compresses the dead ends into the reasoning channel of the final
11
+ answer. The promoted trace stays a clean one-shot solution; its reasoning
12
+ carries the distilled "don't try X, it fails because Y" evidence, so the
13
+ corpus teaches both what works and what to route around.
14
+
15
+ Grounding is deterministic: every salient fact in the block must appear in
16
+ the source's own tool results (the same `_coverage` instrument the
17
+ self-grounding gate uses). An ungrounded block is dropped, never repaired —
18
+ the trace is already promotable without it, and a fabricated dead end is
19
+ worse than no dead end.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from typing import Any, Optional
24
+
25
+ from traceforge.trace import Message, Trace, _is_error_result
26
+
27
+ # Two distinct failed calls is the floor: one failure is a typo, two is an
28
+ # abandoned approach. Measured 2026-10-28 on 300 raws: 13.3% of the pool has
29
+ # >=2 failed calls, 31% has >=1.
30
+ MIN_DEAD_ENDS = 2
31
+ MAX_EVIDENCE = 8 # dead ends shown to the model
32
+ MAX_BLOCK_CHARS = 1600 # reasoning cap in _clean_reasoning is 1200
33
+
34
+ DISTILL_HEADER = """You are compressing the DEAD ENDS of a real agent session into the
35
+ reasoning channel of its ideal-world rewrite.
36
+
37
+ The session tried several approaches, watched them fail, and landed on a
38
+ different solution. The ideal trace keeps only the working path (an ideal
39
+ execution shows no failed call), so the negative knowledge would be lost.
40
+ Write the reasoning a flawless agent would have had — one that already knows
41
+ which approaches fail here, and why.
42
+
43
+ FAILED ATTEMPTS (verbatim evidence from the session's own tool results):
44
+ {evidence}
45
+
46
+ THE SOLUTION THAT WORKED (the ideal trace's final answer):
47
+ {answer}
48
+
49
+ Write ONE reasoning block, 300-{max_chars} characters, plain prose.
50
+
51
+ Each dead end is ONE imperative rule, in this shape:
52
+ "Avoid <approach> — it fails because <the actual error or symptom>; <what
53
+ to do instead>."
54
+ Lead with the most expensive dead end. Then the working path in one sentence.
55
+
56
+ Hard rules:
57
+ - Only facts visible in the evidence above. Never invent a file, error,
58
+ command, number, or tool name.
59
+ - No narrative ("initial attempts", "subsequent attempts", "then I tried",
60
+ "eventually"). State the rule, not the story.
61
+ - No hedging, no "let me think", no identity, no harness/context talk.
62
+ - Do not restate the task and do not narrate the answer.
63
+
64
+ Reply with ONLY: {{"reasoning": "<the block>"}}
65
+ """
66
+
67
+ DISTILL_SCHEMA: dict[str, Any] = {
68
+ "type": "object",
69
+ "properties": {"reasoning": {"type": "string"}},
70
+ "required": ["reasoning"],
71
+ }
72
+
73
+
74
+ def _arg_digest(name: str, arguments: str) -> str:
75
+ """Fingerprint of a call, so a repeated failure counts once.
76
+
77
+ Delegates to splice's canonical digest (tool name + full canonical
78
+ arguments). A local copy that hashed only the argument KEYS collapsed
79
+ every `exec` call into one dead end — one implementation, no drift.
80
+ """
81
+ from traceforge.splice import _arg_digest as _canonical
82
+ return _canonical(name, arguments)
83
+
84
+
85
+ def _error_text(body: str) -> str:
86
+ """The failure itself, not the wrapper around it.
87
+
88
+ A wrapped result ("Script completed Wall time 5.6 seconds Output: {...}")
89
+ hides the error behind the wrapper, so the model gets told "the command
90
+ failed" without being told why. Unwrap the first JSON object in the body
91
+ and prefer its error/output/stderr fields; fall back to the raw text.
92
+ """
93
+ import json as _json
94
+
95
+ text = body or ""
96
+ dec = _json.JSONDecoder()
97
+ for i, ch in enumerate(text[:3000]):
98
+ if ch != "{":
99
+ continue
100
+ try:
101
+ obj, _ = dec.raw_decode(text, i)
102
+ except ValueError:
103
+ continue
104
+ if not isinstance(obj, dict):
105
+ continue
106
+ parts: list[str] = []
107
+ code = obj.get("exit_code")
108
+ if isinstance(code, int) and code != 0:
109
+ parts.append(f"exit_code={code}")
110
+ for k in ("error", "stderr", "output", "message", "detail"):
111
+ v = obj.get(k)
112
+ if isinstance(v, str) and v.strip():
113
+ parts.append(v.strip())
114
+ if parts:
115
+ return " ".join(" ".join(parts).split())[:500]
116
+ return " ".join(text.split())[:500]
117
+
118
+
119
+ def exploration_evidence(
120
+ trace: Trace, *, min_failures: int = MIN_DEAD_ENDS,
121
+ ) -> list[dict[str, str]]:
122
+ """Distinct failed calls in the source, with their error text.
123
+
124
+ Deduped by (tool, argument digest): retrying the same broken command
125
+ five times is ONE dead end, not five. Returns [] when the trace has
126
+ fewer than `min_failures` distinct failures — a trace with one typo is
127
+ not an exploration trace.
128
+ """
129
+ calls = {}
130
+ for m in trace.messages:
131
+ for tc in m.tool_calls:
132
+ if tc.id:
133
+ calls[tc.id] = tc
134
+ seen: set[str] = set()
135
+ out: list[dict[str, str]] = []
136
+ for m in trace.messages:
137
+ if m.role != "tool" or not m.tool_call_id:
138
+ continue
139
+ body = m.content or ""
140
+ if not _is_error_result(body):
141
+ continue
142
+ tc = calls.get(m.tool_call_id)
143
+ name = (tc.name if tc is not None else m.tool_name) or "?"
144
+ args = (tc.arguments if tc is not None else "") or ""
145
+ key = _arg_digest(name, args)
146
+ if key in seen:
147
+ continue
148
+ seen.add(key)
149
+ out.append({
150
+ "tool": name,
151
+ "args": " ".join(args.split())[:240],
152
+ "error": _error_text(body),
153
+ })
154
+ if len(out) >= MAX_EVIDENCE:
155
+ break
156
+ return out if len(out) >= min_failures else []
157
+
158
+
159
+ def _render_evidence(evidence: list[dict[str, str]]) -> str:
160
+ lines = []
161
+ for n, e in enumerate(evidence, 1):
162
+ lines.append(
163
+ f"{n}. {e['tool']}({e['args']})\n -> {e['error']}")
164
+ return "\n".join(lines)
165
+
166
+
167
+ def validate_block(
168
+ block: str, source: Trace, *, max_chars: int = MAX_BLOCK_CHARS,
169
+ ) -> list[str]:
170
+ """Gate the distilled block. [] == usable.
171
+
172
+ The block is reasoning, so it gets the reasoning channel's rules
173
+ (identity, harness self-talk, deliberation, clip markers) plus the one
174
+ rule that matters for a dead end: every fact it names must be visible
175
+ in the source's own tool results.
176
+ """
177
+ from traceforge.audit import SELF_TALK_RE
178
+ from traceforge.grounding import _coverage, _evidence_corpus
179
+ from traceforge.identity import IDENTITY_RE
180
+ from traceforge.splice import CLIP_MARKER_PROBE, _DELIBERATION_RE
181
+
182
+ errs: list[str] = []
183
+ b = " ".join((block or "").split()).strip()
184
+ if len(b) < 200:
185
+ errs.append(f"block too short ({len(b)} chars)")
186
+ if len(b) > max_chars:
187
+ errs.append(f"block too long ({len(b)} > {max_chars} chars)")
188
+ if CLIP_MARKER_PROBE.search(b):
189
+ errs.append("block contained a clip marker")
190
+ if SELF_TALK_RE.search(b):
191
+ errs.append("block referenced harness plumbing")
192
+ if IDENTITY_RE.search(b):
193
+ errs.append("block contained an identity claim")
194
+ if _DELIBERATION_RE.search(b):
195
+ errs.append("block deliberated aloud")
196
+ probe = Trace(session_id="distill-probe",
197
+ messages=[Message(role="assistant", content=b)])
198
+ cov, missing = _coverage(probe, _evidence_corpus(source))
199
+ if cov < 0.9:
200
+ errs.append(
201
+ "block not grounded in the source's tool results: "
202
+ + ", ".join(missing[:5]))
203
+ return errs
204
+
205
+
206
+ def attach_to_final(
207
+ candidate: Trace, block: str, *, max_chars: int = MAX_BLOCK_CHARS,
208
+ ) -> bool:
209
+ """Put the block on the final assistant turn's reasoning channel.
210
+
211
+ Appends to reasoning the writer already supplied (the close's decision
212
+ logic is still useful); when the merge would blow the budget, the
213
+ distilled block wins — it is the part that carries the dead ends.
214
+ """
215
+ for m in reversed(candidate.messages):
216
+ if m.role != "assistant" or not (m.content or "").strip():
217
+ continue
218
+ existing = (m.reasoning or "").strip()
219
+ merged = f"{existing} {block}".strip() if existing else block
220
+ if len(merged) > max_chars + 600:
221
+ merged = block
222
+ m.reasoning = merged
223
+ return True
224
+ return False
225
+
226
+
227
+ def distill_into_reasoning(
228
+ cfg,
229
+ source: Trace,
230
+ candidate: Trace,
231
+ task: str,
232
+ base: str,
233
+ key: str,
234
+ ) -> tuple[Optional[str], str]:
235
+ """One call: dead ends -> reasoning block on the final answer.
236
+
237
+ Returns (block, note). block is None when the trace is not an
238
+ exploration trace, the call failed, or the block was rejected; note
239
+ says which, so the caller can record it in the manifest.
240
+ """
241
+ if not getattr(cfg, "distill", True):
242
+ return None, "disabled"
243
+ evidence = exploration_evidence(
244
+ source, min_failures=getattr(cfg, "distill_min_dead_ends",
245
+ MIN_DEAD_ENDS))
246
+ if not evidence:
247
+ return None, "not an exploration trace"
248
+ answer = next(
249
+ (m.content for m in reversed(candidate.messages)
250
+ if m.role == "assistant" and (m.content or "").strip()), "")
251
+ max_chars = int(getattr(cfg, "distill_max_chars", MAX_BLOCK_CHARS))
252
+ from traceforge.splice import _post_chat
253
+ data, err = _post_chat(
254
+ base, key, cfg.model or "",
255
+ DISTILL_HEADER.format(
256
+ evidence=_render_evidence(evidence),
257
+ answer=(answer or "")[:2000],
258
+ max_chars=max_chars),
259
+ DISTILL_SCHEMA,
260
+ max_tokens=min(1200, max_chars // 2 + 200),
261
+ timeout_s=cfg.rewrite_timeout_s,
262
+ temperature=cfg.splice_temperature)
263
+ if not data:
264
+ return None, f"call failed: {err}"
265
+ block = data.get("reasoning")
266
+ if not isinstance(block, str) or not block.strip():
267
+ return None, "no reasoning in reply"
268
+ block = " ".join(block.split()).strip()
269
+ errs = validate_block(block, source, max_chars=max_chars)
270
+ if errs:
271
+ return None, "rejected: " + "; ".join(errs[:3])
272
+ if not attach_to_final(candidate, block, max_chars=max_chars):
273
+ return None, "no assistant turn to attach to"
274
+ return block, f"{len(evidence)} dead end(s), {len(block)} chars"
traceforge/export.py CHANGED
@@ -118,6 +118,13 @@ def export_chat_jsonl(
118
  "parent_session_id": t.parent_session_id,
119
  "source": t.source,
120
  }
 
 
 
 
 
 
 
121
  role = _agent_role(t)
122
  if role:
123
  meta["agent_role"] = role
 
118
  "parent_session_id": t.parent_session_id,
119
  "source": t.source,
120
  }
121
+ # The harness the session came from. `source` is the producer
122
+ # ("traceforge-splice"), so it cannot answer "which harness is
123
+ # this?" — and the harness-balance axis needs that answer.
124
+ harness = t.harness or (t.source if t.source not in
125
+ ("traceforge-splice", "unknown", None) else None)
126
+ if harness:
127
+ meta["harness"] = harness
128
  role = _agent_role(t)
129
  if role:
130
  meta["agent_role"] = role
traceforge/grounding.py CHANGED
@@ -150,7 +150,9 @@ def self_grounding_errors(t: Trace, min_coverage: float = 0.95) -> list[str]:
150
  cov, missing = _coverage(t, _evidence_corpus(t))
151
  if cov < min_coverage:
152
  shown = ", ".join(m[:80] for m in missing[:8])
153
- return [f"self-grounding {cov:.0%} < {min_coverage:.0%}; facts not "
 
 
154
  f"in the trace's own tool results: {shown}"]
155
  return []
156
 
@@ -164,7 +166,7 @@ def validate_grounding(
164
  if cov < min_coverage:
165
  shown = ", ".join(m[:80] for m in missing[:8])
166
  errs.append(
167
- f"fact grounding {cov:.0%} < {min_coverage:.0%}; "
168
  f"facts not in source trace: {shown}"
169
  )
170
  errs.extend(tool_result_fidelity_errors(ideal, real))
 
150
  cov, missing = _coverage(t, _evidence_corpus(t))
151
  if cov < min_coverage:
152
  shown = ", ".join(m[:80] for m in missing[:8])
153
+ # One decimal, not `:.0%`: 0.947 rounds to "95%", and a gate that
154
+ # reports "self-grounding 95% < 95%" reads as a bug in the gate.
155
+ return [f"self-grounding {cov:.1%} < {min_coverage:.0%}; facts not "
156
  f"in the trace's own tool results: {shown}"]
157
  return []
158
 
 
166
  if cov < min_coverage:
167
  shown = ", ".join(m[:80] for m in missing[:8])
168
  errs.append(
169
+ f"fact grounding {cov:.1%} < {min_coverage:.0%}; "
170
  f"facts not in source trace: {shown}"
171
  )
172
  errs.extend(tool_result_fidelity_errors(ideal, real))
traceforge/identity.py CHANGED
@@ -81,6 +81,70 @@ REFUSAL_RE = re.compile(
81
  # but we never edit user rows, so a harmful-request trace is not
82
  # promotable at all; that class dies at triage/judge level instead.
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  # ---------------------------------------------------------------- system
85
  # The harness writes its own name into the SYSTEM prompt ("You are Claude
86
  # Code, Anthropic's official CLI for Claude"), and a model trained on that
 
81
  # but we never edit user rows, so a harmful-request trace is not
82
  # promotable at all; that class dies at triage/judge level instead.
83
 
84
+ # A vendor co-author trailer is an identity burn in the TARGET span: the
85
+ # trainee learns to sign its commits "Co-Authored-By: Claude". The gate
86
+ # already rejects it, but the repair round rewrites the window from the same
87
+ # source and reproduces the trailer, so the trace dies. Measured 2026-10-28
88
+ # on 9,195 workdirs: 259 failed the identity gate on this single string and
89
+ # 188 of them had NO other violation — a purely mechanical defect paying for
90
+ # 12-18 LLM calls per attempt. Strip it instead of repairing it.
91
+ #
92
+ # The trailer lives inside tool-call ARGUMENTS (a `git commit -m "..."` body),
93
+ # so the newline before it is usually the two characters backslash-n, not a
94
+ # real newline. Walk the string by hand rather than with one regex: the
95
+ # character class that stops at "backslash or newline" is unreadable and the
96
+ # escaped form is easy to get wrong.
97
+ _TRAILER_HEAD_RE = re.compile(
98
+ r"co-?authored-?by:[ \t]*"
99
+ r"(?:claude|chatgpt|gpt-?[45o]|glm|gemini|qwen|gemma|llama|deepseek|"
100
+ r"copilot|sonnet|opus|anthropic|openai)\b",
101
+ re.IGNORECASE,
102
+ )
103
+
104
+
105
+ def strip_identity_trailers(text: str) -> str:
106
+ """Remove vendor co-author trailers from assistant text / tool args.
107
+
108
+ Returns the input unchanged when nothing matched — a clean row must come
109
+ back byte-identical (same rule as _sanitize_task_text: only tidy the tail
110
+ after an actual cut).
111
+ """
112
+ if not text or "authored" not in text.lower():
113
+ return text
114
+ out: list[str] = []
115
+ pos = 0
116
+ changed = False
117
+ for m in _TRAILER_HEAD_RE.finditer(text):
118
+ start, end = m.start(), m.end()
119
+ if start < pos:
120
+ continue
121
+ # walk back over the blank-line separator (real or escaped newline)
122
+ b = start
123
+ for _ in range(2):
124
+ if text[max(0, b - 2):b] == "\n":
125
+ b -= 2
126
+ elif b >= 1 and text[b - 1] == "\n":
127
+ b -= 1
128
+ if b >= 1 and text[b - 1] == "\r":
129
+ b -= 1
130
+ else:
131
+ break
132
+ # walk forward to the end of the trailer line: a real newline or the
133
+ # backslash of the next escaped sequence (a heredoc terminator, a
134
+ # closing quote) ends it
135
+ e = end
136
+ while e < len(text) and text[e] != "\n" and text[e] != "\\":
137
+ e += 1
138
+ out.append(text[pos:b])
139
+ pos = e
140
+ changed = True
141
+ if not changed:
142
+ return text
143
+ out.append(text[pos:])
144
+ new = "".join(out)
145
+ return re.sub(r"(?:\n|\s)+$", "", new)
146
+
147
+
148
  # ---------------------------------------------------------------- system
149
  # The harness writes its own name into the SYSTEM prompt ("You are Claude
150
  # Code, Anthropic's official CLI for Claude"), and a model trained on that
traceforge/improver/harness.py CHANGED
@@ -1,850 +1,941 @@
1
- """Runs the improver as a Hermes Agent one-shot session (the harness).
2
-
3
- Design: for each trace we spawn
4
-
5
- hermes chat --query-file <prompt.txt> --in <workdir> -Q
6
- [--improver-model M] [--max-turns N] [--pass-session-id]
7
-
8
- The improver is a full agent: it can read the guidelines file, inspect the
9
- workdir, write its output with a tool, and re-validate it — agentic
10
- self-improvement inside the generation loop. The harness never trusts the
11
- improver's chat reply; it reads back the JSON file the improver wrote,
12
- then structural validation + repair loop run outside the model.
13
- """
14
- from __future__ import annotations
15
-
16
- import dataclasses
17
- import json
18
- import os
19
- import shutil
20
- import subprocess
21
- import time
22
- from typing import Optional
23
-
24
- from traceforge import chunker
25
- from traceforge.config import Config
26
- from traceforge.grounding import CLIP_MARKER_RE as CLIP_MARKER_PROBE
27
- from traceforge.improver.prompt import build_prompt, write_prompt_file
28
- from traceforge.trace import Message, Trace, json_loads_lenient
29
-
30
- _TOOL_TAIL_ERROR = "last message role is 'tool' (expected assistant)"
31
- _EMPTY_ROW_RE = "message with empty content and no tool calls"
32
-
33
-
34
- def _only_tool_tail(errs: list[str]) -> bool:
35
- """True when the artifact's ONLY defect is that it ends on a tool row."""
36
- return bool(errs) and all(_TOOL_TAIL_ERROR in e for e in errs)
37
-
38
-
39
- def _drop_empty_assistant_rows(improved: Trace) -> bool:
40
- """Remove assistant rows with no content and no calls. Returns changed.
41
-
42
- The writer emits these as separators when it loses the thread. They
43
- carry zero signal and a trainee must never learn to produce them, so
44
- dropping is strictly better than paying a repair round to rewrite the
45
- whole trace around one blank row.
46
- """
47
- kept = [m for m in improved.messages
48
- if not (m.role == "assistant" and not m.tool_calls
49
- and not (m.content or "").strip())]
50
- if len(kept) == len(improved.messages):
51
- return False
52
- improved.messages = kept
53
- return True
54
-
55
-
56
- def _append_synth_final(cfg: Config, improved: Trace,
57
- source: Trace) -> Optional[Trace]:
58
- """Close a trace that the writer left mid-call with a synthesized answer.
59
-
60
- The writer stops after a tool call in ~3% of traces (output cap or turn
61
- cap), leaving an artifact that is one row short of valid. A full repair
62
- round rewrites the whole trace and usually reproduces the same cut, so
63
- the cheap fix is the targeted one: ask for the closing answer only,
64
- grounded in the tail that already exists. Same helper the splice path
65
- uses for raw traces that were cut off mid-run.
66
- """
67
- from traceforge.splice import _resolve_endpoint, _synthesize_final_answer
68
-
69
- try:
70
- base, key = _resolve_endpoint(cfg)
71
- except Exception as e:
72
- print(f" tool-tail synth skipped: endpoint {type(e).__name__}: {e}")
73
- return None
74
- ans, err = _synthesize_final_answer(
75
- cfg, improved, source.first_task_message() or "", base, key)
76
- if not ans:
77
- print(f" tool-tail synth failed: {(err or 'empty answer')[:120]}")
78
- return None
79
- improved.messages.append(Message(role="assistant", content=ans))
80
- return improved
81
-
82
- CLIP_MARKER_ERROR = (
83
- "artifact contains rendering clip markers (…[+N chars clipped]) "
84
- "with no verifiable counts — reviewers reject these. Rewrite the tool "
85
- "results verbatim from outputs.jsonl (contiguous portions fine)."
86
- )
87
-
88
- # Per-workdir file recording which gate rounds already PASSED for which
89
- # artifact content digest (see _load_gate_cache).
90
- GATE_CACHE_FILE = "gate_cache.json"
91
-
92
- # Bump whenever a JUDGE/GATE STANDARD changes (new pillar rule, ANSWER
93
- # note wording that changes verdicts, regex gate changes) — cached
94
- # passes recorded under an older standard must not replay. The value
95
- # rides in the cache file; _load_gate_cache discards stale files.
96
- GATE_CACHE_STANDARDS = "2026-10-08.1" # +answer-focus rule (P1 FOCUS, over-answering = poor) # +write-JSON deliverable forms, tool-tail carry, exact-args dup reads # +mixed-turn surgical drops, usage-error detect, planner >=2 + write-fix chains # +identity scans tool args (Co-Authored-By), degenerate prose gate # +judge sees reasoning channel
97
-
98
-
99
- def _clip_markers_present(candidate) -> bool:
100
- """True if any message content carries a rendering clip marker."""
101
- blob = "\n".join((m.content or "") for m in candidate.messages)
102
- return bool(CLIP_MARKER_PROBE.search(blob))
103
-
104
-
105
- def _artifact_digest(candidate) -> str:
106
- """Stable content hash of an improved artifact.
107
-
108
- Gate passes are keyed on this: structural/grounding/verify/review
109
- PASSes are only replayed from cache when the artifact byte-content is
110
- unchanged (the file may still have been rewritten with identical
111
- bytes — that's fine, it's the same artifact).
112
- """
113
- import hashlib
114
-
115
- h = hashlib.sha256()
116
- for m in candidate.messages:
117
- h.update((m.role or "").encode("utf-8"))
118
- h.update(b"\x00")
119
- h.update((m.content or "").encode("utf-8"))
120
- h.update(b"\x01")
121
- if m.tool_call_id:
122
- h.update(m.tool_call_id.encode("utf-8"))
123
- h.update(b"\x01")
124
- if m.tool_name:
125
- h.update(m.tool_name.encode("utf-8"))
126
- h.update(b"\x02")
127
- if m.tool_calls:
128
- for tc in m.tool_calls:
129
- h.update((tc.id or "").encode("utf-8"))
130
- h.update((tc.name or "").encode("utf-8"))
131
- h.update((tc.arguments or "").encode("utf-8"))
132
- h.update(b"\x03")
133
- return h.hexdigest()
134
-
135
-
136
- class ImproverResult:
137
- def __init__(
138
- self,
139
- *,
140
- session_id: str,
141
- ok: bool,
142
- improved: Optional[Trace],
143
- reply: str,
144
- hermes_session_id: Optional[str] = None,
145
- errors: Optional[list[str]] = None,
146
- attempts: int = 0,
147
- duration_s: float = 0.0,
148
- ):
149
- self.session_id = session_id # source trace session id
150
- self.ok = ok
151
- self.improved = improved
152
- self.reply = reply
153
- self.hermes_session_id = hermes_session_id
154
- self.errors = errors or []
155
- self.attempts = attempts
156
- self.duration_s = duration_s
157
-
158
- def to_dict(self) -> dict:
159
- return {
160
- "session_id": self.session_id,
161
- "ok": self.ok,
162
- "errors": self.errors,
163
- "hermes_session_id": self.hermes_session_id,
164
- "attempts": self.attempts,
165
- "duration_s": round(self.duration_s, 1),
166
- }
167
-
168
-
169
- def _run_hermes(
170
- cfg: Config, prompt_path: str, workdir: str
171
- ) -> tuple[int, str, Optional[str], str]:
172
- """Invoke hermes chat one-shot; returns (exit_code, stdout, session_id)."""
173
- cmd = [
174
- "hermes", "chat",
175
- "--query-file", prompt_path,
176
- "--in", workdir,
177
- "-Q",
178
- "--format", "text",
179
- "--pass-session-id",
180
- ]
181
- if cfg.model:
182
- cmd += ["-m", cfg.model]
183
- if cfg.provider:
184
- cmd += ["--provider", cfg.provider]
185
- if cfg.max_turns:
186
- cmd += ["--max-turns", str(cfg.max_turns)]
187
- if cfg.run_budget:
188
- cmd += ["--run-budget", str(cfg.run_budget)]
189
- if cfg.skills:
190
- cmd += ["-s", ",".join(cfg.skills)]
191
- proc = subprocess.run(
192
- cmd,
193
- capture_output=True,
194
- text=True,
195
- timeout=cfg.harness_timeout_s,
196
- cwd=workdir,
197
- )
198
- session_id = None
199
- for line in (proc.stdout or "").splitlines():
200
- if line.strip().startswith("session_id:"):
201
- session_id = line.split(":", 1)[1].strip() or None
202
- return proc.returncode, proc.stdout, session_id, proc.stderr
203
-
204
-
205
- def _parse_improved(path: str) -> Optional[Trace]:
206
- """Parse the improver-written improved.json into a Trace, leniently."""
207
- if not os.path.exists(path):
208
- return None
209
- with open(path, "r", encoding="utf-8") as f:
210
- raw = f.read()
211
- # tolerate fenced code blocks from chatty models
212
- raw = raw.strip()
213
- if raw.startswith("```"):
214
- raw = raw.split("\n", 1)[1] if "\n" in raw else ""
215
- if raw.rstrip().endswith("```"):
216
- raw = raw.rstrip()[: -3]
217
- data = json_loads_lenient(raw)
218
- if not isinstance(data, dict):
219
- return None
220
- msgs = data.get("messages")
221
- if not isinstance(msgs, list):
222
- return None
223
- from traceforge.trace import Message, ToolCall
224
-
225
- out = []
226
- for d in msgs:
227
- if not isinstance(d, dict) or "role" not in d:
228
- return None
229
- m = {
230
- "role": d["role"],
231
- "content": d.get("content"),
232
- "reasoning": d.get("reasoning"),
233
- "tool_call_id": d.get("tool_call_id"),
234
- "tool_name": d.get("tool_name"),
235
- }
236
- m = {k: v for k, v in m.items() if v is not None}
237
- if "tool_calls" in d and isinstance(d["tool_calls"], list):
238
- m["tool_calls"] = [
239
- {
240
- "id": str(tc.get("id", "")),
241
- "name": str(tc.get("name", "")),
242
- "arguments": tc.get("arguments") or "{}",
243
- }
244
- for tc in d["tool_calls"]
245
- if isinstance(tc, dict)
246
- ]
247
- out.append(Message.from_dict(m))
248
- return Trace(
249
- session_id="improved-placeholder", # set by caller
250
- messages=out,
251
- improved=True,
252
- )
253
-
254
-
255
- def _append_status(cfg: Config, sid: str, status: dict) -> None:
256
- """Append a stage-level status row to the manifest."""
257
- import json as _json
258
-
259
- os.makedirs(os.path.dirname(cfg.manifest_path), exist_ok=True)
260
- row = {"session_id": sid, **status}
261
- with open(cfg.manifest_path, "a", encoding="utf-8") as f:
262
- f.write(_json.dumps(row, ensure_ascii=False) + "\n")
263
-
264
-
265
- def _load_gate_cache(workdir: str) -> dict:
266
- """Read the workdir's gate-pass cache (digest -> list of gate rows).
267
-
268
- Batches die at arbitrary points (turn interruptions, terminal close,
269
- SIGTERM to the worker pool); without this, every verify/review round
270
- already PAID FOR on an unchanged artifact is re-run from scratch on
271
- the next resume. The cache is keyed by the artifact's content digest
272
- so a rewritten (different) artifact never inherits stale passes.
273
- Cached passes from an OLDER standards version are discarded — judge
274
- rules evolve, and a verdict recorded under old rules must not
275
- replay against new ones.
276
- """
277
- path = os.path.join(workdir, GATE_CACHE_FILE)
278
- try:
279
- with open(path, "r", encoding="utf-8") as f:
280
- data = json.load(f)
281
- if not isinstance(data, dict):
282
- return {}
283
- if data.get("standards") != GATE_CACHE_STANDARDS:
284
- return {}
285
- return data
286
- except (OSError, json.JSONDecodeError):
287
- return {}
288
-
289
-
290
- def _save_gate_cache(workdir: str, cache: dict) -> None:
291
- """Persist the gate-pass cache atomically (workdir, per trace)."""
292
- path = os.path.join(workdir, GATE_CACHE_FILE)
293
- tmp = path + ".tmp"
294
- cache = dict(cache)
295
- cache["standards"] = GATE_CACHE_STANDARDS
296
- with open(tmp, "w", encoding="utf-8") as f:
297
- json.dump(cache, f, ensure_ascii=False)
298
- os.replace(tmp, path)
299
-
300
-
301
- def _cache_gate_pass(cache: dict, digest: str, stage: str, round_no: int) -> None:
302
- """Record a gate PASS for one artifact digest."""
303
- rows = cache.setdefault(digest, [])
304
- key = f"{stage}:{round_no}"
305
- if key not in rows:
306
- rows.append(key)
307
-
308
-
309
- def _acquire_workdir_lock(workdir: str) -> bool:
310
- """O_EXCL pid lockfile; locks left by dead processes are stolen.
311
-
312
- Two concurrent improve batches working the same sid corrupt each
313
- other (shared improved.json / verdict.json — the verifier unlinks its
314
- verdict file before each run) and burn double model time. The lock
315
- makes one-improver-per-workdir structural, not a user discipline.
316
- """
317
- lock_path = os.path.join(workdir, ".lock")
318
- my_pid = str(os.getpid())
319
- for _ in range(3):
320
- try:
321
- fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
322
- try:
323
- os.write(fd, my_pid.encode())
324
- finally:
325
- os.close(fd)
326
- return True
327
- except FileExistsError:
328
- try:
329
- other = open(lock_path, encoding="utf-8").read().strip()
330
- except OSError:
331
- continue
332
- pid = int(other) if other.isdigit() else -1
333
- alive = False
334
- if pid > 0:
335
- try:
336
- os.kill(pid, 0)
337
- alive = True
338
- except ProcessLookupError:
339
- alive = False
340
- except PermissionError:
341
- alive = True # exists, not ours: assume alive
342
- except OverflowError:
343
- alive = False
344
- if alive:
345
- return False
346
- try:
347
- os.unlink(lock_path) # stale lock from a dead run: steal
348
- except OSError:
349
- pass
350
- return False
351
-
352
-
353
- def _release_workdir_lock(workdir: str) -> None:
354
- """Drop our own lock (never another live run's)."""
355
- lock_path = os.path.join(workdir, ".lock")
356
- try:
357
- with open(lock_path, encoding="utf-8") as f:
358
- if f.read().strip() == str(os.getpid()):
359
- os.unlink(lock_path)
360
- except OSError:
361
- pass
362
-
363
-
364
- def _write_outputs_index(
365
- cfg: Config, trace: Trace, workdir: str, max_chars: int = 250_000
366
- ) -> str:
367
- """Full real tool outputs as outputs.jsonl, keyed by rendering index.
368
-
369
- The compact rendering clips each tool result to a short head; P4
370
- demands verbatim reuse, so the improver needs the unclipped text.
371
- One JSON object per line: {"i": N, "tool", "args", "output"} where N
372
- is the same [N] message index the rendering shows — the improver can
373
- correlate a clipped result in its prompt with the full text here.
374
-
375
- max_chars is a byte-blow safety valve (measured corpus max real
376
- output: ~111KB), NOT a compression budget: the 20k cap this used to
377
- apply hid exactly the mid-tail evidence (p95 = 20k) that P4 demands
378
- the improver quote — on exactly the big traces where review kills
379
- artifacts — forcing composed pseudo-outputs. Outputs beyond the
380
- safety valve carry an honest total-marker, never a fake count.
381
- """
382
- path = os.path.join(workdir, "outputs.jsonl")
383
- arg_of: dict[str, str] = {}
384
- for m in trace.messages:
385
- for tc in m.tool_calls:
386
- arg_of[tc.id] = tc.arguments or "{}"
387
- with open(path, "w", encoding="utf-8") as f:
388
- for i, m in enumerate(trace.messages, 1):
389
- if m.role != "tool" or not m.content:
390
- continue
391
- out = m.content
392
- if len(out) > max_chars:
393
- out = out[:max_chars] + f" …[+{len(out) - max_chars} chars total]"
394
- f.write(json.dumps({
395
- "i": i,
396
- "tool": m.tool_name or "?",
397
- "args": arg_of.get(m.tool_call_id or "", "")[:400],
398
- "output": out,
399
- }, ensure_ascii=False) + "\n")
400
- return path
401
-
402
-
403
- def _generate_single(cfg, trace, rendered, workdir, output_path, errors, prev_path, sid):
404
- """One single-shot improver invocation. Returns (candidate, reply, hsid, code, stderr) or raises Timeout."""
405
- from traceforge.classify import detect_language
406
- prompt = build_prompt(
407
- rendered_trace=rendered,
408
- guidelines_path=cfg.guidelines_path,
409
- output_path=output_path,
410
- outputs_path=os.path.join(workdir, "outputs.jsonl"),
411
- errors=errors,
412
- prev_path=prev_path,
413
- language=detect_language(trace.first_task_message() or ""),
414
- )
415
- prompt_path = write_prompt_file(cfg.prompts_dir, sid, prompt)
416
- try:
417
- code, stdout, hsid, stderr = _run_hermes(cfg, prompt_path, workdir)
418
- except subprocess.TimeoutExpired as e:
419
- raise e
420
- reply = (stdout or "").strip()
421
- return _parse_improved(output_path), reply, hsid, code, stderr
422
-
423
-
424
- def improve_trace(
425
- cfg: Config,
426
- trace: Trace,
427
- *,
428
- max_attempts: int = 2,
429
- validator=None,
430
- status_cb=None,
431
- ) -> ImproverResult:
432
- """Run the agentic improver on one trace, with a validation repair loop.
433
-
434
- Long traces (rendered size > cfg.chunk_max_chars) go through the staged
435
- map-digest-write pipeline instead of the single shot. Every admitted
436
- trace passes: N independent verification rounds (web fact-check) and
437
- M independent review rounds (quality-pillar review) — all must PASS.
438
-
439
- ``status_cb`` (optional) receives short human stage strings
440
- ("improving", "digest", "verify 1/2", ...) for live progress display.
441
- """
442
- from traceforge.trace import Trace as _T
443
- from traceforge.validate import validate_structural
444
- from traceforge.grounding import validate_grounding
445
- from traceforge.improver.verify import run_verifier
446
- from traceforge.improver.review import run_review
447
-
448
- def _status(s: str) -> None:
449
- if status_cb is not None:
450
- status_cb(s)
451
-
452
- started = time.time()
453
- sid = trace.session_id
454
- workdir = os.path.join(cfg.improver_workdir, sid)
455
- os.makedirs(workdir, exist_ok=True)
456
- # One improver per workdir, structurally: concurrent batches on the
457
- # same sid race on improved.json/verdict.json and pay the model twice.
458
- if not _acquire_workdir_lock(workdir):
459
- return ImproverResult(
460
- session_id=sid, ok=False, improved=None, reply="",
461
- hermes_session_id=None,
462
- errors=["workdir locked by a live improver run — skipping"],
463
- attempts=0, duration_s=0.0,
464
- )
465
- try:
466
- return _improve_locked(cfg, trace, max_attempts, status_cb)
467
- finally:
468
- _release_workdir_lock(workdir)
469
-
470
-
471
- def _improve_locked(
472
- cfg: Config, trace: Trace, max_attempts: int, status_cb
473
- ) -> ImproverResult:
474
- from traceforge.trace import Trace as _T
475
- from traceforge.validate import validate_structural
476
- from traceforge.grounding import validate_grounding
477
- from traceforge.improver.verify import run_verifier
478
- from traceforge.improver.review import run_review
479
-
480
- def _status(s: str) -> None:
481
- if status_cb is not None:
482
- status_cb(s)
483
-
484
- started = time.time()
485
- sid = trace.session_id
486
- workdir = os.path.join(cfg.improver_workdir, sid)
487
- rendered = trace.render_compact(
488
- user_chars=cfg.user_chars,
489
- assistant_chars=cfg.assistant_chars,
490
- tool_result_chars=cfg.tool_result_chars,
491
- tool_args_chars=cfg.tool_args_chars,
492
- include_reasoning=cfg.include_reasoning,
493
- )
494
- output_path = os.path.join(workdir, "improved.json")
495
- # Full unclipped tool outputs on disk: P4's verbatim-reuse rule is
496
- # only satisfiable when the improver can actually see the full text.
497
- outputs_path = _write_outputs_index(cfg, trace, workdir)
498
-
499
- errors: list[str] = []
500
- prev_path: Optional[str] = None
501
- attempts = 0
502
- improved: Optional[Trace] = None
503
- reply = ""
504
- hermes_session_id: Optional[str] = None
505
- # long-trace attempts don't run hermes chat directly (stages do); the
506
- # loop below must not touch single-shot navigation vars on that path
507
- code = 0
508
- stderr = ""
509
-
510
- for attempt in range(1, max_attempts + 1):
511
- attempts = attempt
512
- candidate = None
513
- # Fast-path: when a previous run already left an artifact in this
514
- # workdir, re-adjudicate it first (regate). Only pay the model for
515
- # a fresh write when nothing usable exists on disk. This makes
516
- # "rerun after a gate change" nearly free instead of a full
517
- # re-write of every trace.
518
- if attempt == 1 and os.path.exists(output_path):
519
- _status("regate")
520
- candidate = _parse_improved(output_path)
521
- # Static pre-flight: an artifact that still carries rendering
522
- # clip markers is dead on arrival at review (reviewers kill
523
- # invented counts; grounding silently strips them). Fail it
524
- # here — one static check instead of 4 deep-thinker
525
- # sessions re-discovering it.
526
- if candidate is not None and _clip_markers_present(candidate):
527
- candidate = None
528
- errors = [CLIP_MARKER_ERROR]
529
- # keep the failed artifact as the repair reference: the
530
- # next attempt must FIX it (REPAIR_HEADER + errors), not
531
- # blind-rewrite from a fresh TASK_HEADER that would
532
- # silently drop the specific clip-marker error.
533
- prev_path = output_path
534
- if candidate is not None:
535
- reply = "REGATE (existing artifact)"
536
- code = 0
537
- stderr = ""
538
- elif len(rendered) <= cfg.chunk_max_chars:
539
- _status("improving" if attempt == 1 else f"repair {attempt}")
540
- try:
541
- candidate, reply_one, hsid, code, stderr = _generate_single(
542
- cfg, trace, rendered, workdir, output_path, errors,
543
- prev_path, sid,
544
- )
545
- except subprocess.TimeoutExpired:
546
- errors = [f"harness timeout after {cfg.harness_timeout_s}s"]
547
- continue
548
- reply = reply_one
549
- hermes_session_id = hsid
550
- if reply.startswith("REJECTED"):
551
- errors = [f"rejected by improver: {reply[:300]}"]
552
- break
553
- else:
554
- # long-trace staged pipeline: digest -> map -> writer
555
- from traceforge.improver import longtrace
556
-
557
- chunk_dir = os.path.join(workdir, "chunks")
558
- if prev_path is None:
559
- _status("digest+map")
560
- chunks = chunker.chunk_trace(
561
- trace,
562
- max_chars=cfg.chunk_max_chars,
563
- overlap_messages=cfg.chunk_overlap_messages,
564
- render_kwargs={
565
- "user_chars": cfg.user_chars,
566
- "assistant_chars": cfg.assistant_chars,
567
- "tool_result_chars": cfg.tool_result_chars,
568
- "tool_args_chars": cfg.tool_args_chars,
569
- "include_reasoning": cfg.include_reasoning,
570
- },
571
- )
572
- if not chunks:
573
- errors = ["chunking produced no chunks"]
574
- continue
575
- longtrace.write_chunks(chunk_dir, chunks, trace)
576
- _status(f"digest 0/{len(chunks)}")
577
- digests, derrs = longtrace.digest_all(
578
- cfg, trace, chunks, workdir,
579
- progress_cb=lambda done, total: _status(
580
- f"digest {done}/{total}"
581
- ),
582
- )
583
- if derrs:
584
- errors = [f"digest failures: {derrs}"]
585
- continue
586
- _status(f"map ({len(chunks)} chunks)")
587
- map_d, merr = longtrace.build_map(cfg, trace, digests, workdir)
588
- if map_d is None:
589
- errors = [f"map merge failed: {merr}"]
590
- continue
591
- _append_status(cfg, sid, {
592
- "stage": "map", "chunks": len(chunks),
593
- "approx_tokens": sum(c.approx_chars for c in chunks) // 4,
594
- })
595
- # writer (also used for repair attempts on long traces)
596
- map_d = longtrace._json_file(os.path.join(workdir, "session_map.json"))
597
- if map_d is None:
598
- errors = ["session map missing after long-trace stage"]
599
- continue
600
- _status("writer")
601
- candidate, werr = longtrace.run_writer(
602
- cfg, trace, map_d, chunk_dir, workdir, output_path,
603
- repair_errors=errors if prev_path else None,
604
- )
605
- if candidate is None:
606
- errors = [f"long-trace writer failed: {werr}"]
607
- continue
608
- reply = "WRITER DONE"
609
- # The artifact on disk is the source of truth — NOT the exit code.
610
- # The improver may hit its turn cap after writing a perfectly valid
611
- # file (e.g. right after a repair); validate what exists regardless.
612
- candidate = _parse_improved(output_path)
613
- if candidate is None:
614
- if code != 0:
615
- stderr_tail = f"; stderr: {stderr.strip()[-500:]}" if stderr else ""
616
- errors = [
617
- f"hermes chat exited with code {code} and "
618
- f"improved.json missing/unparseable{stderr_tail}"
619
- ]
620
- else:
621
- errors = [
622
- "improved.json missing, unparseable, or missing 'messages'"
623
- ]
624
- continue
625
- # Static clip-marker gate on EVERY path, not just regate: writers
626
- # and repairs can still copy markers out of the clipped rendering.
627
- # A marker artifact is dead on arrival at review (and grounding
628
- # silently forgives it) — fail fast here instead of burning
629
- # verifier + reviewer sessions on it.
630
- if _clip_markers_present(candidate):
631
- errors = [f"clip-marker gate: {CLIP_MARKER_ERROR}"]
632
- # repair must fix THIS artifact (REPAIR_HEADER + errors), not
633
- # blind-rewrite from a TASK_HEADER that drops the specifics
634
- prev_path = output_path
635
- continue
636
- prev_path = output_path
637
- improved = candidate
638
- # Pipeline framing markers ("[trace segment k of n]", "[subagent
639
- # brief — role: ...]") belong to the improver's INPUT; strip them
640
- # before the gates so the echo check compares against the real task
641
- # and the promoted artifact carries no scaffolding.
642
- from traceforge.validate import strip_scaffold_markers
643
- if strip_scaffold_markers(improved):
644
- improved.save(output_path)
645
- errs = validate_structural(improved)
646
- if errs and any(_EMPTY_ROW_RE in e for e in errs):
647
- # blank assistant rows are pure noise — drop them and re-check
648
- # rather than paying a full rewrite to remove one empty row.
649
- # Runs even when other errors are present: a trace with a blank
650
- # row AND a tool tail must get both fixes, not neither.
651
- if _drop_empty_assistant_rows(improved):
652
- improved.save(output_path) # repair must start from the fix
653
- errs = validate_structural(improved)
654
- if not errs:
655
- _status("dropped empty assistant rows")
656
- if _only_tool_tail(errs):
657
- # writer stopped mid-call: one row short of valid. Synthesize
658
- # the closing answer instead of paying a full repair round
659
- # that reproduces the same cut (measured: 49/1535 traces).
660
- fixed = _append_synth_final(cfg, improved, trace)
661
- if fixed is not None:
662
- improved = fixed
663
- # persist BEFORE re-validating: a later repair round reads
664
- # prev_path from disk, so an in-memory-only fix is thrown
665
- # away and the writer reproduces the same tool tail.
666
- improved.save(output_path)
667
- errs = validate_structural(improved)
668
- if not errs:
669
- _status("final answer (tool-tail synth)")
670
- if not errs:
671
- errs = validate_grounding(improved, trace)
672
- if not errs and (cfg.verify or cfg.review):
673
- # Gate-pass cache: rounds that already PASSED for THIS exact
674
- # artifact content are replayed instead of re-paid. Survives
675
- # batch interruptions (turn cap, SIGTERM, terminal close); a
676
- # rewritten artifact has a different digest and re-runs all
677
- # rounds fresh.
678
- digest = _artifact_digest(improved)
679
- gate_cache = _load_gate_cache(workdir)
680
- gate_pass_keys = set(gate_cache.get(digest, []))
681
-
682
- def _gate_pass(stage: str, round_no: int) -> None:
683
- _cache_gate_pass(gate_cache, digest, stage, round_no)
684
- _save_gate_cache(workdir, gate_cache)
685
-
686
- verify_failures: list[str] = []
687
- # ---- N independent verification rounds (web fact-check)
688
- # Strict admission: every round must end PASS. A transient ERROR
689
- # (timeout, unparseable verdict) gets ONE in-place retry; a
690
- # second ERROR surfaces as an error row — never silent
691
- # under-verification, never a model repair for infra issues.
692
- if cfg.verify:
693
- passes = 0
694
- vr = 1
695
- while vr <= cfg.verify_rounds:
696
- if f"verify:{vr}" in gate_pass_keys:
697
- passes += 1 # already adjudicated on identical bytes
698
- _status(f"verify {vr}/{cfg.verify_rounds} (cached)")
699
- _append_status(cfg, sid, {
700
- "stage": "verify", "round": vr,
701
- "verdict": "PASS", "cached": True,
702
- })
703
- vr += 1
704
- continue
705
- _status(f"verify {vr}/{cfg.verify_rounds}")
706
- vres = run_verifier(cfg, output_path, workdir)
707
- if vres.verdict == "PASS":
708
- passes += 1
709
- _gate_pass("verify", vr)
710
- _append_status(cfg, sid, {
711
- "stage": "verify", "round": vr,
712
- "verdict": "PASS",
713
- "claims_checked": vres.claims_checked,
714
- })
715
- vr += 1
716
- continue
717
- if vres.verdict == "FAIL":
718
- claims = "\n".join(
719
- f'- "{u.get("claim", "?")}" — {u.get("why", "?")}'
720
- for u in vres.unsupported[:10]
721
- )
722
- verify_failures.append(
723
- f"[verify r{vr}] external claims unsupported:\n{claims}"
724
- )
725
- _append_status(cfg, sid, {
726
- "stage": "verify", "round": vr, "verdict": "FAIL",
727
- "claims_checked": vres.claims_checked,
728
- })
729
- break # concrete FAIL: repair next attempt
730
- # transient ERROR: one in-place retry of the same round
731
- retry = run_verifier(cfg, output_path, workdir)
732
- if retry.verdict == "PASS":
733
- passes += 1
734
- _gate_pass("verify", vr)
735
- _append_status(cfg, sid, {
736
- "stage": "verify", "round": vr,
737
- "verdict": "PASS", "retried": True,
738
- "claims_checked": retry.claims_checked,
739
- })
740
- vr += 1
741
- continue
742
- if retry.verdict == "FAIL":
743
- claims = "\n".join(
744
- f'- "{u.get("claim", "?")}" — {u.get("why", "?")}'
745
- for u in retry.unsupported[:10]
746
- )
747
- verify_failures.append(
748
- f"[verify r{vr}] external claims unsupported:\n{claims}"
749
- )
750
- _append_status(cfg, sid, {
751
- "stage": "verify", "round": vr, "verdict": "FAIL",
752
- "retried": True,
753
- "claims_checked": retry.claims_checked,
754
- })
755
- break
756
- verify_failures.append(
757
- f"[verify r{vr}] infra error x2: "
758
- f"{vres.error or 'verifier error'} / "
759
- f"{retry.error or 'verifier error'}"
760
- )
761
- _append_status(cfg, sid, {
762
- "stage": "verify", "round": vr, "verdict": "ERROR",
763
- "error": (
764
- f"{vres.error or 'verifier error'} / "
765
- f"{retry.error or 'verifier error'}"
766
- )[:300],
767
- })
768
- break
769
- # ---- M independent review rounds (quality pillars)
770
- review_failures: list[str] = []
771
- task_snip = trace.first_task_message() or "(no user message)"
772
- if not verify_failures and cfg.review:
773
- for rr in range(1, cfg.review_rounds + 1):
774
- if f"review:{rr}" in gate_pass_keys:
775
- _status(f"review {rr}/{cfg.review_rounds} (cached)")
776
- _append_status(cfg, sid, {
777
- "stage": "review", "round": rr, "verdict": "PASS",
778
- "cached": True,
779
- })
780
- continue
781
- _status(f"review {rr}/{cfg.review_rounds}")
782
- rres = run_review(
783
- cfg, output_path, task_snip, workdir, rr
784
- )
785
- if not rres.ok() and rres.verdict == "ERROR":
786
- rres = run_review(
787
- cfg, output_path, task_snip, workdir, rr
788
- ) # one in-place retry on transient infra error
789
- if rres.ok():
790
- _gate_pass("review", rr)
791
- _append_status(cfg, sid, {
792
- "stage": "review", "round": rr, "verdict": "PASS",
793
- })
794
- continue
795
- if rres.verdict == "FAIL":
796
- review_failures.append(
797
- "[review r%d] pillar violations:\n%s"
798
- % (rr, "\n".join(rres.violations[:10]))
799
- )
800
- _append_status(cfg, sid, {
801
- "stage": "review", "round": rr, "verdict": "FAIL",
802
- })
803
- break # concrete FAIL: repair next attempt
804
- review_failures.append(
805
- f"[review r{rr}] infra error x2: {rres.error or 'review error'}"
806
- )
807
- _append_status(cfg, sid, {
808
- "stage": "review", "round": rr, "verdict": "ERROR",
809
- "error": (rres.error or "review error")[:300],
810
- })
811
- break
812
- errs = verify_failures + review_failures
813
- if errs:
814
- # Infra failures (verifier/reviewer down) are NOT content errors:
815
- # a model repair cannot fix them. Fail fast without re-running
816
- # the improver so no model time is wasted on infrastructure.
817
- if any("infra error x2" in e for e in errs):
818
- return ImproverResult(
819
- session_id=sid, ok=False, improved=None, reply=reply,
820
- hermes_session_id=hermes_session_id,
821
- errors=errs, attempts=attempts,
822
- duration_s=time.time() - started,
823
- )
824
- errors = errs + (
825
- [f"note: hermes exit code {code}"] if code != 0 else []
826
- )
827
- continue
828
- improved.session_id = f"ideal-{sid}"
829
- improved.parent_session_id = sid
830
- # cfg.model None = default profile; the exact model is recoverable
831
- # from hermes_session_id (recorded in the manifest).
832
- improved.model = cfg.model or "hermes-default"
833
- improved.source = "hermes-one-shot"
834
- return ImproverResult(
835
- session_id=sid, ok=True, improved=improved, reply=reply,
836
- hermes_session_id=hermes_session_id, errors=[],
837
- attempts=attempts, duration_s=time.time() - started,
838
- )
839
-
840
- return ImproverResult(
841
- session_id=sid, ok=False, improved=None, reply=reply,
842
- hermes_session_id=hermes_session_id, errors=errors,
843
- attempts=attempts, duration_s=time.time() - started,
844
- )
845
-
846
-
847
- def cleanup_workdir(cfg: Config, sid: str, keep: bool = False) -> None:
848
- wd = os.path.join(cfg.improver_workdir, sid)
849
- if not keep and os.path.isdir(wd):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
850
  shutil.rmtree(wd, ignore_errors=True)
 
1
+ """Runs the improver as a Hermes Agent one-shot session (the harness).
2
+
3
+ Design: for each trace we spawn
4
+
5
+ hermes chat --query-file <prompt.txt> --in <workdir> -Q
6
+ [--improver-model M] [--max-turns N] [--pass-session-id]
7
+
8
+ The improver is a full agent: it can read the guidelines file, inspect the
9
+ workdir, write its output with a tool, and re-validate it — agentic
10
+ self-improvement inside the generation loop. The harness never trusts the
11
+ improver's chat reply; it reads back the JSON file the improver wrote,
12
+ then structural validation + repair loop run outside the model.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import dataclasses
17
+ import json
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import time
22
+ from typing import Optional
23
+
24
+ from traceforge import chunker
25
+ from traceforge.config import Config
26
+ from traceforge.grounding import CLIP_MARKER_RE as CLIP_MARKER_PROBE
27
+ from traceforge.improver.prompt import build_prompt, write_prompt_file
28
+ from traceforge.trace import Message, Trace, json_loads_lenient
29
+
30
+ _TOOL_TAIL_ERROR = "last message role is 'tool' (expected assistant)"
31
+ _EMPTY_ROW_RE = "message with empty content and no tool calls"
32
+
33
+
34
+ def _only_tool_tail(errs: list[str]) -> bool:
35
+ """True when the artifact's ONLY defect is that it ends on a tool row."""
36
+ return bool(errs) and all(_TOOL_TAIL_ERROR in e for e in errs)
37
+
38
+
39
+ def _drop_empty_assistant_rows(improved: Trace) -> bool:
40
+ """Remove assistant rows with no content and no calls. Returns changed.
41
+
42
+ The writer emits these as separators when it loses the thread. They
43
+ carry zero signal and a trainee must never learn to produce them, so
44
+ dropping is strictly better than paying a repair round to rewrite the
45
+ whole trace around one blank row.
46
+ """
47
+ kept = [m for m in improved.messages
48
+ if not (m.role == "assistant" and not m.tool_calls
49
+ and not (m.content or "").strip())]
50
+ if len(kept) == len(improved.messages):
51
+ return False
52
+ improved.messages = kept
53
+ return True
54
+
55
+
56
+ def _drop_narration_rows(improved: Trace) -> bool:
57
+ """Remove standalone short announcement rows. Returns changed.
58
+
59
+ The writer keeps the source agent's "let me look at X" announcements as
60
+ standalone prose rows. Measured 2026-10-28 on the 4,505-trace corpus: 80
61
+ such rows, 56 of them in 52 traces sitting between a tool result and the
62
+ next call. The guidelines forbid narration and the judge cites it as P1
63
+ filler, but a repair round rewrites the window and reproduces it, so the
64
+ deterministic drop is the fix. The rule (short, prose-only, not the final
65
+ answer, not a reply to a user turn) lives in splice._drop_narration_rows
66
+ so both engines share one definition.
67
+ """
68
+ from traceforge.splice import _drop_narration_rows as _drop
69
+ msgs = improved.messages
70
+ out, _, reasons = _drop(msgs, list(range(len(msgs))))
71
+ if not reasons:
72
+ return False
73
+ improved.messages = out
74
+ return True
75
+
76
+
77
+ def _strip_identity_trailers_trace(improved: Trace) -> bool:
78
+ """Strip vendor co-author trailers from assistant text / tool args.
79
+
80
+ The gate rejects them (identity burn in the TARGET span), but a repair
81
+ round rewrites the window from the same source and reproduces the
82
+ trailer. Measured 2026-10-28: 259 workdirs failed on this one string,
83
+ 188 with no other violation. The rule lives in splice so both engines
84
+ share one definition.
85
+ """
86
+ from traceforge.splice import _strip_identity_trailers as _strip
87
+ msgs = improved.messages
88
+ out, _, reasons = _strip(msgs, list(range(len(msgs))))
89
+ if not reasons:
90
+ return False
91
+ improved.messages = out
92
+ return True
93
+
94
+
95
+ def _append_synth_final(cfg: Config, improved: Trace,
96
+ source: Trace) -> Optional[Trace]:
97
+ """Close a trace that the writer left mid-call with a synthesized answer.
98
+
99
+ The writer stops after a tool call in ~3% of traces (output cap or turn
100
+ cap), leaving an artifact that is one row short of valid. A full repair
101
+ round rewrites the whole trace and usually reproduces the same cut, so
102
+ the cheap fix is the targeted one: ask for the closing answer only,
103
+ grounded in the tail that already exists. Same helper the splice path
104
+ uses for raw traces that were cut off mid-run.
105
+ """
106
+ from traceforge.splice import _resolve_endpoint, _synthesize_final_answer
107
+
108
+ try:
109
+ base, key = _resolve_endpoint(cfg)
110
+ except Exception as e:
111
+ print(f" tool-tail synth skipped: endpoint {type(e).__name__}: {e}")
112
+ return None
113
+ ans, err = _synthesize_final_answer(
114
+ cfg, improved, source.first_task_message() or "", base, key)
115
+ if not ans:
116
+ print(f" tool-tail synth failed: {(err or 'empty answer')[:120]}")
117
+ return None
118
+ improved.messages.append(Message(role="assistant", content=ans))
119
+ return improved
120
+
121
+ CLIP_MARKER_ERROR = (
122
+ "artifact contains rendering clip markers (…[+N chars clipped]) "
123
+ "with no verifiable counts — reviewers reject these. Rewrite the tool "
124
+ "results verbatim from outputs.jsonl (contiguous portions fine)."
125
+ )
126
+
127
+ # Per-workdir file recording which gate rounds already PASSED for which
128
+ # artifact content digest (see _load_gate_cache).
129
+ GATE_CACHE_FILE = "gate_cache.json"
130
+
131
+ # Bump whenever a JUDGE/GATE STANDARD changes (new pillar rule, ANSWER
132
+ # note wording that changes verdicts, regex gate changes) — cached
133
+ # passes recorded under an older standard must not replay. The value
134
+ # rides in the cache file; _load_gate_cache discards stale files.
135
+ GATE_CACHE_STANDARDS = "2026-10-08.1" # +answer-focus rule (P1 FOCUS, over-answering = poor) # +write-JSON deliverable forms, tool-tail carry, exact-args dup reads # +mixed-turn surgical drops, usage-error detect, planner >=2 + write-fix chains # +identity scans tool args (Co-Authored-By), degenerate prose gate # +judge sees reasoning channel
136
+
137
+
138
+ def _clip_markers_present(candidate) -> bool:
139
+ """True if any message content carries a rendering clip marker."""
140
+ blob = "\n".join((m.content or "") for m in candidate.messages)
141
+ return bool(CLIP_MARKER_PROBE.search(blob))
142
+
143
+
144
+ def _artifact_digest(candidate) -> str:
145
+ """Stable content hash of an improved artifact.
146
+
147
+ Gate passes are keyed on this: structural/grounding/verify/review
148
+ PASSes are only replayed from cache when the artifact byte-content is
149
+ unchanged (the file may still have been rewritten with identical
150
+ bytes — that's fine, it's the same artifact).
151
+ """
152
+ import hashlib
153
+
154
+ h = hashlib.sha256()
155
+ for m in candidate.messages:
156
+ h.update((m.role or "").encode("utf-8"))
157
+ h.update(b"\x00")
158
+ h.update((m.content or "").encode("utf-8"))
159
+ h.update(b"\x01")
160
+ if m.tool_call_id:
161
+ h.update(m.tool_call_id.encode("utf-8"))
162
+ h.update(b"\x01")
163
+ if m.tool_name:
164
+ h.update(m.tool_name.encode("utf-8"))
165
+ h.update(b"\x02")
166
+ if m.tool_calls:
167
+ for tc in m.tool_calls:
168
+ h.update((tc.id or "").encode("utf-8"))
169
+ h.update((tc.name or "").encode("utf-8"))
170
+ h.update((tc.arguments or "").encode("utf-8"))
171
+ h.update(b"\x03")
172
+ return h.hexdigest()
173
+
174
+
175
+ class ImproverResult:
176
+ def __init__(
177
+ self,
178
+ *,
179
+ session_id: str,
180
+ ok: bool,
181
+ improved: Optional[Trace],
182
+ reply: str,
183
+ hermes_session_id: Optional[str] = None,
184
+ errors: Optional[list[str]] = None,
185
+ attempts: int = 0,
186
+ duration_s: float = 0.0,
187
+ ):
188
+ self.session_id = session_id # source trace session id
189
+ self.ok = ok
190
+ self.improved = improved
191
+ self.reply = reply
192
+ self.hermes_session_id = hermes_session_id
193
+ self.errors = errors or []
194
+ self.attempts = attempts
195
+ self.duration_s = duration_s
196
+
197
+ def to_dict(self) -> dict:
198
+ return {
199
+ "session_id": self.session_id,
200
+ "ok": self.ok,
201
+ "errors": self.errors,
202
+ "hermes_session_id": self.hermes_session_id,
203
+ "attempts": self.attempts,
204
+ "duration_s": round(self.duration_s, 1),
205
+ }
206
+
207
+
208
+ def _run_hermes(
209
+ cfg: Config, prompt_path: str, workdir: str
210
+ ) -> tuple[int, str, Optional[str], str]:
211
+ """Invoke hermes chat one-shot; returns (exit_code, stdout, session_id)."""
212
+ cmd = [
213
+ "hermes", "chat",
214
+ "--query-file", prompt_path,
215
+ "--in", workdir,
216
+ "-Q",
217
+ "--format", "text",
218
+ "--pass-session-id",
219
+ ]
220
+ if cfg.model:
221
+ cmd += ["-m", cfg.model]
222
+ if cfg.provider:
223
+ cmd += ["--provider", cfg.provider]
224
+ if cfg.max_turns:
225
+ cmd += ["--max-turns", str(cfg.max_turns)]
226
+ if cfg.run_budget:
227
+ cmd += ["--run-budget", str(cfg.run_budget)]
228
+ if cfg.skills:
229
+ cmd += ["-s", ",".join(cfg.skills)]
230
+ proc = subprocess.run(
231
+ cmd,
232
+ capture_output=True,
233
+ text=True,
234
+ timeout=cfg.harness_timeout_s,
235
+ cwd=workdir,
236
+ )
237
+ session_id = None
238
+ for line in (proc.stdout or "").splitlines():
239
+ if line.strip().startswith("session_id:"):
240
+ session_id = line.split(":", 1)[1].strip() or None
241
+ return proc.returncode, proc.stdout, session_id, proc.stderr
242
+
243
+
244
+ def _parse_improved(path: str) -> Optional[Trace]:
245
+ """Parse the improver-written improved.json into a Trace, leniently."""
246
+ if not os.path.exists(path):
247
+ return None
248
+ with open(path, "r", encoding="utf-8") as f:
249
+ raw = f.read()
250
+ # tolerate fenced code blocks from chatty models
251
+ raw = raw.strip()
252
+ if raw.startswith("```"):
253
+ raw = raw.split("\n", 1)[1] if "\n" in raw else ""
254
+ if raw.rstrip().endswith("```"):
255
+ raw = raw.rstrip()[: -3]
256
+ data = json_loads_lenient(raw)
257
+ if not isinstance(data, dict):
258
+ return None
259
+ msgs = data.get("messages")
260
+ if not isinstance(msgs, list):
261
+ return None
262
+ from traceforge.trace import Message, ToolCall
263
+
264
+ out = []
265
+ for d in msgs:
266
+ if not isinstance(d, dict) or "role" not in d:
267
+ return None
268
+ m = {
269
+ "role": d["role"],
270
+ "content": d.get("content"),
271
+ "reasoning": d.get("reasoning"),
272
+ "tool_call_id": d.get("tool_call_id"),
273
+ "tool_name": d.get("tool_name"),
274
+ }
275
+ m = {k: v for k, v in m.items() if v is not None}
276
+ if "tool_calls" in d and isinstance(d["tool_calls"], list):
277
+ m["tool_calls"] = [
278
+ {
279
+ "id": str(tc.get("id", "")),
280
+ "name": str(tc.get("name", "")),
281
+ "arguments": tc.get("arguments") or "{}",
282
+ }
283
+ for tc in d["tool_calls"]
284
+ if isinstance(tc, dict)
285
+ ]
286
+ out.append(Message.from_dict(m))
287
+ return Trace(
288
+ session_id="improved-placeholder", # set by caller
289
+ messages=out,
290
+ improved=True,
291
+ )
292
+
293
+
294
+ def _append_status(cfg: Config, sid: str, status: dict) -> None:
295
+ """Append a stage-level status row to the manifest."""
296
+ import json as _json
297
+
298
+ os.makedirs(os.path.dirname(cfg.manifest_path), exist_ok=True)
299
+ row = {"session_id": sid, **status}
300
+ with open(cfg.manifest_path, "a", encoding="utf-8") as f:
301
+ f.write(_json.dumps(row, ensure_ascii=False) + "\n")
302
+
303
+
304
+ def _load_gate_cache(workdir: str) -> dict:
305
+ """Read the workdir's gate-pass cache (digest -> list of gate rows).
306
+
307
+ Batches die at arbitrary points (turn interruptions, terminal close,
308
+ SIGTERM to the worker pool); without this, every verify/review round
309
+ already PAID FOR on an unchanged artifact is re-run from scratch on
310
+ the next resume. The cache is keyed by the artifact's content digest
311
+ so a rewritten (different) artifact never inherits stale passes.
312
+ Cached passes from an OLDER standards version are discarded — judge
313
+ rules evolve, and a verdict recorded under old rules must not
314
+ replay against new ones.
315
+ """
316
+ path = os.path.join(workdir, GATE_CACHE_FILE)
317
+ try:
318
+ with open(path, "r", encoding="utf-8") as f:
319
+ data = json.load(f)
320
+ if not isinstance(data, dict):
321
+ return {}
322
+ if data.get("standards") != GATE_CACHE_STANDARDS:
323
+ return {}
324
+ return data
325
+ except (OSError, json.JSONDecodeError):
326
+ return {}
327
+
328
+
329
+ def _save_gate_cache(workdir: str, cache: dict) -> None:
330
+ """Persist the gate-pass cache atomically (workdir, per trace)."""
331
+ path = os.path.join(workdir, GATE_CACHE_FILE)
332
+ tmp = path + ".tmp"
333
+ cache = dict(cache)
334
+ cache["standards"] = GATE_CACHE_STANDARDS
335
+ with open(tmp, "w", encoding="utf-8") as f:
336
+ json.dump(cache, f, ensure_ascii=False)
337
+ os.replace(tmp, path)
338
+
339
+
340
+ def _cache_gate_pass(cache: dict, digest: str, stage: str, round_no: int) -> None:
341
+ """Record a gate PASS for one artifact digest."""
342
+ rows = cache.setdefault(digest, [])
343
+ key = f"{stage}:{round_no}"
344
+ if key not in rows:
345
+ rows.append(key)
346
+
347
+
348
+ def _acquire_workdir_lock(workdir: str) -> bool:
349
+ """O_EXCL pid lockfile; locks left by dead processes are stolen.
350
+
351
+ Two concurrent improve batches working the same sid corrupt each
352
+ other (shared improved.json / verdict.json — the verifier unlinks its
353
+ verdict file before each run) and burn double model time. The lock
354
+ makes one-improver-per-workdir structural, not a user discipline.
355
+ """
356
+ lock_path = os.path.join(workdir, ".lock")
357
+ my_pid = str(os.getpid())
358
+ for _ in range(3):
359
+ try:
360
+ fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
361
+ try:
362
+ os.write(fd, my_pid.encode())
363
+ finally:
364
+ os.close(fd)
365
+ return True
366
+ except FileExistsError:
367
+ try:
368
+ other = open(lock_path, encoding="utf-8").read().strip()
369
+ except OSError:
370
+ continue
371
+ pid = int(other) if other.isdigit() else -1
372
+ alive = False
373
+ if pid > 0:
374
+ try:
375
+ os.kill(pid, 0)
376
+ alive = True
377
+ except ProcessLookupError:
378
+ alive = False
379
+ except PermissionError:
380
+ alive = True # exists, not ours: assume alive
381
+ except OverflowError:
382
+ alive = False
383
+ except OSError:
384
+ # Windows raises WinError 87 ("Falscher Parameter") for a
385
+ # pid that does not exist instead of ProcessLookupError,
386
+ # so an uncaught OSError here crashed the stale-lock
387
+ # steal and left the lock in place forever. Any other
388
+ # OSError means we cannot prove the owner is alive, and
389
+ # stealing a stale lock is the safe direction: the work
390
+ # is resumable, a permanently stuck lock is not.
391
+ alive = False
392
+ if alive:
393
+ return False
394
+ try:
395
+ os.unlink(lock_path) # stale lock from a dead run: steal
396
+ except OSError:
397
+ pass
398
+ return False
399
+
400
+
401
+ def _release_workdir_lock(workdir: str) -> None:
402
+ """Drop our own lock (never another live run's).
403
+
404
+ The handle must be CLOSED before the unlink: Windows refuses to delete a
405
+ file that is still open, so the old `with open(...): os.unlink(...)` form
406
+ raised PermissionError, was swallowed by `except OSError`, and left every
407
+ workdir holding a dead-pid lock forever. POSIX allows unlinking an open
408
+ file, which is why this only ever showed up on Windows.
409
+ """
410
+ lock_path = os.path.join(workdir, ".lock")
411
+ try:
412
+ with open(lock_path, encoding="utf-8") as f:
413
+ owner = f.read().strip()
414
+ except OSError:
415
+ return
416
+ if owner != str(os.getpid()):
417
+ return
418
+ try:
419
+ os.unlink(lock_path)
420
+ except OSError:
421
+ pass
422
+
423
+
424
+ def _write_outputs_index(
425
+ cfg: Config, trace: Trace, workdir: str, max_chars: int = 250_000
426
+ ) -> str:
427
+ """Full real tool outputs as outputs.jsonl, keyed by rendering index.
428
+
429
+ The compact rendering clips each tool result to a short head; P4
430
+ demands verbatim reuse, so the improver needs the unclipped text.
431
+ One JSON object per line: {"i": N, "tool", "args", "output"} where N
432
+ is the same [N] message index the rendering shows — the improver can
433
+ correlate a clipped result in its prompt with the full text here.
434
+
435
+ max_chars is a byte-blow safety valve (measured corpus max real
436
+ output: ~111KB), NOT a compression budget: the 20k cap this used to
437
+ apply hid exactly the mid-tail evidence (p95 = 20k) that P4 demands
438
+ the improver quote — on exactly the big traces where review kills
439
+ artifacts — forcing composed pseudo-outputs. Outputs beyond the
440
+ safety valve carry an honest total-marker, never a fake count.
441
+ """
442
+ path = os.path.join(workdir, "outputs.jsonl")
443
+ arg_of: dict[str, str] = {}
444
+ for m in trace.messages:
445
+ for tc in m.tool_calls:
446
+ arg_of[tc.id] = tc.arguments or "{}"
447
+ with open(path, "w", encoding="utf-8") as f:
448
+ for i, m in enumerate(trace.messages, 1):
449
+ if m.role != "tool" or not m.content:
450
+ continue
451
+ out = m.content
452
+ if len(out) > max_chars:
453
+ out = out[:max_chars] + f" …[+{len(out) - max_chars} chars total]"
454
+ f.write(json.dumps({
455
+ "i": i,
456
+ "tool": m.tool_name or "?",
457
+ "args": arg_of.get(m.tool_call_id or "", "")[:400],
458
+ "output": out,
459
+ }, ensure_ascii=False) + "\n")
460
+ return path
461
+
462
+
463
+ def _generate_single(cfg, trace, rendered, workdir, output_path, errors, prev_path, sid):
464
+ """One single-shot improver invocation. Returns (candidate, reply, hsid, code, stderr) or raises Timeout."""
465
+ from traceforge.classify import detect_language
466
+ prompt = build_prompt(
467
+ rendered_trace=rendered,
468
+ guidelines_path=cfg.guidelines_path,
469
+ output_path=output_path,
470
+ outputs_path=os.path.join(workdir, "outputs.jsonl"),
471
+ errors=errors,
472
+ prev_path=prev_path,
473
+ language=detect_language(trace.first_task_message() or ""),
474
+ )
475
+ prompt_path = write_prompt_file(cfg.prompts_dir, sid, prompt)
476
+ try:
477
+ code, stdout, hsid, stderr = _run_hermes(cfg, prompt_path, workdir)
478
+ except subprocess.TimeoutExpired as e:
479
+ raise e
480
+ reply = (stdout or "").strip()
481
+ return _parse_improved(output_path), reply, hsid, code, stderr
482
+
483
+
484
+ def improve_trace(
485
+ cfg: Config,
486
+ trace: Trace,
487
+ *,
488
+ max_attempts: int = 2,
489
+ validator=None,
490
+ status_cb=None,
491
+ ) -> ImproverResult:
492
+ """Run the agentic improver on one trace, with a validation repair loop.
493
+
494
+ Long traces (rendered size > cfg.chunk_max_chars) go through the staged
495
+ map-digest-write pipeline instead of the single shot. Every admitted
496
+ trace passes: N independent verification rounds (web fact-check) and
497
+ M independent review rounds (quality-pillar review) — all must PASS.
498
+
499
+ ``status_cb`` (optional) receives short human stage strings
500
+ ("improving", "digest", "verify 1/2", ...) for live progress display.
501
+ """
502
+ from traceforge.trace import Trace as _T
503
+ from traceforge.validate import validate_structural
504
+ from traceforge.grounding import validate_grounding
505
+ from traceforge.improver.verify import run_verifier
506
+ from traceforge.improver.review import run_review
507
+
508
+ def _status(s: str) -> None:
509
+ if status_cb is not None:
510
+ status_cb(s)
511
+
512
+ started = time.time()
513
+ sid = trace.session_id
514
+ workdir = os.path.join(cfg.improver_workdir, sid)
515
+ os.makedirs(workdir, exist_ok=True)
516
+ # One improver per workdir, structurally: concurrent batches on the
517
+ # same sid race on improved.json/verdict.json and pay the model twice.
518
+ if not _acquire_workdir_lock(workdir):
519
+ return ImproverResult(
520
+ session_id=sid, ok=False, improved=None, reply="",
521
+ hermes_session_id=None,
522
+ errors=["workdir locked by a live improver run — skipping"],
523
+ attempts=0, duration_s=0.0,
524
+ )
525
+ try:
526
+ return _improve_locked(cfg, trace, max_attempts, status_cb)
527
+ finally:
528
+ _release_workdir_lock(workdir)
529
+
530
+
531
+ def _improve_locked(
532
+ cfg: Config, trace: Trace, max_attempts: int, status_cb
533
+ ) -> ImproverResult:
534
+ from traceforge.trace import Trace as _T
535
+ from traceforge.validate import validate_structural
536
+ from traceforge.grounding import validate_grounding
537
+ from traceforge.improver.verify import run_verifier
538
+ from traceforge.improver.review import run_review
539
+
540
+ def _status(s: str) -> None:
541
+ if status_cb is not None:
542
+ status_cb(s)
543
+
544
+ started = time.time()
545
+ sid = trace.session_id
546
+ workdir = os.path.join(cfg.improver_workdir, sid)
547
+ rendered = trace.render_compact(
548
+ user_chars=cfg.user_chars,
549
+ assistant_chars=cfg.assistant_chars,
550
+ tool_result_chars=cfg.tool_result_chars,
551
+ tool_args_chars=cfg.tool_args_chars,
552
+ include_reasoning=cfg.include_reasoning,
553
+ )
554
+ output_path = os.path.join(workdir, "improved.json")
555
+ # Full unclipped tool outputs on disk: P4's verbatim-reuse rule is
556
+ # only satisfiable when the improver can actually see the full text.
557
+ outputs_path = _write_outputs_index(cfg, trace, workdir)
558
+
559
+ errors: list[str] = []
560
+ prev_path: Optional[str] = None
561
+ attempts = 0
562
+ improved: Optional[Trace] = None
563
+ reply = ""
564
+ hermes_session_id: Optional[str] = None
565
+ # long-trace attempts don't run hermes chat directly (stages do); the
566
+ # loop below must not touch single-shot navigation vars on that path
567
+ code = 0
568
+ stderr = ""
569
+
570
+ for attempt in range(1, max_attempts + 1):
571
+ attempts = attempt
572
+ candidate = None
573
+ # Fast-path: when a previous run already left an artifact in this
574
+ # workdir, re-adjudicate it first (regate). Only pay the model for
575
+ # a fresh write when nothing usable exists on disk. This makes
576
+ # "rerun after a gate change" nearly free instead of a full
577
+ # re-write of every trace.
578
+ if attempt == 1 and os.path.exists(output_path):
579
+ _status("regate")
580
+ candidate = _parse_improved(output_path)
581
+ # Static pre-flight: an artifact that still carries rendering
582
+ # clip markers is dead on arrival at review (reviewers kill
583
+ # invented counts; grounding silently strips them). Fail it
584
+ # here — one static check instead of 4 deep-thinker
585
+ # sessions re-discovering it.
586
+ if candidate is not None and _clip_markers_present(candidate):
587
+ candidate = None
588
+ errors = [CLIP_MARKER_ERROR]
589
+ # keep the failed artifact as the repair reference: the
590
+ # next attempt must FIX it (REPAIR_HEADER + errors), not
591
+ # blind-rewrite from a fresh TASK_HEADER that would
592
+ # silently drop the specific clip-marker error.
593
+ prev_path = output_path
594
+ if candidate is not None:
595
+ reply = "REGATE (existing artifact)"
596
+ code = 0
597
+ stderr = ""
598
+ elif len(rendered) <= cfg.chunk_max_chars:
599
+ _status("improving" if attempt == 1 else f"repair {attempt}")
600
+ try:
601
+ candidate, reply_one, hsid, code, stderr = _generate_single(
602
+ cfg, trace, rendered, workdir, output_path, errors,
603
+ prev_path, sid,
604
+ )
605
+ except subprocess.TimeoutExpired:
606
+ errors = [f"harness timeout after {cfg.harness_timeout_s}s"]
607
+ continue
608
+ reply = reply_one
609
+ hermes_session_id = hsid
610
+ if reply.startswith("REJECTED"):
611
+ errors = [f"rejected by improver: {reply[:300]}"]
612
+ break
613
+ else:
614
+ # long-trace staged pipeline: digest -> map -> writer
615
+ from traceforge.improver import longtrace
616
+
617
+ chunk_dir = os.path.join(workdir, "chunks")
618
+ if prev_path is None:
619
+ _status("digest+map")
620
+ chunks = chunker.chunk_trace(
621
+ trace,
622
+ max_chars=cfg.chunk_max_chars,
623
+ overlap_messages=cfg.chunk_overlap_messages,
624
+ render_kwargs={
625
+ "user_chars": cfg.user_chars,
626
+ "assistant_chars": cfg.assistant_chars,
627
+ "tool_result_chars": cfg.tool_result_chars,
628
+ "tool_args_chars": cfg.tool_args_chars,
629
+ "include_reasoning": cfg.include_reasoning,
630
+ },
631
+ )
632
+ if not chunks:
633
+ errors = ["chunking produced no chunks"]
634
+ continue
635
+ longtrace.write_chunks(chunk_dir, chunks, trace)
636
+ _status(f"digest 0/{len(chunks)}")
637
+ digests, derrs = longtrace.digest_all(
638
+ cfg, trace, chunks, workdir,
639
+ progress_cb=lambda done, total: _status(
640
+ f"digest {done}/{total}"
641
+ ),
642
+ )
643
+ if derrs:
644
+ errors = [f"digest failures: {derrs}"]
645
+ continue
646
+ _status(f"map ({len(chunks)} chunks)")
647
+ map_d, merr = longtrace.build_map(cfg, trace, digests, workdir)
648
+ if map_d is None:
649
+ errors = [f"map merge failed: {merr}"]
650
+ continue
651
+ _append_status(cfg, sid, {
652
+ "stage": "map", "chunks": len(chunks),
653
+ "approx_tokens": sum(c.approx_chars for c in chunks) // 4,
654
+ })
655
+ # writer (also used for repair attempts on long traces)
656
+ map_d = longtrace._json_file(os.path.join(workdir, "session_map.json"))
657
+ if map_d is None:
658
+ errors = ["session map missing after long-trace stage"]
659
+ continue
660
+ _status("writer")
661
+ candidate, werr = longtrace.run_writer(
662
+ cfg, trace, map_d, chunk_dir, workdir, output_path,
663
+ repair_errors=errors if prev_path else None,
664
+ )
665
+ if candidate is None:
666
+ errors = [f"long-trace writer failed: {werr}"]
667
+ continue
668
+ reply = "WRITER DONE"
669
+ # The artifact on disk is the source of truth — NOT the exit code.
670
+ # The improver may hit its turn cap after writing a perfectly valid
671
+ # file (e.g. right after a repair); validate what exists regardless.
672
+ candidate = _parse_improved(output_path)
673
+ if candidate is None:
674
+ if code != 0:
675
+ stderr_tail = f"; stderr: {stderr.strip()[-500:]}" if stderr else ""
676
+ errors = [
677
+ f"hermes chat exited with code {code} and "
678
+ f"improved.json missing/unparseable{stderr_tail}"
679
+ ]
680
+ else:
681
+ errors = [
682
+ "improved.json missing, unparseable, or missing 'messages'"
683
+ ]
684
+ continue
685
+ # Static clip-marker gate on EVERY path, not just regate: writers
686
+ # and repairs can still copy markers out of the clipped rendering.
687
+ # A marker artifact is dead on arrival at review (and grounding
688
+ # silently forgives it) — fail fast here instead of burning
689
+ # verifier + reviewer sessions on it.
690
+ if _clip_markers_present(candidate):
691
+ errors = [f"clip-marker gate: {CLIP_MARKER_ERROR}"]
692
+ # repair must fix THIS artifact (REPAIR_HEADER + errors), not
693
+ # blind-rewrite from a TASK_HEADER that drops the specifics
694
+ prev_path = output_path
695
+ continue
696
+ prev_path = output_path
697
+ improved = candidate
698
+ # Pipeline framing markers ("[trace segment k of n]", "[subagent
699
+ # brief — role: ...]") belong to the improver's INPUT; strip them
700
+ # before the gates so the echo check compares against the real task
701
+ # and the promoted artifact carries no scaffolding.
702
+ from traceforge.validate import strip_scaffold_markers
703
+ if strip_scaffold_markers(improved):
704
+ improved.save(output_path)
705
+ from traceforge.validate import strip_harness_citation
706
+ if strip_harness_citation(improved):
707
+ improved.save(output_path)
708
+ # Narration rows ("Writing the report.") are filler the trainee must
709
+ # never learn; drop them before the gates so the echo/grounding
710
+ # checks see the real turn sequence.
711
+ if _drop_narration_rows(improved):
712
+ improved.save(output_path)
713
+ # A vendor co-author trailer in the source's commit args is an
714
+ # identity burn the gate rejects; strip it before the gates so the
715
+ # repair round is not spent reproducing it.
716
+ if _strip_identity_trailers_trace(improved):
717
+ improved.save(output_path)
718
+ errs = validate_structural(improved)
719
+ if errs and any(_EMPTY_ROW_RE in e for e in errs):
720
+ # blank assistant rows are pure noise — drop them and re-check
721
+ # rather than paying a full rewrite to remove one empty row.
722
+ # Runs even when other errors are present: a trace with a blank
723
+ # row AND a tool tail must get both fixes, not neither.
724
+ if _drop_empty_assistant_rows(improved):
725
+ improved.save(output_path) # repair must start from the fix
726
+ errs = validate_structural(improved)
727
+ if not errs:
728
+ _status("dropped empty assistant rows")
729
+ if _only_tool_tail(errs):
730
+ # writer stopped mid-call: one row short of valid. Synthesize
731
+ # the closing answer instead of paying a full repair round
732
+ # that reproduces the same cut (measured: 49/1535 traces).
733
+ fixed = _append_synth_final(cfg, improved, trace)
734
+ if fixed is not None:
735
+ improved = fixed
736
+ # persist BEFORE re-validating: a later repair round reads
737
+ # prev_path from disk, so an in-memory-only fix is thrown
738
+ # away and the writer reproduces the same tool tail.
739
+ improved.save(output_path)
740
+ errs = validate_structural(improved)
741
+ if not errs:
742
+ _status("final answer (tool-tail synth)")
743
+ if not errs:
744
+ errs = validate_grounding(improved, trace)
745
+ if not errs and (cfg.verify or cfg.review):
746
+ # Gate-pass cache: rounds that already PASSED for THIS exact
747
+ # artifact content are replayed instead of re-paid. Survives
748
+ # batch interruptions (turn cap, SIGTERM, terminal close); a
749
+ # rewritten artifact has a different digest and re-runs all
750
+ # rounds fresh.
751
+ digest = _artifact_digest(improved)
752
+ gate_cache = _load_gate_cache(workdir)
753
+ gate_pass_keys = set(gate_cache.get(digest, []))
754
+
755
+ def _gate_pass(stage: str, round_no: int) -> None:
756
+ _cache_gate_pass(gate_cache, digest, stage, round_no)
757
+ _save_gate_cache(workdir, gate_cache)
758
+
759
+ verify_failures: list[str] = []
760
+ # ---- N independent verification rounds (web fact-check)
761
+ # Strict admission: every round must end PASS. A transient ERROR
762
+ # (timeout, unparseable verdict) gets ONE in-place retry; a
763
+ # second ERROR surfaces as an error row — never silent
764
+ # under-verification, never a model repair for infra issues.
765
+ if cfg.verify:
766
+ passes = 0
767
+ vr = 1
768
+ while vr <= cfg.verify_rounds:
769
+ if f"verify:{vr}" in gate_pass_keys:
770
+ passes += 1 # already adjudicated on identical bytes
771
+ _status(f"verify {vr}/{cfg.verify_rounds} (cached)")
772
+ _append_status(cfg, sid, {
773
+ "stage": "verify", "round": vr,
774
+ "verdict": "PASS", "cached": True,
775
+ })
776
+ vr += 1
777
+ continue
778
+ _status(f"verify {vr}/{cfg.verify_rounds}")
779
+ vres = run_verifier(cfg, output_path, workdir)
780
+ if vres.verdict == "PASS":
781
+ passes += 1
782
+ _gate_pass("verify", vr)
783
+ _append_status(cfg, sid, {
784
+ "stage": "verify", "round": vr,
785
+ "verdict": "PASS",
786
+ "claims_checked": vres.claims_checked,
787
+ })
788
+ vr += 1
789
+ continue
790
+ if vres.verdict == "FAIL":
791
+ claims = "\n".join(
792
+ f'- "{u.get("claim", "?")}" — {u.get("why", "?")}'
793
+ for u in vres.unsupported[:10]
794
+ )
795
+ verify_failures.append(
796
+ f"[verify r{vr}] external claims unsupported:\n{claims}"
797
+ )
798
+ _append_status(cfg, sid, {
799
+ "stage": "verify", "round": vr, "verdict": "FAIL",
800
+ "claims_checked": vres.claims_checked,
801
+ })
802
+ break # concrete FAIL: repair next attempt
803
+ # transient ERROR: one in-place retry of the same round
804
+ retry = run_verifier(cfg, output_path, workdir)
805
+ if retry.verdict == "PASS":
806
+ passes += 1
807
+ _gate_pass("verify", vr)
808
+ _append_status(cfg, sid, {
809
+ "stage": "verify", "round": vr,
810
+ "verdict": "PASS", "retried": True,
811
+ "claims_checked": retry.claims_checked,
812
+ })
813
+ vr += 1
814
+ continue
815
+ if retry.verdict == "FAIL":
816
+ claims = "\n".join(
817
+ f'- "{u.get("claim", "?")}" — {u.get("why", "?")}'
818
+ for u in retry.unsupported[:10]
819
+ )
820
+ verify_failures.append(
821
+ f"[verify r{vr}] external claims unsupported:\n{claims}"
822
+ )
823
+ _append_status(cfg, sid, {
824
+ "stage": "verify", "round": vr, "verdict": "FAIL",
825
+ "retried": True,
826
+ "claims_checked": retry.claims_checked,
827
+ })
828
+ break
829
+ verify_failures.append(
830
+ f"[verify r{vr}] infra error x2: "
831
+ f"{vres.error or 'verifier error'} / "
832
+ f"{retry.error or 'verifier error'}"
833
+ )
834
+ _append_status(cfg, sid, {
835
+ "stage": "verify", "round": vr, "verdict": "ERROR",
836
+ "error": (
837
+ f"{vres.error or 'verifier error'} / "
838
+ f"{retry.error or 'verifier error'}"
839
+ )[:300],
840
+ })
841
+ break
842
+ # ---- M independent review rounds (quality pillars)
843
+ review_failures: list[str] = []
844
+ task_snip = trace.first_task_message() or "(no user message)"
845
+ if not verify_failures and cfg.review:
846
+ for rr in range(1, cfg.review_rounds + 1):
847
+ if f"review:{rr}" in gate_pass_keys:
848
+ _status(f"review {rr}/{cfg.review_rounds} (cached)")
849
+ _append_status(cfg, sid, {
850
+ "stage": "review", "round": rr, "verdict": "PASS",
851
+ "cached": True,
852
+ })
853
+ continue
854
+ _status(f"review {rr}/{cfg.review_rounds}")
855
+ rres = run_review(
856
+ cfg, output_path, task_snip, workdir, rr
857
+ )
858
+ if not rres.ok() and rres.verdict == "ERROR":
859
+ rres = run_review(
860
+ cfg, output_path, task_snip, workdir, rr
861
+ ) # one in-place retry on transient infra error
862
+ if rres.ok():
863
+ _gate_pass("review", rr)
864
+ _append_status(cfg, sid, {
865
+ "stage": "review", "round": rr, "verdict": "PASS",
866
+ })
867
+ continue
868
+ if rres.verdict == "FAIL":
869
+ review_failures.append(
870
+ "[review r%d] pillar violations:\n%s"
871
+ % (rr, "\n".join(rres.violations[:10]))
872
+ )
873
+ _append_status(cfg, sid, {
874
+ "stage": "review", "round": rr, "verdict": "FAIL",
875
+ })
876
+ break # concrete FAIL: repair next attempt
877
+ review_failures.append(
878
+ f"[review r{rr}] infra error x2: {rres.error or 'review error'}"
879
+ )
880
+ _append_status(cfg, sid, {
881
+ "stage": "review", "round": rr, "verdict": "ERROR",
882
+ "error": (rres.error or "review error")[:300],
883
+ })
884
+ break
885
+ errs = verify_failures + review_failures
886
+ if errs:
887
+ # Infra failures (verifier/reviewer down) are NOT content errors:
888
+ # a model repair cannot fix them. Fail fast without re-running
889
+ # the improver so no model time is wasted on infrastructure.
890
+ if any("infra error x2" in e for e in errs):
891
+ return ImproverResult(
892
+ session_id=sid, ok=False, improved=None, reply=reply,
893
+ hermes_session_id=hermes_session_id,
894
+ errors=errs, attempts=attempts,
895
+ duration_s=time.time() - started,
896
+ )
897
+ errors = errs + (
898
+ [f"note: hermes exit code {code}"] if code != 0 else []
899
+ )
900
+ continue
901
+ improved.session_id = f"ideal-{sid}"
902
+ improved.parent_session_id = sid
903
+ # cfg.model None = default profile; the exact model is recoverable
904
+ # from hermes_session_id (recorded in the manifest).
905
+ improved.model = cfg.model or "hermes-default"
906
+ improved.source = "hermes-one-shot"
907
+ # Dead-end distillation (see traceforge/distill.py): the ideal path
908
+ # routes around the failed calls, so the negative knowledge would be
909
+ # lost. Compress it into the final answer's reasoning channel. Runs
910
+ # after every gate so a rejected block can never cost the trace.
911
+ try:
912
+ from traceforge.distill import distill_into_reasoning
913
+ from traceforge.splice import _resolve_endpoint
914
+ base, key = _resolve_endpoint(cfg)
915
+ block, note = distill_into_reasoning(
916
+ cfg, trace, improved, trace.first_task_message() or "",
917
+ base, key)
918
+ _append_status(cfg, sid, {
919
+ "stage": "distill", "ok": block is not None,
920
+ "note": note[:300]})
921
+ except Exception as e: # never lose a promotable trace
922
+ _append_status(cfg, sid, {
923
+ "stage": "distill", "ok": False,
924
+ "note": f"{type(e).__name__}: {e}"[:300]})
925
+ return ImproverResult(
926
+ session_id=sid, ok=True, improved=improved, reply=reply,
927
+ hermes_session_id=hermes_session_id, errors=[],
928
+ attempts=attempts, duration_s=time.time() - started,
929
+ )
930
+
931
+ return ImproverResult(
932
+ session_id=sid, ok=False, improved=None, reply=reply,
933
+ hermes_session_id=hermes_session_id, errors=errors,
934
+ attempts=attempts, duration_s=time.time() - started,
935
+ )
936
+
937
+
938
+ def cleanup_workdir(cfg: Config, sid: str, keep: bool = False) -> None:
939
+ wd = os.path.join(cfg.improver_workdir, sid)
940
+ if not keep and os.path.isdir(wd):
941
  shutil.rmtree(wd, ignore_errors=True)
traceforge/live.py CHANGED
@@ -199,7 +199,11 @@ class LiveStats:
199
  delta_out = 0
200
  models: dict = {}
201
  for (sess, cwd, tin, tout, msgs, api, running, model) in rows:
202
- key = (cwd or "").rstrip("/").rsplit("/", 1)[-1]
 
 
 
 
203
  d = per.setdefault(key, {
204
  "in": 0, "out": 0, "msgs": 0, "api": 0,
205
  "runs": 0, "running": 0, "rate": 0.0, "models": set(),
 
199
  delta_out = 0
200
  models: dict = {}
201
  for (sess, cwd, tin, tout, msgs, api, running, model) in rows:
202
+ # The per-trace key is the workspace directory name. Splitting on
203
+ # "/" alone leaves the whole path on Windows (cwd uses "\"), so
204
+ # every per_trace() lookup missed and the sublines showed
205
+ # "telemetry pending" forever.
206
+ key = os.path.basename((cwd or "").replace("\\", "/").rstrip("/"))
207
  d = per.setdefault(key, {
208
  "in": 0, "out": 0, "msgs": 0, "api": 0,
209
  "runs": 0, "running": 0, "rate": 0.0, "models": set(),
traceforge/net.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Transient-failure policy for the pipeline's HTTP calls.
2
+
3
+ The writer endpoint answers 503 while a model is loading or its queue is
4
+ saturated, and drops connections (WinError 10054) under load. Both call
5
+ sites used to treat EVERY HTTPError as "unknown request field" and switch
6
+ to the next request variant, so a 503 burned all three variants in a few
7
+ milliseconds and the trace failed with `rewrite windows failed: window
8
+ N-M: HTTP 503`.
9
+
10
+ Measured 2026-10-29 on data/manifest.jsonl: 5146 of 9795 failed traces
11
+ carried an HTTP 503 and 619 a connection reset — 59% of the failure
12
+ population was a transient endpoint condition, and each one burned a full
13
+ attempt plus a re-queue. A 503 is not a schema problem: retry the SAME
14
+ variant after a backoff, and only fall through to the next variant on a
15
+ 4xx that actually names a bad field.
16
+
17
+ The backoff is bounded by the caller's own call deadline, so a genuinely
18
+ dead endpoint still fails fast instead of holding a worker.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import random
23
+ import urllib.error
24
+ from typing import Optional
25
+
26
+ # 408 request timeout, 425 too early, 429 rate limited, 5xx server side.
27
+ # 500/502/504 are included because a proxy in front of a loading model
28
+ # answers them exactly like a 503.
29
+ TRANSIENT_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})
30
+
31
+ # Backoff shape: 2s, 4s, 8s, 16s (capped), plus up to 25% jitter so eight
32
+ # workers that hit the same 503 do not retry in lockstep.
33
+ BASE_DELAY_S = 2.0
34
+ MAX_DELAY_S = 20.0
35
+
36
+
37
+ def is_transient_status(code: Optional[int]) -> bool:
38
+ return code in TRANSIENT_STATUS
39
+
40
+
41
+ def is_transient_error(exc: BaseException) -> bool:
42
+ """True when retrying the same request could plausibly succeed."""
43
+ if isinstance(exc, urllib.error.HTTPError):
44
+ return is_transient_status(getattr(exc, "code", None))
45
+ return isinstance(exc, (urllib.error.URLError, TimeoutError, OSError))
46
+
47
+
48
+ def retry_after_s(exc: BaseException) -> Optional[float]:
49
+ """The server's own Retry-After, when it sent a usable one."""
50
+ headers = getattr(exc, "headers", None)
51
+ if headers is None:
52
+ return None
53
+ try:
54
+ raw = headers.get("Retry-After")
55
+ except Exception:
56
+ return None
57
+ if not raw:
58
+ return None
59
+ try:
60
+ return max(0.0, float(str(raw).strip()))
61
+ except (TypeError, ValueError):
62
+ return None
63
+
64
+
65
+ def backoff_s(attempt: int, exc: Optional[BaseException] = None) -> float:
66
+ """Seconds to wait before retry number `attempt` (0-based)."""
67
+ if exc is not None:
68
+ hinted = retry_after_s(exc)
69
+ if hinted is not None:
70
+ return min(hinted, MAX_DELAY_S)
71
+ delay = min(BASE_DELAY_S * (2 ** max(attempt, 0)), MAX_DELAY_S)
72
+ return delay * (1.0 + random.random() * 0.25)
traceforge/space/app.py CHANGED
@@ -1,499 +1,501 @@
1
- """Gradio app: submit a trace, sponsor a dataset, read the dashboard.
2
-
3
- The UI is deliberately thin — every decision lives in `SpacePipeline` and
4
- `TraceStore`, so the same code runs locally (`python app.py`) and on a
5
- Hugging Face Space (`app.py` at the repo root).
6
-
7
- Tabs
8
- Submit one trace (JSON / JSONL / pasted) -> hash -> queue -> output
9
- Sponsor a JSONL of traces -> hashed, deduped, queued, attributed
10
- Dashboard public stats: corpus, time sensitivity, tasks, sponsors, queue
11
- Browse the stored outputs, by hash
12
- """
13
- from __future__ import annotations
14
-
15
- import html
16
- import json
17
- import os
18
- from typing import Any, Optional
19
-
20
- from traceforge.space.hashing import short_hash
21
- from traceforge.space.ingest import parse_records, read_upload
22
- from traceforge.space.jobs import get_worker
23
- from traceforge.space.pipeline import SpacePipeline, pipeline_ready, space_config
24
- from traceforge.space.stats import bar_rows, dashboard, pct
25
- from traceforge.space.store import DONE, FAILED, QUEUED, RUNNING, TraceStore
26
-
27
- REFRESH_S = 8.0
28
-
29
- CSS = """
30
- .tf-wrap { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
31
- .tf-kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(130px,1fr));
32
- gap:8px; margin:6px 0 14px; }
33
- .tf-kpi { background:#161b22; border:1px solid #2a3140; border-radius:8px;
34
- padding:9px 11px; }
35
- .tf-kpi .v { font-size:21px; font-weight:600; color:#e6edf3; line-height:1.2; }
36
- .tf-kpi .k { font-size:11px; color:#8b949e; text-transform:uppercase;
37
- letter-spacing:.04em; }
38
- .tf-kpi .s { font-size:11px; color:#6e7681; }
39
- .tf-ok { color:#3fb950; } .tf-warn { color:#d29922; } .tf-bad { color:#f85149; }
40
- .tf-bar-row { display:grid; grid-template-columns:190px 1fr 64px; gap:8px;
41
- align-items:center; font-size:12px; color:#9aa4b2; margin:3px 0; }
42
- .tf-bar-outer { background:#1b2029; border-radius:6px; height:14px;
43
- overflow:hidden; border:1px solid #2a3140; }
44
- .tf-bar-inner { height:100%; background:linear-gradient(90deg,#2f81f7,#3fb950); }
45
- .tf-head { font-size:13px; color:#8b949e; margin:2px 0 8px; }
46
- .tf-pill { padding:2px 8px; border-radius:999px; font-size:11px;
47
- border:1px solid #2a3140; }
48
- """
49
-
50
-
51
- # --------------------------------------------------------------------- html
52
-
53
- def _esc(s: Any) -> str:
54
- return html.escape(str(s if s is not None else ""))
55
-
56
-
57
- def _kpi(value: Any, key: str, sub: str = "", cls: str = "") -> str:
58
- sub_html = f'<div class="s">{_esc(sub)}</div>' if sub else ""
59
- return (f'<div class="tf-kpi"><div class="v {cls}">{_esc(value)}</div>'
60
- f'<div class="k">{_esc(key)}</div>{sub_html}</div>')
61
-
62
-
63
- def _bars(counts: dict, total: Optional[int] = None) -> str:
64
- rows = bar_rows(counts, total=total)
65
- if not rows:
66
- return '<div class="tf-head">no data yet</div>'
67
- out = ['<div class="tf-wrap">']
68
- for label, n, p in rows:
69
- out.append(
70
- '<div class="tf-bar-row">'
71
- f'<span>{_esc(label)}</span>'
72
- f'<span class="tf-bar-outer"><span class="tf-bar-inner" '
73
- f'style="width:{max(0.0, min(100.0, p)):.1f}%"></span></span>'
74
- f'<span>{n} · {p:.0f}%</span></div>'
75
- )
76
- out.append("</div>")
77
- return "".join(out)
78
-
79
-
80
- def _status_pill(status: str, no_change: bool = False) -> str:
81
- if no_change:
82
- return '<span class="tf-pill tf-warn">no change</span>'
83
- cls = {DONE: "tf-ok", FAILED: "tf-bad", RUNNING: "tf-warn",
84
- QUEUED: "tf-warn"}.get(status, "")
85
- return f'<span class="tf-pill {cls}">{_esc(status)}</span>'
86
-
87
-
88
- def _kpi_html(space: dict, ref: dict, worker: dict, ready: tuple[bool, str]) -> str:
89
- ok, reason = ready
90
- cards = [
91
- _kpi(f"{space['seeds']:,}", "seeds", "distinct content hashes"),
92
- _kpi(f"{space['done']:,}", "outputs",
93
- f"{pct(space['done'], space['seeds']):.0f}% of seeds"),
94
- _kpi(f"{space['queued']:,}", "queued",
95
- f"{space['running']} running"),
96
- _kpi(f"{space['failed']:,}", "failed", "errored or rejected"),
97
- _kpi(f"{space.get('no_change', 0):,}", "no change",
98
- "seed already ideal"),
99
- _kpi(f"{len(space['sponsors'])}", "sponsors",
100
- f"{sum(s['seeds'] for s in space['sponsors'])} seeds"),
101
- _kpi(f"{space['duration_s']['median']:.0f}s", "median runtime",
102
- f"p90 {space['duration_s']['p90']:.0f}s"),
103
- ]
104
- if ref.get("available"):
105
- cards.append(_kpi(f"{ref.get('traces', 0):,}", "reference corpus",
106
- "published ideal traces"))
107
- head = (
108
- '<div class="tf-wrap"><div class="tf-head">'
109
- f'<span class="tf-pill {"tf-ok" if ok else "tf-bad"}">'
110
- f'{"pipeline ready" if ok else "pipeline not configured"}</span> '
111
- f'{_esc(reason)} · worker {"alive" if worker.get("alive") else "stopped"}'
112
- f' · queue {worker.get("queued", 0)}'
113
- "</div></div>"
114
- )
115
- return head + '<div class="tf-wrap"><div class="tf-kpis">' + "".join(cards) + "</div></div>"
116
-
117
-
118
- def _ts_html(space: dict, ref: dict) -> str:
119
- """Time sensitivity: the metric that says whether an answer decays."""
120
- parts = ['<div class="tf-wrap"><div class="tf-head">'
121
- "time sensitivity — a temporal fact answered from weights decays; "
122
- "the same fact fetched with a tool does not</div>"]
123
- space_ts = space.get("time_sensitivity") or {}
124
- if space_ts:
125
- parts.append("<b>this Space</b>")
126
- parts.append(_bars(space_ts))
127
- census = (ref or {}).get("time_sensitivity") or {}
128
- if census.get("available"):
129
- parts.append(f"<b>reference corpus</b> ({census.get('total', 0):,} traces)")
130
- parts.append(_bars(census.get("classes") or {}))
131
- parts.append(
132
- f'<div class="tf-head">needs rewrite (tool available but never '
133
- f'called): {census.get("needs_rewrite", 0):,}</div>')
134
- if not space_ts and not census.get("available"):
135
- parts.append('<div class="tf-head">no data yet</div>')
136
- parts.append("</div>")
137
- return "".join(parts)
138
-
139
-
140
- # ------------------------------------------------------------------- tables
141
-
142
- def _rows(records: list[dict], cols: list[str]) -> list[list]:
143
- out = []
144
- for r in records:
145
- row = []
146
- for c in cols:
147
- v = r.get(c)
148
- if c == "hash":
149
- v = short_hash(v or "")
150
- elif c == "time_sensitivity":
151
- v = (v or {}).get("class", "")
152
- elif isinstance(v, float):
153
- v = round(v, 1)
154
- row.append(v if v is not None else "")
155
- out.append(row)
156
- return out
157
-
158
-
159
- # ---------------------------------------------------------------------- app
160
-
161
- def build_app(store: Optional[TraceStore] = None,
162
- pipeline: Optional[SpacePipeline] = None):
163
- import gradio as gr
164
-
165
- cfg = pipeline.cfg if pipeline else space_config()
166
- store = store or (pipeline.store if pipeline else TraceStore(
167
- os.path.join(cfg.data_dir)))
168
- pipeline = pipeline or SpacePipeline(store, cfg)
169
- worker = get_worker(pipeline)
170
- ready = pipeline_ready(cfg)
171
- dataset_dir = os.path.join(os.path.dirname(cfg.data_dir), "dataset")
172
- cache_path = os.path.join(store.root, "reference_census.json")
173
-
174
- # ------------------------------------------------------------- submit
175
-
176
- def on_submit(file_path, pasted, sponsor, sync):
177
- if not ready:
178
- return (f"**pipeline not configured** — {ready[1]}. Nothing was "
179
- "queued.", None, None)
180
- if file_path:
181
- records, errors = read_upload(file_path)
182
- else:
183
- records, errors = parse_records(pasted or "")
184
- if errors:
185
- return (f"**parse errors**\n\n" + "\n".join(f"- {e}" for e in errors[:10]),
186
- None, None)
187
- if not records:
188
- return "**nothing to submit** — upload a file or paste JSON", None, None
189
- if len(records) > 1:
190
- return (f"**{len(records)} records** — the Submit tab takes one trace. "
191
- "Use **Sponsor a dataset** for a batch.", None, None)
192
- try:
193
- res = pipeline.submit(records[0], sponsor=sponsor)
194
- except Exception as e:
195
- return f"**submit failed**: {e}", None, None
196
- h = res["hash"]
197
- if res.get("duplicate") and res.get("status") == DONE:
198
- path = store.output_path(h)
199
- return (f"**already processed** — this seed hash already has an output.\n\n"
200
- f"`{h}`\n\nNo second output was created.", h,
201
- path if os.path.exists(path) else None)
202
- if sync:
203
- res = pipeline.process(h)
204
- rec = pipeline.result(h)
205
- md = _result_md(rec)
206
- path = store.output_path(h) if rec.get("status") == DONE else None
207
- return md, h, path if path and os.path.exists(path) else None
208
-
209
- def on_check(h):
210
- if not h:
211
- return "no hash yet — submit a trace first", None
212
- rec = pipeline.result(h)
213
- path = store.output_path(h) if rec.get("status") == DONE else None
214
- return _result_md(rec), path if path and os.path.exists(path) else None
215
-
216
- def on_health():
217
- from traceforge.space.local_llm import health
218
- return health()
219
-
220
- def on_probe():
221
- from traceforge.space.local_llm import probe
222
- r = probe()
223
- if r.get("ok"):
224
- return f"**local model answered:** `{r['text']}`"
225
- return f"**local model failed:** `{r['error']}`"
226
-
227
- def on_debug():
228
- from traceforge.triage import _PARSE_DEBUG, _TRIAGE_DEBUG
229
- return {"triage": list(_TRIAGE_DEBUG), "parse": list(_PARSE_DEBUG)}
230
-
231
- def _result_md(rec: dict) -> str:
232
- h = rec.get("hash") or ""
233
- st = rec.get("status") or "unknown"
234
- lines = [f"### {_status_pill(st, bool(rec.get('no_change')))} `{h}`", ""]
235
- if rec.get("duplicate"):
236
- lines.append("_duplicate submission — the existing record was returned_")
237
- lines.append("")
238
- if rec.get("task"):
239
- lines.append(f"- task: **{rec['task']}** · language: **{rec.get('language', '?')}**")
240
- if rec.get("n_messages"):
241
- lines.append(f"- seed: {rec['n_messages']} messages · "
242
- f"{rec.get('n_tool_calls', 0)} tool calls")
243
- if rec.get("n_messages_out"):
244
- lines.append(f"- output: {rec['n_messages_out']} messages")
245
- if rec.get("route"):
246
- lines.append(f"- route: `{rec['route']}` · attempts: {rec.get('attempts', 0)}")
247
- if rec.get("endpoint"):
248
- lines.append(f"- endpoint: `{rec['endpoint']}`")
249
- if rec.get("duration_s"):
250
- lines.append(f"- runtime: {rec['duration_s']:.0f}s")
251
- ts = rec.get("time_sensitivity") or {}
252
- if ts.get("class"):
253
- lines.append(f"- time sensitivity: **{ts['class']}** — {ts.get('label', '')}")
254
- if ts.get("temporal_facts"):
255
- lines.append(f" - temporal facts: {', '.join(ts['temporal_facts'])}")
256
- if ts.get("retrieval_available"):
257
- lines.append(f" - retrieval available: {', '.join(ts['retrieval_available'])}")
258
- if ts.get("needs_rewrite"):
259
- lines.append(" - the raw trace answered from weights although a "
260
- "retrieval tool was available")
261
- ts2 = rec.get("time_sensitivity_improved") or {}
262
- if ts2.get("class") and ts2.get("class") != ts.get("class"):
263
- lines.append(f"- after rewrite: **{ts2['class']}**")
264
- if rec.get("error"):
265
- lines.append(f"- error: `{rec['error'][:600]}`")
266
- if st == QUEUED:
267
- lines.append("")
268
- lines.append("_queued — the worker picks it up in submission order. "
269
- "Press **Check status** to refresh._")
270
- return "\n".join(lines)
271
-
272
- # ------------------------------------------------------------ sponsor
273
-
274
- def on_sponsor(file_path, pasted, sponsor):
275
- if not ready:
276
- return (f"**pipeline not configured** — {ready[1]}. Nothing was "
277
- "queued.", [], "")
278
- if file_path:
279
- records, errors = read_upload(file_path)
280
- else:
281
- records, errors = parse_records(pasted or "")
282
- if errors:
283
- return (f"**parse errors**\n\n" + "\n".join(f"- {e}" for e in errors[:10]),
284
- [], "")
285
- if not records:
286
- return "**nothing to sponsor** — upload a JSONL of traces", [], ""
287
- results = pipeline.submit_many(records, sponsor=sponsor)
288
- new = sum(1 for r in results if not r.get("duplicate") and r.get("hash"))
289
- dup = sum(1 for r in results if r.get("duplicate"))
290
- err = sum(1 for r in results if not r.get("hash"))
291
- rows = []
292
- for r in results:
293
- rows.append([
294
- short_hash(r.get("hash") or ""),
295
- r.get("status") or "error",
296
- "duplicate" if r.get("duplicate") else ("new" if r.get("hash") else "error"),
297
- r.get("n_messages", ""),
298
- r.get("task", "")[:60] if r.get("task") else "",
299
- (r.get("time_sensitivity") or {}).get("class", ""),
300
- (r.get("error") or "")[:120],
301
- ])
302
- md = (f"**{len(records)} records** — {new} new, {dup} already known, "
303
- f"{err} rejected. Sponsor: **{_esc(sponsor or 'anonymous')}**.\n\n"
304
- "New seeds are queued; each one produces at most one output.")
305
- return md, rows, sponsor or "anonymous"
306
-
307
- # ---------------------------------------------------------- dashboard
308
-
309
- def on_dashboard():
310
- d = dashboard(store, dataset_dir=dataset_dir, cache_path=cache_path)
311
- space, ref = d["space"], d["reference"]
312
- w = worker.status()
313
- kpis = _kpi_html(space, ref, w, ready)
314
- ts = _ts_html(space, ref)
315
- tasks = bar_rows(space.get("tasks") or {})
316
- langs = bar_rows(space.get("languages") or {})
317
- sponsors = [[s["sponsor"], s["seeds"], s["done"], s["failed"]]
318
- for s in space.get("sponsors") or []]
319
- recent = _rows(d["recent"], ["hash", "status", "sponsor", "task",
320
- "time_sensitivity", "duration_s"])
321
- queue = _rows(d["queue"], ["hash", "sponsor", "task", "n_messages"])
322
- ref_rows = []
323
- if ref.get("available"):
324
- card = ref.get("card") or {}
325
- for k, v in (card.get("tasks") or {}).items():
326
- ref_rows.append([k, v, pct(v, card.get("traces", 0))])
327
- return kpis, ts, tasks, langs, sponsors, recent, queue, ref_rows
328
-
329
- # ------------------------------------------------------------- browse
330
-
331
- def browse_choices():
332
- done = [r for r in store.all() if r.get("status") == DONE]
333
- done.sort(key=lambda r: r.get("updated_at") or 0, reverse=True)
334
- return gr.update(choices=[r["hash"] for r in done][:500])
335
-
336
- def on_browse(h):
337
- if not h:
338
- return None, None
339
- out = store.load_output(h)
340
- if out is None:
341
- return None, None
342
- return out.to_dict(), store.output_path(h)
343
-
344
- # ---------------------------------------------------------------- UI
345
-
346
- with gr.Blocks(title="traceforge — ideal-world agent traces") as app:
347
- gr.Markdown(
348
- "# traceforge\n"
349
- "Turn a real agent trace into its **ideal-world reference**: the same "
350
- "work, with the dead ends removed, gated for structure and grounding.\n\n"
351
- "Every seed is identified by a **content hash** — submitting the same "
352
- "trace twice returns the existing output instead of creating a second one."
353
- )
354
-
355
- with gr.Tab("Submit a trace"):
356
- with gr.Row():
357
- with gr.Column(scale=3):
358
- file_in = gr.File(label="trace file (.json / .jsonl)",
359
- file_types=[".json", ".jsonl"],
360
- type="filepath")
361
- paste_in = gr.Textbox(
362
- label="…or paste a trace (JSON, JSONL, or a message list)",
363
- lines=10, placeholder='{"messages": [{"role": "user", ...}]}')
364
- with gr.Column(scale=2):
365
- sponsor_in = gr.Textbox(label="your name / handle (optional)",
366
- max_lines=1)
367
- sync_in = gr.Checkbox(
368
- label="process synchronously (debug — blocks until done)",
369
- value=os.environ.get("TRACEFORGE_SPACE_SYNC") == "1")
370
- btn_submit = gr.Button("Submit", variant="primary")
371
- btn_check = gr.Button("Check status")
372
- last_hash = gr.State("")
373
- status_md = gr.Markdown("_no submission yet_")
374
- out_file = gr.File(label="improved trace (download)", interactive=False)
375
- with gr.Accordion("Local model (ZeroGPU)", open=False):
376
- gr.Markdown(
377
- "With no remote endpoint configured, the Space runs "
378
- "`Qwen/Qwen2.5-3B-Instruct` on ZeroGPU. GPU time is charged "
379
- "to **your** HF daily quota, not to the Space owner. Local "
380
- "mode handles seeds up to 24 KB.")
381
- btn_health = gr.Button("Check local model")
382
- health_json = gr.JSON(label="local model status")
383
- btn_probe = gr.Button("Test local model (loads it, ~60 s)")
384
- probe_md = gr.Markdown()
385
- btn_debug = gr.Button("Last triage replies")
386
- debug_json = gr.JSON(label="triage debug")
387
- btn_health.click(on_health, outputs=[health_json])
388
- btn_probe.click(on_probe, outputs=[probe_md])
389
- btn_debug.click(on_debug, outputs=[debug_json])
390
-
391
- btn_submit.click(on_submit,
392
- inputs=[file_in, paste_in, sponsor_in, sync_in],
393
- outputs=[status_md, last_hash, out_file])
394
- btn_check.click(on_check, inputs=[last_hash],
395
- outputs=[status_md, out_file])
396
-
397
- with gr.Tab("Sponsor a dataset"):
398
- gr.Markdown(
399
- "Upload a JSONL of traces. Each row is hashed; **new** seeds are "
400
- "queued for the pipeline and attributed to you, **known** seeds are "
401
- "skipped. The processed rows join the public output dataset."
402
- )
403
- with gr.Row():
404
- with gr.Column(scale=3):
405
- sp_file = gr.File(label="dataset (.jsonl)", file_types=[".jsonl"],
406
- type="filepath")
407
- sp_paste = gr.Textbox(label="…or paste JSONL", lines=8)
408
- with gr.Column(scale=2):
409
- sp_name = gr.Textbox(label="sponsor name", max_lines=1)
410
- sp_btn = gr.Button("Sponsor", variant="primary")
411
- sp_md = gr.Markdown("_no dataset sponsored yet_")
412
- sp_table = gr.Dataframe(
413
- headers=["hash", "status", "new?", "messages", "task",
414
- "time sensitivity", "error"],
415
- interactive=False, wrap=True, max_height=420,
416
- )
417
- sp_credit = gr.State("")
418
- sp_btn.click(on_sponsor, inputs=[sp_file, sp_paste, sp_name],
419
- outputs=[sp_md, sp_table, sp_credit])
420
-
421
- with gr.Tab("Dashboard"):
422
- gr.Markdown("Public statistics. Refreshes automatically.")
423
- dash_kpis = gr.HTML()
424
- dash_ts = gr.HTML()
425
- with gr.Row():
426
- with gr.Column():
427
- gr.Markdown("### task mix (this Space)")
428
- dash_tasks = gr.Dataframe(
429
- headers=["task", "seeds", "pct"], interactive=False,
430
- wrap=True, max_height=300)
431
- with gr.Column():
432
- gr.Markdown("### languages")
433
- dash_langs = gr.Dataframe(
434
- headers=["language", "seeds", "pct"], interactive=False,
435
- wrap=True, max_height=300)
436
- with gr.Row():
437
- with gr.Column():
438
- gr.Markdown("### sponsors")
439
- dash_sponsors = gr.Dataframe(
440
- headers=["sponsor", "seeds", "done", "failed"],
441
- interactive=False, wrap=True, max_height=300)
442
- with gr.Column():
443
- gr.Markdown("### reference corpus — task mix")
444
- dash_ref = gr.Dataframe(
445
- headers=["task", "traces", "pct"], interactive=False,
446
- wrap=True, max_height=300)
447
- gr.Markdown("### recent outputs")
448
- dash_recent = gr.Dataframe(
449
- headers=["hash", "status", "sponsor", "task",
450
- "time sensitivity", "runtime s"],
451
- interactive=False, wrap=True, max_height=320)
452
- gr.Markdown("### queue")
453
- dash_queue = gr.Dataframe(
454
- headers=["hash", "sponsor", "task", "messages"],
455
- interactive=False, wrap=True, max_height=240)
456
-
457
- dash_outputs = [dash_kpis, dash_ts, dash_tasks, dash_langs,
458
- dash_sponsors, dash_recent, dash_queue, dash_ref]
459
- timer = gr.Timer(REFRESH_S)
460
- timer.tick(on_dashboard, outputs=dash_outputs)
461
- app.load(on_dashboard, outputs=dash_outputs)
462
-
463
- with gr.Tab("Browse outputs"):
464
- with gr.Row():
465
- pick = gr.Dropdown(label="processed seed (by hash)", choices=[],
466
- scale=4)
467
- btn_reload = gr.Button("Reload list", scale=1)
468
- preview = gr.JSON(label="improved trace", max_height=520)
469
- dl = gr.File(label="download", interactive=False)
470
- btn_reload.click(browse_choices, outputs=[pick])
471
- pick.change(on_browse, inputs=[pick], outputs=[preview, dl])
472
- app.load(browse_choices, outputs=[pick])
473
-
474
- return app
475
-
476
-
477
- def main(argv: Optional[list[str]] = None) -> int:
478
- import argparse
479
-
480
- import gradio as gr
481
-
482
- ap = argparse.ArgumentParser(description="traceforge Gradio app")
483
- ap.add_argument("--host", default=os.environ.get("GRADIO_SERVER_NAME",
484
- "127.0.0.1"))
485
- ap.add_argument("--port", type=int,
486
- default=int(os.environ.get("GRADIO_SERVER_PORT", "7860")))
487
- ap.add_argument("--share", action="store_true")
488
- args = ap.parse_args(argv)
489
-
490
- app = build_app()
491
- app.queue(default_concurrency_limit=4)
492
- app.launch(server_name=args.host, server_port=args.port,
493
- share=args.share, inbrowser=False, show_error=True,
494
- css=CSS, theme=gr.themes.Base())
495
- return 0
496
-
497
-
498
- if __name__ == "__main__":
499
- raise SystemExit(main())
 
 
 
1
+ """Gradio app: submit a trace, sponsor a dataset, read the dashboard.
2
+
3
+ The UI is deliberately thin — every decision lives in `SpacePipeline` and
4
+ `TraceStore`, so the same code runs locally (`python app.py`) and on a
5
+ Hugging Face Space (`app.py` at the repo root).
6
+
7
+ Tabs
8
+ Submit one trace (JSON / JSONL / pasted) -> hash -> queue -> output
9
+ Sponsor a JSONL of traces -> hashed, deduped, queued, attributed
10
+ Dashboard public stats: corpus, time sensitivity, tasks, sponsors, queue
11
+ Browse the stored outputs, by hash
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import html
16
+ import json
17
+ import os
18
+ from typing import Any, Optional
19
+
20
+ from traceforge.space.hashing import short_hash
21
+ from traceforge.space.ingest import parse_records, read_upload
22
+ from traceforge.space.jobs import get_worker
23
+ from traceforge.space.pipeline import SpacePipeline, pipeline_ready, space_config
24
+ from traceforge.space.stats import bar_rows, dashboard, pct
25
+ from traceforge.space.store import DONE, FAILED, QUEUED, RUNNING, TraceStore
26
+
27
+ REFRESH_S = 8.0
28
+
29
+ CSS = """
30
+ .tf-wrap { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
31
+ .tf-kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(130px,1fr));
32
+ gap:8px; margin:6px 0 14px; }
33
+ .tf-kpi { background:#161b22; border:1px solid #2a3140; border-radius:8px;
34
+ padding:9px 11px; }
35
+ .tf-kpi .v { font-size:21px; font-weight:600; color:#e6edf3; line-height:1.2; }
36
+ .tf-kpi .k { font-size:11px; color:#8b949e; text-transform:uppercase;
37
+ letter-spacing:.04em; }
38
+ .tf-kpi .s { font-size:11px; color:#6e7681; }
39
+ .tf-ok { color:#3fb950; } .tf-warn { color:#d29922; } .tf-bad { color:#f85149; }
40
+ .tf-bar-row { display:grid; grid-template-columns:190px 1fr 64px; gap:8px;
41
+ align-items:center; font-size:12px; color:#9aa4b2; margin:3px 0; }
42
+ .tf-bar-outer { background:#1b2029; border-radius:6px; height:14px;
43
+ overflow:hidden; border:1px solid #2a3140; }
44
+ .tf-bar-inner { height:100%; background:linear-gradient(90deg,#2f81f7,#3fb950); }
45
+ .tf-head { font-size:13px; color:#8b949e; margin:2px 0 8px; }
46
+ .tf-pill { padding:2px 8px; border-radius:999px; font-size:11px;
47
+ border:1px solid #2a3140; }
48
+ """
49
+
50
+
51
+ # --------------------------------------------------------------------- html
52
+
53
+ def _esc(s: Any) -> str:
54
+ return html.escape(str(s if s is not None else ""))
55
+
56
+
57
+ def _kpi(value: Any, key: str, sub: str = "", cls: str = "") -> str:
58
+ sub_html = f'<div class="s">{_esc(sub)}</div>' if sub else ""
59
+ return (f'<div class="tf-kpi"><div class="v {cls}">{_esc(value)}</div>'
60
+ f'<div class="k">{_esc(key)}</div>{sub_html}</div>')
61
+
62
+
63
+ def _bars(counts: dict, total: Optional[int] = None) -> str:
64
+ rows = bar_rows(counts, total=total)
65
+ if not rows:
66
+ return '<div class="tf-head">no data yet</div>'
67
+ out = ['<div class="tf-wrap">']
68
+ for label, n, p in rows:
69
+ out.append(
70
+ '<div class="tf-bar-row">'
71
+ f'<span>{_esc(label)}</span>'
72
+ f'<span class="tf-bar-outer"><span class="tf-bar-inner" '
73
+ f'style="width:{max(0.0, min(100.0, p)):.1f}%"></span></span>'
74
+ f'<span>{n} · {p:.0f}%</span></div>'
75
+ )
76
+ out.append("</div>")
77
+ return "".join(out)
78
+
79
+
80
+ def _status_pill(status: str, no_change: bool = False) -> str:
81
+ if no_change:
82
+ return '<span class="tf-pill tf-warn">no change</span>'
83
+ cls = {DONE: "tf-ok", FAILED: "tf-bad", RUNNING: "tf-warn",
84
+ QUEUED: "tf-warn"}.get(status, "")
85
+ return f'<span class="tf-pill {cls}">{_esc(status)}</span>'
86
+
87
+
88
+ def _kpi_html(space: dict, ref: dict, worker: dict, ready: tuple[bool, str]) -> str:
89
+ ok, reason = ready
90
+ cards = [
91
+ _kpi(f"{space['seeds']:,}", "seeds", "distinct content hashes"),
92
+ _kpi(f"{space['done']:,}", "outputs",
93
+ f"{pct(space['done'], space['seeds']):.0f}% of seeds"),
94
+ _kpi(f"{space['queued']:,}", "queued",
95
+ f"{space['running']} running"),
96
+ _kpi(f"{space['failed']:,}", "failed", "errored or rejected"),
97
+ _kpi(f"{space.get('no_change', 0):,}", "no change",
98
+ "seed already ideal"),
99
+ _kpi(f"{len(space['sponsors'])}", "sponsors",
100
+ f"{sum(s['seeds'] for s in space['sponsors'])} seeds"),
101
+ _kpi(f"{space['duration_s']['median']:.0f}s", "median runtime",
102
+ f"p90 {space['duration_s']['p90']:.0f}s"),
103
+ ]
104
+ if ref.get("available"):
105
+ cards.append(_kpi(f"{ref.get('traces', 0):,}", "reference corpus",
106
+ "published ideal traces"))
107
+ head = (
108
+ '<div class="tf-wrap"><div class="tf-head">'
109
+ f'<span class="tf-pill {"tf-ok" if ok else "tf-bad"}">'
110
+ f'{"pipeline ready" if ok else "pipeline not configured"}</span> '
111
+ f'{_esc(reason)} · worker {"alive" if worker.get("alive") else "stopped"}'
112
+ f' · queue {worker.get("queued", 0)}'
113
+ "</div></div>"
114
+ )
115
+ return head + '<div class="tf-wrap"><div class="tf-kpis">' + "".join(cards) + "</div></div>"
116
+
117
+
118
+ def _ts_html(space: dict, ref: dict) -> str:
119
+ """Time sensitivity: the metric that says whether an answer decays."""
120
+ parts = ['<div class="tf-wrap"><div class="tf-head">'
121
+ "time sensitivity — a temporal fact answered from weights decays; "
122
+ "the same fact fetched with a tool does not</div>"]
123
+ space_ts = space.get("time_sensitivity") or {}
124
+ if space_ts:
125
+ parts.append("<b>this Space</b>")
126
+ parts.append(_bars(space_ts))
127
+ census = (ref or {}).get("time_sensitivity") or {}
128
+ if census.get("available"):
129
+ parts.append(f"<b>reference corpus</b> ({census.get('total', 0):,} traces)")
130
+ parts.append(_bars(census.get("classes") or {}))
131
+ parts.append(
132
+ f'<div class="tf-head">needs rewrite (tool available but never '
133
+ f'called): {census.get("needs_rewrite", 0):,}</div>')
134
+ if not space_ts and not census.get("available"):
135
+ parts.append('<div class="tf-head">no data yet</div>')
136
+ parts.append("</div>")
137
+ return "".join(parts)
138
+
139
+
140
+ # ------------------------------------------------------------------- tables
141
+
142
+ def _rows(records: list[dict], cols: list[str]) -> list[list]:
143
+ out = []
144
+ for r in records:
145
+ row = []
146
+ for c in cols:
147
+ v = r.get(c)
148
+ if c == "hash":
149
+ v = short_hash(v or "")
150
+ elif c == "time_sensitivity":
151
+ v = (v or {}).get("class", "")
152
+ elif isinstance(v, float):
153
+ v = round(v, 1)
154
+ row.append(v if v is not None else "")
155
+ out.append(row)
156
+ return out
157
+
158
+
159
+ # ---------------------------------------------------------------------- app
160
+
161
+ def build_app(store: Optional[TraceStore] = None,
162
+ pipeline: Optional[SpacePipeline] = None):
163
+ import gradio as gr
164
+
165
+ cfg = pipeline.cfg if pipeline else space_config()
166
+ store = store or (pipeline.store if pipeline else TraceStore(
167
+ os.path.join(cfg.data_dir)))
168
+ pipeline = pipeline or SpacePipeline(store, cfg)
169
+ worker = get_worker(pipeline)
170
+ ready = pipeline_ready(cfg)
171
+ dataset_dir = os.path.join(os.path.dirname(cfg.data_dir), "dataset")
172
+ cache_path = os.path.join(store.root, "reference_census.json")
173
+
174
+ # ------------------------------------------------------------- submit
175
+
176
+ def on_submit(file_path, pasted, sponsor, sync):
177
+ if not ready:
178
+ return (f"**pipeline not configured** — {ready[1]}. Nothing was "
179
+ "queued.", None, None)
180
+ if file_path:
181
+ records, errors = read_upload(file_path)
182
+ else:
183
+ records, errors = parse_records(pasted or "")
184
+ if errors:
185
+ return (f"**parse errors**\n\n" + "\n".join(f"- {e}" for e in errors[:10]),
186
+ None, None)
187
+ if not records:
188
+ return "**nothing to submit** — upload a file or paste JSON", None, None
189
+ if len(records) > 1:
190
+ return (f"**{len(records)} records** — the Submit tab takes one trace. "
191
+ "Use **Sponsor a dataset** for a batch.", None, None)
192
+ try:
193
+ res = pipeline.submit(records[0], sponsor=sponsor)
194
+ except Exception as e:
195
+ return f"**submit failed**: {e}", None, None
196
+ h = res["hash"]
197
+ if res.get("duplicate") and res.get("status") == DONE:
198
+ path = store.output_path(h)
199
+ return (f"**already processed** — this seed hash already has an output.\n\n"
200
+ f"`{h}`\n\nNo second output was created.", h,
201
+ path if os.path.exists(path) else None)
202
+ if sync:
203
+ res = pipeline.process(h)
204
+ rec = pipeline.result(h)
205
+ md = _result_md(rec)
206
+ path = store.output_path(h) if rec.get("status") == DONE else None
207
+ return md, h, path if path and os.path.exists(path) else None
208
+
209
+ def on_check(h):
210
+ if not h:
211
+ return "no hash yet — submit a trace first", None
212
+ rec = pipeline.result(h)
213
+ path = store.output_path(h) if rec.get("status") == DONE else None
214
+ return _result_md(rec), path if path and os.path.exists(path) else None
215
+
216
+ def on_health():
217
+ from traceforge.space.local_llm import health
218
+ return health()
219
+
220
+ def on_probe():
221
+ from traceforge.space.local_llm import probe
222
+ r = probe()
223
+ if r.get("ok"):
224
+ return f"**local model answered:** `{r['text']}`"
225
+ return f"**local model failed:** `{r['error']}`"
226
+
227
+ def on_debug():
228
+ from traceforge.triage import _PARSE_DEBUG, _TRIAGE_DEBUG
229
+ return {"triage": list(_TRIAGE_DEBUG), "parse": list(_PARSE_DEBUG)}
230
+
231
+ def _result_md(rec: dict) -> str:
232
+ h = rec.get("hash") or ""
233
+ st = rec.get("status") or "unknown"
234
+ lines = [f"### {_status_pill(st, bool(rec.get('no_change')))} `{h}`", ""]
235
+ if rec.get("duplicate"):
236
+ lines.append("_duplicate submission — the existing record was returned_")
237
+ lines.append("")
238
+ if rec.get("task"):
239
+ lines.append(f"- task: **{rec['task']}** · language: **{rec.get('language', '?')}**")
240
+ if rec.get("n_messages"):
241
+ lines.append(f"- seed: {rec['n_messages']} messages · "
242
+ f"{rec.get('n_tool_calls', 0)} tool calls")
243
+ if rec.get("n_messages_out"):
244
+ lines.append(f"- output: {rec['n_messages_out']} messages")
245
+ if rec.get("route"):
246
+ lines.append(f"- route: `{rec['route']}` · attempts: {rec.get('attempts', 0)}")
247
+ if rec.get("endpoint"):
248
+ lines.append(f"- endpoint: `{rec['endpoint']}`")
249
+ if rec.get("duration_s"):
250
+ lines.append(f"- runtime: {rec['duration_s']:.0f}s")
251
+ ts = rec.get("time_sensitivity") or {}
252
+ if ts.get("class"):
253
+ lines.append(f"- time sensitivity: **{ts['class']}** — {ts.get('label', '')}")
254
+ if ts.get("temporal_facts"):
255
+ lines.append(f" - temporal facts: {', '.join(ts['temporal_facts'])}")
256
+ if ts.get("retrieval_available"):
257
+ lines.append(f" - retrieval available: {', '.join(ts['retrieval_available'])}")
258
+ if ts.get("needs_rewrite"):
259
+ lines.append(" - the raw trace answered from weights although a "
260
+ "retrieval tool was available")
261
+ ts2 = rec.get("time_sensitivity_improved") or {}
262
+ if ts2.get("class") and ts2.get("class") != ts.get("class"):
263
+ lines.append(f"- after rewrite: **{ts2['class']}**")
264
+ if rec.get("error"):
265
+ lines.append(f"- error: `{rec['error'][:600]}`")
266
+ if st == QUEUED:
267
+ lines.append("")
268
+ lines.append("_queued — the worker picks it up in submission order. "
269
+ "Press **Check status** to refresh._")
270
+ return "\n".join(lines)
271
+
272
+ # ------------------------------------------------------------ sponsor
273
+
274
+ def on_sponsor(file_path, pasted, sponsor):
275
+ if not ready:
276
+ return (f"**pipeline not configured** — {ready[1]}. Nothing was "
277
+ "queued.", [], "")
278
+ if file_path:
279
+ records, errors = read_upload(file_path)
280
+ else:
281
+ records, errors = parse_records(pasted or "")
282
+ if errors:
283
+ return (f"**parse errors**\n\n" + "\n".join(f"- {e}" for e in errors[:10]),
284
+ [], "")
285
+ if not records:
286
+ return "**nothing to sponsor** — upload a JSONL of traces", [], ""
287
+ results = pipeline.submit_many(records, sponsor=sponsor)
288
+ new = sum(1 for r in results if not r.get("duplicate") and r.get("hash"))
289
+ dup = sum(1 for r in results if r.get("duplicate"))
290
+ err = sum(1 for r in results if not r.get("hash"))
291
+ rows = []
292
+ for r in results:
293
+ rows.append([
294
+ short_hash(r.get("hash") or ""),
295
+ r.get("status") or "error",
296
+ "duplicate" if r.get("duplicate") else ("new" if r.get("hash") else "error"),
297
+ r.get("n_messages", ""),
298
+ r.get("task", "")[:60] if r.get("task") else "",
299
+ (r.get("time_sensitivity") or {}).get("class", ""),
300
+ (r.get("error") or "")[:120],
301
+ ])
302
+ md = (f"**{len(records)} records** — {new} new, {dup} already known, "
303
+ f"{err} rejected. Sponsor: **{_esc(sponsor or 'anonymous')}**.\n\n"
304
+ "New seeds are queued; each one produces at most one output.")
305
+ return md, rows, sponsor or "anonymous"
306
+
307
+ # ---------------------------------------------------------- dashboard
308
+
309
+ def on_dashboard():
310
+ d = dashboard(store, dataset_dir=dataset_dir, cache_path=cache_path)
311
+ space, ref = d["space"], d["reference"]
312
+ w = worker.status()
313
+ kpis = _kpi_html(space, ref, w, ready)
314
+ ts = _ts_html(space, ref)
315
+ tasks = bar_rows(space.get("tasks") or {})
316
+ langs = bar_rows(space.get("languages") or {})
317
+ sponsors = [[s["sponsor"], s["seeds"], s["done"], s["failed"]]
318
+ for s in space.get("sponsors") or []]
319
+ recent = _rows(d["recent"], ["hash", "status", "sponsor", "task",
320
+ "time_sensitivity", "duration_s"])
321
+ queue = _rows(d["queue"], ["hash", "sponsor", "task", "n_messages"])
322
+ ref_rows = []
323
+ if ref.get("available"):
324
+ card = ref.get("card") or {}
325
+ for k, v in (card.get("tasks") or {}).items():
326
+ ref_rows.append([k, v, pct(v, card.get("traces", 0))])
327
+ return kpis, ts, tasks, langs, sponsors, recent, queue, ref_rows
328
+
329
+ # ------------------------------------------------------------- browse
330
+
331
+ def browse_choices():
332
+ done = [r for r in store.all() if r.get("status") == DONE]
333
+ done.sort(key=lambda r: r.get("updated_at") or 0, reverse=True)
334
+ return gr.update(choices=[r["hash"] for r in done][:500])
335
+
336
+ def on_browse(h):
337
+ if not h:
338
+ return None, None
339
+ out = store.load_output(h)
340
+ if out is None:
341
+ return None, None
342
+ return out.to_dict(), store.output_path(h)
343
+
344
+ # ---------------------------------------------------------------- UI
345
+
346
+ with gr.Blocks(title="traceforge — ideal-world agent traces") as app:
347
+ gr.Markdown(
348
+ "# traceforge\n"
349
+ "Turn a real agent trace into its **ideal-world reference**: the same "
350
+ "work, with the dead ends removed, gated for structure and grounding.\n\n"
351
+ "Every seed is identified by a **content hash** — submitting the same "
352
+ "trace twice returns the existing output instead of creating a second one."
353
+ )
354
+
355
+ with gr.Tab("Submit a trace"):
356
+ with gr.Row():
357
+ with gr.Column(scale=3):
358
+ file_in = gr.File(label="trace file (.json / .jsonl)",
359
+ file_types=[".json", ".jsonl"],
360
+ type="filepath")
361
+ paste_in = gr.Textbox(
362
+ label="…or paste a trace (JSON, JSONL, or a message list)",
363
+ lines=10, placeholder='{"messages": [{"role": "user", ...}]}')
364
+ with gr.Column(scale=2):
365
+ sponsor_in = gr.Textbox(label="your name / handle (optional)",
366
+ max_lines=1)
367
+ sync_in = gr.Checkbox(
368
+ label="process synchronously (debug — blocks until done)",
369
+ value=os.environ.get("TRACEFORGE_SPACE_SYNC") == "1")
370
+ btn_submit = gr.Button("Submit", variant="primary")
371
+ btn_check = gr.Button("Check status")
372
+ last_hash = gr.State("")
373
+ status_md = gr.Markdown("_no submission yet_")
374
+ out_file = gr.File(label="improved trace (download)", interactive=False)
375
+ with gr.Accordion("Local model (ZeroGPU)", open=False):
376
+ gr.Markdown(
377
+ "With no remote endpoint configured, the Space runs "
378
+ "`LiquidAI/LFM2.5-24B-A2B` on ZeroGPU. GPU time is charged "
379
+ "to **your** HF daily quota, not to the Space owner — the "
380
+ "24B checkpoint needs the 96 GB `xlarge` slice, which "
381
+ "costs 2x quota per second. Local mode handles seeds up "
382
+ "to 24 KB.")
383
+ btn_health = gr.Button("Check local model")
384
+ health_json = gr.JSON(label="local model status")
385
+ btn_probe = gr.Button("Test local model (loads it, ~60 s)")
386
+ probe_md = gr.Markdown()
387
+ btn_debug = gr.Button("Last triage replies")
388
+ debug_json = gr.JSON(label="triage debug")
389
+ btn_health.click(on_health, outputs=[health_json])
390
+ btn_probe.click(on_probe, outputs=[probe_md])
391
+ btn_debug.click(on_debug, outputs=[debug_json])
392
+
393
+ btn_submit.click(on_submit,
394
+ inputs=[file_in, paste_in, sponsor_in, sync_in],
395
+ outputs=[status_md, last_hash, out_file])
396
+ btn_check.click(on_check, inputs=[last_hash],
397
+ outputs=[status_md, out_file])
398
+
399
+ with gr.Tab("Sponsor a dataset"):
400
+ gr.Markdown(
401
+ "Upload a JSONL of traces. Each row is hashed; **new** seeds are "
402
+ "queued for the pipeline and attributed to you, **known** seeds are "
403
+ "skipped. The processed rows join the public output dataset."
404
+ )
405
+ with gr.Row():
406
+ with gr.Column(scale=3):
407
+ sp_file = gr.File(label="dataset (.jsonl)", file_types=[".jsonl"],
408
+ type="filepath")
409
+ sp_paste = gr.Textbox(label="…or paste JSONL", lines=8)
410
+ with gr.Column(scale=2):
411
+ sp_name = gr.Textbox(label="sponsor name", max_lines=1)
412
+ sp_btn = gr.Button("Sponsor", variant="primary")
413
+ sp_md = gr.Markdown("_no dataset sponsored yet_")
414
+ sp_table = gr.Dataframe(
415
+ headers=["hash", "status", "new?", "messages", "task",
416
+ "time sensitivity", "error"],
417
+ interactive=False, wrap=True, max_height=420,
418
+ )
419
+ sp_credit = gr.State("")
420
+ sp_btn.click(on_sponsor, inputs=[sp_file, sp_paste, sp_name],
421
+ outputs=[sp_md, sp_table, sp_credit])
422
+
423
+ with gr.Tab("Dashboard"):
424
+ gr.Markdown("Public statistics. Refreshes automatically.")
425
+ dash_kpis = gr.HTML()
426
+ dash_ts = gr.HTML()
427
+ with gr.Row():
428
+ with gr.Column():
429
+ gr.Markdown("### task mix (this Space)")
430
+ dash_tasks = gr.Dataframe(
431
+ headers=["task", "seeds", "pct"], interactive=False,
432
+ wrap=True, max_height=300)
433
+ with gr.Column():
434
+ gr.Markdown("### languages")
435
+ dash_langs = gr.Dataframe(
436
+ headers=["language", "seeds", "pct"], interactive=False,
437
+ wrap=True, max_height=300)
438
+ with gr.Row():
439
+ with gr.Column():
440
+ gr.Markdown("### sponsors")
441
+ dash_sponsors = gr.Dataframe(
442
+ headers=["sponsor", "seeds", "done", "failed"],
443
+ interactive=False, wrap=True, max_height=300)
444
+ with gr.Column():
445
+ gr.Markdown("### reference corpus — task mix")
446
+ dash_ref = gr.Dataframe(
447
+ headers=["task", "traces", "pct"], interactive=False,
448
+ wrap=True, max_height=300)
449
+ gr.Markdown("### recent outputs")
450
+ dash_recent = gr.Dataframe(
451
+ headers=["hash", "status", "sponsor", "task",
452
+ "time sensitivity", "runtime s"],
453
+ interactive=False, wrap=True, max_height=320)
454
+ gr.Markdown("### queue")
455
+ dash_queue = gr.Dataframe(
456
+ headers=["hash", "sponsor", "task", "messages"],
457
+ interactive=False, wrap=True, max_height=240)
458
+
459
+ dash_outputs = [dash_kpis, dash_ts, dash_tasks, dash_langs,
460
+ dash_sponsors, dash_recent, dash_queue, dash_ref]
461
+ timer = gr.Timer(REFRESH_S)
462
+ timer.tick(on_dashboard, outputs=dash_outputs)
463
+ app.load(on_dashboard, outputs=dash_outputs)
464
+
465
+ with gr.Tab("Browse outputs"):
466
+ with gr.Row():
467
+ pick = gr.Dropdown(label="processed seed (by hash)", choices=[],
468
+ scale=4)
469
+ btn_reload = gr.Button("Reload list", scale=1)
470
+ preview = gr.JSON(label="improved trace", max_height=520)
471
+ dl = gr.File(label="download", interactive=False)
472
+ btn_reload.click(browse_choices, outputs=[pick])
473
+ pick.change(on_browse, inputs=[pick], outputs=[preview, dl])
474
+ app.load(browse_choices, outputs=[pick])
475
+
476
+ return app
477
+
478
+
479
+ def main(argv: Optional[list[str]] = None) -> int:
480
+ import argparse
481
+
482
+ import gradio as gr
483
+
484
+ ap = argparse.ArgumentParser(description="traceforge Gradio app")
485
+ ap.add_argument("--host", default=os.environ.get("GRADIO_SERVER_NAME",
486
+ "127.0.0.1"))
487
+ ap.add_argument("--port", type=int,
488
+ default=int(os.environ.get("GRADIO_SERVER_PORT", "7860")))
489
+ ap.add_argument("--share", action="store_true")
490
+ args = ap.parse_args(argv)
491
+
492
+ app = build_app()
493
+ app.queue(default_concurrency_limit=4)
494
+ app.launch(server_name=args.host, server_port=args.port,
495
+ share=args.share, inbrowser=False, show_error=True,
496
+ css=CSS, theme=gr.themes.Base())
497
+ return 0
498
+
499
+
500
+ if __name__ == "__main__":
501
+ raise SystemExit(main())
traceforge/space/local_llm.py CHANGED
@@ -1,291 +1,406 @@
1
- """Local OpenAI-compatible chat server for the Space's ZeroGPU fallback.
2
-
3
- When no remote endpoint is configured, the Space runs a small instruct
4
- model on Hugging Face ZeroGPU. The pipeline talks to it exactly like a
5
- remote endpoint — a urllib POST to ``/v1/chat/completions`` — so no core
6
- module needs to know the difference.
7
-
8
- ZeroGPU bills the **visitor's** daily quota, not the Space owner's: the
9
- decorated function requests a GPU slice for the duration of one call and
10
- releases it afterwards, so idle time is free. Quotas (2026): anonymous
11
- 2 min/day, free account 5 min/day, PRO 40 min/day.
12
-
13
- The checkpoint is prefetched to disk at startup and loaded to the GPU on
14
- the first request, because a 3B fp16 model is ~6 GB and downloading it
15
- inside a 120 s GPU slice would eat the whole slice.
16
- """
17
- from __future__ import annotations
18
-
19
- import json
20
- import os
21
- import threading
22
- import urllib.request
23
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
24
- from typing import Any, Optional
25
-
26
- LOCAL_MODEL = (os.environ.get("TRACEFORGE_LOCAL_MODEL")
27
- or "Qwen/Qwen2.5-3B-Instruct")
28
- LOCAL_MAX_NEW_TOKENS = int(os.environ.get("TRACEFORGE_LOCAL_MAX_NEW_TOKENS")
29
- or 2000)
30
- LOCAL_MAX_INPUT_TOKENS = int(os.environ.get("TRACEFORGE_LOCAL_MAX_INPUT_TOKENS")
31
- or 28000)
32
- GPU_DURATION_S = int(os.environ.get("TRACEFORGE_GPU_DURATION_S") or 120)
33
- LOCAL_DEVICE = os.environ.get("TRACEFORGE_LOCAL_DEVICE") or "cuda"
34
-
35
- _lock = threading.Lock()
36
- _model: Any = None
37
- _tok: Any = None
38
- _server: Optional[ThreadingHTTPServer] = None
39
- _server_url: Optional[str] = None
40
- LAST_ERROR: Optional[str] = None
41
- REQUESTS = 0
42
- LAST_REQUEST: Optional[dict] = None
43
- LAST_RESPONSE: Optional[str] = None
44
-
45
- # A Space can carry HTTP(S)_PROXY for outbound traffic; urllib would then
46
- # route the loopback call through it and the local server would never see
47
- # the request. Bypass the proxy for loopback before any call is made.
48
- for _var in ("NO_PROXY", "no_proxy"):
49
- _cur = os.environ.get(_var, "")
50
- if "127.0.0.1" not in _cur:
51
- os.environ[_var] = (_cur + ",127.0.0.1,localhost").strip(",")
52
-
53
-
54
- def _gpu(fn):
55
- """``@spaces.GPU`` on a ZeroGPU Space, a no-op everywhere else."""
56
- try:
57
- import spaces # type: ignore
58
- return spaces.GPU(duration=GPU_DURATION_S)(fn)
59
- except Exception:
60
- return fn
61
-
62
-
63
- def _stub() -> bool:
64
- """Wiring tests run without torch: return a canned JSON object."""
65
- return os.environ.get("TRACEFORGE_LOCAL_STUB") == "1"
66
-
67
-
68
- def _load():
69
- global _model, _tok
70
- if _model is not None:
71
- return _model, _tok
72
- import torch
73
- from transformers import AutoModelForCausalLM, AutoTokenizer
74
-
75
- _tok = AutoTokenizer.from_pretrained(LOCAL_MODEL)
76
- _model = AutoModelForCausalLM.from_pretrained(
77
- LOCAL_MODEL,
78
- torch_dtype=torch.float16 if LOCAL_DEVICE == "cuda" else torch.float32,
79
- device_map=LOCAL_DEVICE,
80
- low_cpu_mem_usage=True,
81
- )
82
- _model.eval()
83
- return _model, _tok
84
-
85
-
86
- @_gpu
87
- def generate(messages: list[dict], *, max_tokens: int = 512,
88
- temperature: float = 0.2) -> str:
89
- """One chat completion on the local model (inside a GPU slice)."""
90
- if _stub():
91
- return '{"ok": true}'
92
- import torch
93
-
94
- model, tok = _load()
95
- prompt = tok.apply_chat_template(messages, tokenize=False,
96
- add_generation_prompt=True)
97
- enc = tok(prompt, return_tensors="pt", truncation=True,
98
- max_length=LOCAL_MAX_INPUT_TOKENS).to(LOCAL_DEVICE)
99
- n = max(1, min(int(max_tokens or 512), LOCAL_MAX_NEW_TOKENS))
100
- with torch.inference_mode():
101
- out = model.generate(
102
- **enc,
103
- max_new_tokens=n,
104
- do_sample=temperature > 0,
105
- temperature=max(float(temperature), 0.01),
106
- top_p=0.95,
107
- pad_token_id=tok.eos_token_id,
108
- )
109
- text = tok.decode(out[0][enc["input_ids"].shape[1]:],
110
- skip_special_tokens=True)
111
- return text.strip()
112
-
113
-
114
- def _friendly_error(e: Exception) -> str:
115
- """Turn a ZeroGPU quota rejection into something a visitor can act on."""
116
- msg = f"{type(e).__name__}: {e}"
117
- low = msg.lower()
118
- if "quota" in low or ("gpu" in low and "exceed" in low):
119
- return ("ZeroGPU quota exhausted for this visitor. Anonymous visitors "
120
- "get 2 min/day, a free HF account 5 min/day, PRO 40 min/day. "
121
- "Sign in to huggingface.co or come back tomorrow.")
122
- return msg
123
-
124
-
125
- class _Handler(BaseHTTPRequestHandler):
126
- protocol_version = "HTTP/1.1"
127
-
128
- def log_message(self, format, *args): # keep the Space log readable
129
- pass
130
-
131
- def _send(self, code: int, obj: dict) -> None:
132
- body = json.dumps(obj).encode("utf-8")
133
- self.send_response(code)
134
- self.send_header("Content-Type", "application/json")
135
- self.send_header("Content-Length", str(len(body)))
136
- self.end_headers()
137
- self.wfile.write(body)
138
-
139
- def do_GET(self): # noqa: N802 - BaseHTTPRequestHandler API
140
- if self.path.rstrip("/").endswith("/models"):
141
- self._send(200, {"object": "list", "data": [
142
- {"id": LOCAL_MODEL, "object": "model", "owned_by": "local"}]})
143
- else:
144
- self._send(404, {"error": "not found"})
145
-
146
- def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler API
147
- global REQUESTS, LAST_REQUEST
148
- REQUESTS += 1
149
- if not self.path.rstrip("/").endswith("/chat/completions"):
150
- LAST_REQUEST = {"path": self.path, "error": "not found"}
151
- self._send(404, {"error": "not found"})
152
- return
153
- try:
154
- n = int(self.headers.get("Content-Length") or 0)
155
- payload = json.loads(self.rfile.read(n).decode("utf-8"))
156
- except Exception as e:
157
- LAST_REQUEST = {"path": self.path, "error": f"bad json: {e}"}
158
- self._send(400, {"error": f"bad json: {e}"})
159
- return
160
- messages = list(payload.get("messages") or [])
161
- LAST_REQUEST = {"path": self.path, "bytes": n,
162
- "messages": len(messages),
163
- "max_tokens": payload.get("max_tokens")}
164
- # The pipeline's prompts already ask for JSON, but a small model
165
- # likes to wrap it in prose; make the constraint explicit.
166
- if payload.get("structured_outputs") or payload.get("guided_json"):
167
- messages = [{"role": "system",
168
- "content": "Return only one JSON object. No markdown "
169
- "fences, no commentary."}] + messages
170
- try:
171
- with _lock:
172
- text = generate(
173
- messages,
174
- max_tokens=int(payload.get("max_tokens") or 512),
175
- temperature=float(payload.get("temperature") or 0.2),
176
- )
177
- except Exception as e:
178
- global LAST_ERROR
179
- LAST_ERROR = _friendly_error(e)
180
- import traceback
181
- traceback.print_exc()
182
- self._send(500, {"error": f"local model failed: {LAST_ERROR}"})
183
- return
184
- global LAST_RESPONSE
185
- LAST_RESPONSE = (text or "")[:800]
186
- self._send(200, {
187
- "id": "local-0",
188
- "object": "chat.completion",
189
- "model": LOCAL_MODEL,
190
- "choices": [{
191
- "index": 0,
192
- "finish_reason": "stop",
193
- "message": {"role": "assistant", "content": text},
194
- }],
195
- "usage": {"prompt_tokens": 0, "completion_tokens": 0,
196
- "total_tokens": 0},
197
- })
198
-
199
-
200
- def ensure_local_server() -> str:
201
- """Start the loopback server once; return its ``/v1`` base URL."""
202
- global _server, _server_url
203
- if _server_url:
204
- return _server_url
205
- srv = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
206
- srv.daemon_threads = True
207
- threading.Thread(target=srv.serve_forever, name="local-llm",
208
- daemon=True).start()
209
- _server = srv
210
- _server_url = f"http://127.0.0.1:{srv.server_address[1]}/v1"
211
- return _server_url
212
-
213
-
214
- def prefetch() -> None:
215
- """Download the checkpoint in the background so the first call is fast."""
216
- if _stub():
217
- return
218
-
219
- def _run():
220
- try:
221
- from huggingface_hub import snapshot_download
222
- snapshot_download(LOCAL_MODEL, allow_patterns=[
223
- "*.json", "*.safetensors", "*.txt", "*.model"])
224
- except Exception:
225
- pass
226
-
227
- threading.Thread(target=_run, name="local-llm-prefetch",
228
- daemon=True).start()
229
-
230
-
231
- def local_model_id() -> str:
232
- return LOCAL_MODEL
233
-
234
-
235
- def is_local_url(url: str) -> bool:
236
- return bool(url) and url.startswith("http://127.0.0.1:")
237
-
238
-
239
- def _ping() -> dict:
240
- if not _server_url:
241
- return {"ok": False, "error": "no server"}
242
- try:
243
- with urllib.request.urlopen(_server_url + "/models", timeout=3) as r:
244
- return {"ok": True, "status": r.status}
245
- except Exception as e:
246
- return {"ok": False, "error": f"{type(e).__name__}: {e}"}
247
-
248
-
249
- def probe() -> dict:
250
- """One real chat call through the local server (loads the model)."""
251
- if not _server_url:
252
- return {"ok": False, "error": "no server"}
253
- payload = {
254
- "model": LOCAL_MODEL,
255
- "messages": [{"role": "user",
256
- "content": "Reply with the single word: ok"}],
257
- "max_tokens": 8,
258
- "temperature": 0.0,
259
- }
260
- req = urllib.request.Request(
261
- _server_url + "/chat/completions",
262
- data=json.dumps(payload).encode("utf-8"),
263
- headers={"Content-Type": "application/json"},
264
- method="POST",
265
- )
266
- try:
267
- with urllib.request.urlopen(req, timeout=180) as r:
268
- data = json.loads(r.read().decode("utf-8"))
269
- return {"ok": True,
270
- "text": (data["choices"][0]["message"]["content"] or "")[:200]}
271
- except Exception as e:
272
- return {"ok": False, "error": f"{type(e).__name__}: {e}"}
273
-
274
-
275
- def health() -> dict:
276
- """What the UI shows when the local model misbehaves."""
277
- return {
278
- "model": LOCAL_MODEL,
279
- "device": LOCAL_DEVICE,
280
- "loaded": _model is not None,
281
- "stub": _stub(),
282
- "gpu_duration_s": GPU_DURATION_S,
283
- "server": _server_url or "",
284
- "ping": _ping(),
285
- "requests": REQUESTS,
286
- "last_request": LAST_REQUEST,
287
- "last_response": LAST_RESPONSE,
288
- "proxy": {k: v for k, v in os.environ.items()
289
- if "proxy" in k.lower()},
290
- "last_error": LAST_ERROR,
291
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local OpenAI-compatible chat server for the Space's ZeroGPU fallback.
2
+
3
+ When no remote endpoint is configured, the Space runs LFM2.5-24B-A2B (24B
4
+ total, 2.45B active) on Hugging Face ZeroGPU. The pipeline talks to it
5
+ exactly like a remote endpoint — a urllib POST to
6
+ ``/v1/chat/completions`` — so no core module needs to know the difference.
7
+
8
+ ZeroGPU bills the **visitor's** daily quota, not the Space owner's: the
9
+ decorated function requests a GPU slice for the duration of one call and
10
+ releases it afterwards, so idle time is free. Quotas (2026): anonymous
11
+ 2 min/day, free account 5 min/day, PRO 40 min/day.
12
+
13
+ Three facts drive the configuration here:
14
+
15
+ * The bf16 checkpoint is ~48 GB of weights, so it needs the 96 GB
16
+ ``xlarge`` slice — the default ``large`` slice is 48 GB, i.e. exactly
17
+ the weights and nothing else — and xlarge costs 2x quota per second.
18
+ * 48 GB does not fit the Space's 50 GB ephemeral disk next to torch and
19
+ transformers. Mount the model repo as a read-only volume instead
20
+ (``TRACEFORGE_LOCAL_MOUNT``, see docs/SPACE.md). Only when it is not
21
+ mounted is the checkpoint downloaded, and then free space is checked
22
+ first so the Space is not evicted mid-download.
23
+ * The model always thinks before it answers (the chat template opens a
24
+ ``think`` block), so the reply is stripped back to the answer before it
25
+ reaches the pipeline's JSON parser.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import threading
32
+ import urllib.request
33
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
34
+ from typing import Any, Optional
35
+
36
+ LOCAL_MODEL = (os.environ.get("TRACEFORGE_LOCAL_MODEL")
37
+ or "LiquidAI/LFM2.5-24B-A2B")
38
+ LOCAL_MAX_NEW_TOKENS = int(os.environ.get("TRACEFORGE_LOCAL_MAX_NEW_TOKENS")
39
+ or 4096)
40
+ LOCAL_MAX_INPUT_TOKENS = int(os.environ.get("TRACEFORGE_LOCAL_MAX_INPUT_TOKENS")
41
+ or 28000)
42
+ GPU_DURATION_S = int(os.environ.get("TRACEFORGE_GPU_DURATION_S") or 120)
43
+ # 48 GB of bf16 weights do not fit the 48 GB "large" slice.
44
+ GPU_SIZE = os.environ.get("TRACEFORGE_GPU_SIZE") or "xlarge"
45
+ LOCAL_DEVICE = os.environ.get("TRACEFORGE_LOCAL_DEVICE") or "cuda"
46
+ LOCAL_DTYPE = os.environ.get("TRACEFORGE_LOCAL_DTYPE") or "bfloat16"
47
+ # Where the model repo is mounted read-only (docs/SPACE.md). Loading from a
48
+ # mount keeps 48 GB off the Space's 50 GB ephemeral disk.
49
+ LOCAL_MOUNT = (os.environ.get("TRACEFORGE_LOCAL_MOUNT")
50
+ or "/models/lfm2.5-24b-a2b")
51
+ # Free disk the download needs; the checkpoint is 48 GB and the Space has 50.
52
+ LOCAL_DISK_GB = float(os.environ.get("TRACEFORGE_LOCAL_DISK_GB") or 50)
53
+
54
+ _lock = threading.Lock()
55
+ _model: Any = None
56
+ _tok: Any = None
57
+ _server: Optional[ThreadingHTTPServer] = None
58
+ _server_url: Optional[str] = None
59
+ LAST_ERROR: Optional[str] = None
60
+ REQUESTS = 0
61
+ LAST_REQUEST: Optional[dict] = None
62
+ LAST_RESPONSE: Optional[str] = None
63
+ # "idle" -> "running" -> "done" | "error"; "mounted" when no download is
64
+ # needed. A request that arrives before the checkpoint is on disk must fail
65
+ # fast: downloading 48 GB inside a 120 s GPU slice would burn the whole slice.
66
+ PREFETCH: dict = {"state": "idle", "error": "", "source": ""}
67
+
68
+ # A Space can carry HTTP(S)_PROXY for outbound traffic; urllib would then
69
+ # route the loopback call through it and the local server would never see
70
+ # the request. Bypass the proxy for loopback before any call is made.
71
+ for _var in ("NO_PROXY", "no_proxy"):
72
+ _cur = os.environ.get(_var, "")
73
+ if "127.0.0.1" not in _cur:
74
+ os.environ[_var] = (_cur + ",127.0.0.1,localhost").strip(",")
75
+
76
+
77
+ def _gpu(fn):
78
+ """``@spaces.GPU`` on a ZeroGPU Space, a no-op everywhere else."""
79
+ try:
80
+ import spaces # type: ignore
81
+ except Exception:
82
+ return fn
83
+ try:
84
+ return spaces.GPU(duration=GPU_DURATION_S, size=GPU_SIZE)(fn)
85
+ except TypeError: # `spaces` older than the size argument
86
+ return spaces.GPU(duration=GPU_DURATION_S)(fn)
87
+
88
+
89
+ def _stub() -> bool:
90
+ """Wiring tests run without torch: return a canned JSON object."""
91
+ return os.environ.get("TRACEFORGE_LOCAL_STUB") == "1"
92
+
93
+
94
+ def _model_source() -> str:
95
+ """Local directory when the checkpoint is mounted, else the hub id.
96
+
97
+ A mounted repo is a plain directory of safetensors, so ``from_pretrained``
98
+ reads it in place — no 48 GB copy on the Space's ephemeral disk.
99
+ """
100
+ explicit = (os.environ.get("TRACEFORGE_LOCAL_MODEL_PATH") or "").strip()
101
+ for cand in (explicit, LOCAL_MOUNT):
102
+ if cand and os.path.isfile(os.path.join(cand, "config.json")):
103
+ return cand
104
+ return LOCAL_MODEL
105
+
106
+
107
+ def _torch_dtype():
108
+ import torch
109
+ if LOCAL_DEVICE != "cuda":
110
+ return torch.float32
111
+ return {"bfloat16": torch.bfloat16, "float16": torch.float16,
112
+ "float32": torch.float32}.get(LOCAL_DTYPE, torch.bfloat16)
113
+
114
+
115
+ def _load():
116
+ global _model, _tok
117
+ if _model is not None:
118
+ return _model, _tok
119
+ from transformers import AutoModelForCausalLM, AutoTokenizer
120
+
121
+ src = _model_source()
122
+ _tok = AutoTokenizer.from_pretrained(src)
123
+ kwargs = dict(device_map=LOCAL_DEVICE, low_cpu_mem_usage=True)
124
+ try:
125
+ # transformers >= 5 renamed `torch_dtype` to `dtype`.
126
+ _model = AutoModelForCausalLM.from_pretrained(
127
+ src, dtype=_torch_dtype(), **kwargs)
128
+ except TypeError: # transformers 4.x
129
+ _model = AutoModelForCausalLM.from_pretrained(
130
+ src, torch_dtype=_torch_dtype(), **kwargs)
131
+ _model.eval()
132
+ return _model, _tok
133
+
134
+
135
+ _THINK_END = "</think>"
136
+
137
+
138
+ def _strip_reasoning(text: str) -> str:
139
+ """Drop the model's ``think`` block; LFM2.5 always thinks first.
140
+
141
+ The chat template opens the block, so the reply is
142
+ ``reasoning ... answer``. A reply that never closes the block was cut
143
+ off mid-thought and carries no answer — return "" so the caller sees an
144
+ empty completion instead of parsing reasoning as JSON.
145
+ """
146
+ if not text:
147
+ return ""
148
+ i = text.rfind(_THINK_END)
149
+ if i >= 0:
150
+ return text[i + len(_THINK_END):].strip()
151
+ if "think" in text:
152
+ return ""
153
+ return text.strip()
154
+
155
+
156
+ def _prefetch_error() -> str:
157
+ """Why a request cannot run yet — the checkpoint is not on disk."""
158
+ state = PREFETCH.get("state")
159
+ if state == "error":
160
+ return (f"local model unavailable: {PREFETCH.get('error')}. "
161
+ "Mount the checkpoint as a volume (docs/SPACE.md) or set a "
162
+ "remote endpoint.")
163
+ return (f"local model is still downloading ({LOCAL_MODEL}, ~48 GB). "
164
+ "Try again in a few minutes.")
165
+
166
+
167
+ @_gpu
168
+ def generate(messages: list[dict], *, max_tokens: int = 512,
169
+ temperature: float = 0.2) -> str:
170
+ """One chat completion on the local model (inside a GPU slice)."""
171
+ if _stub():
172
+ return '{"ok": true}'
173
+ if PREFETCH.get("state") not in ("done", "mounted"):
174
+ # Never download inside the slice: 48 GB would eat the whole slice
175
+ # and the visitor's quota with it.
176
+ if PREFETCH.get("state") == "idle":
177
+ prefetch()
178
+ raise RuntimeError(_prefetch_error())
179
+ import torch
180
+
181
+ model, tok = _load()
182
+ prompt = tok.apply_chat_template(messages, tokenize=False,
183
+ add_generation_prompt=True)
184
+ enc = tok(prompt, return_tensors="pt", truncation=True,
185
+ max_length=LOCAL_MAX_INPUT_TOKENS).to(LOCAL_DEVICE)
186
+ n = max(1, min(int(max_tokens or 512), LOCAL_MAX_NEW_TOKENS))
187
+ with torch.inference_mode():
188
+ out = model.generate(
189
+ **enc,
190
+ max_new_tokens=n,
191
+ do_sample=temperature > 0,
192
+ temperature=max(float(temperature), 0.01),
193
+ top_p=0.95,
194
+ # The model card's generation parameters.
195
+ top_k=50,
196
+ repetition_penalty=1.05,
197
+ pad_token_id=tok.eos_token_id,
198
+ )
199
+ text = tok.decode(out[0][enc["input_ids"].shape[1]:],
200
+ skip_special_tokens=True)
201
+ return _strip_reasoning(text)
202
+
203
+
204
+ def _friendly_error(e: Exception) -> str:
205
+ """Turn a ZeroGPU quota rejection into something a visitor can act on."""
206
+ msg = f"{type(e).__name__}: {e}"
207
+ low = msg.lower()
208
+ if "quota" in low or ("gpu" in low and "exceed" in low):
209
+ return ("ZeroGPU quota exhausted for this visitor. Anonymous visitors "
210
+ "get 2 min/day, a free HF account 5 min/day, PRO 40 min/day. "
211
+ "Sign in to huggingface.co or come back tomorrow.")
212
+ return msg
213
+
214
+
215
+ class _Handler(BaseHTTPRequestHandler):
216
+ protocol_version = "HTTP/1.1"
217
+
218
+ def log_message(self, format, *args): # keep the Space log readable
219
+ pass
220
+
221
+ def _send(self, code: int, obj: dict) -> None:
222
+ body = json.dumps(obj).encode("utf-8")
223
+ self.send_response(code)
224
+ self.send_header("Content-Type", "application/json")
225
+ self.send_header("Content-Length", str(len(body)))
226
+ self.end_headers()
227
+ self.wfile.write(body)
228
+
229
+ def do_GET(self): # noqa: N802 - BaseHTTPRequestHandler API
230
+ if self.path.rstrip("/").endswith("/models"):
231
+ self._send(200, {"object": "list", "data": [
232
+ {"id": LOCAL_MODEL, "object": "model", "owned_by": "local"}]})
233
+ else:
234
+ self._send(404, {"error": "not found"})
235
+
236
+ def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler API
237
+ global REQUESTS, LAST_REQUEST
238
+ REQUESTS += 1
239
+ if not self.path.rstrip("/").endswith("/chat/completions"):
240
+ LAST_REQUEST = {"path": self.path, "error": "not found"}
241
+ self._send(404, {"error": "not found"})
242
+ return
243
+ try:
244
+ n = int(self.headers.get("Content-Length") or 0)
245
+ payload = json.loads(self.rfile.read(n).decode("utf-8"))
246
+ except Exception as e:
247
+ LAST_REQUEST = {"path": self.path, "error": f"bad json: {e}"}
248
+ self._send(400, {"error": f"bad json: {e}"})
249
+ return
250
+ messages = list(payload.get("messages") or [])
251
+ LAST_REQUEST = {"path": self.path, "bytes": n,
252
+ "messages": len(messages),
253
+ "max_tokens": payload.get("max_tokens")}
254
+ # The pipeline's prompts already ask for JSON, but a small model
255
+ # likes to wrap it in prose; make the constraint explicit.
256
+ if payload.get("structured_outputs") or payload.get("guided_json"):
257
+ messages = [{"role": "system",
258
+ "content": "Return only one JSON object. No markdown "
259
+ "fences, no commentary."}] + messages
260
+ try:
261
+ with _lock:
262
+ text = generate(
263
+ messages,
264
+ max_tokens=int(payload.get("max_tokens") or 512),
265
+ temperature=float(payload.get("temperature") or 0.2),
266
+ )
267
+ except Exception as e:
268
+ global LAST_ERROR
269
+ LAST_ERROR = _friendly_error(e)
270
+ import traceback
271
+ traceback.print_exc()
272
+ self._send(500, {"error": f"local model failed: {LAST_ERROR}"})
273
+ return
274
+ global LAST_RESPONSE
275
+ LAST_RESPONSE = (text or "")[:800]
276
+ self._send(200, {
277
+ "id": "local-0",
278
+ "object": "chat.completion",
279
+ "model": LOCAL_MODEL,
280
+ "choices": [{
281
+ "index": 0,
282
+ "finish_reason": "stop",
283
+ "message": {"role": "assistant", "content": text},
284
+ }],
285
+ "usage": {"prompt_tokens": 0, "completion_tokens": 0,
286
+ "total_tokens": 0},
287
+ })
288
+
289
+
290
+ def ensure_local_server() -> str:
291
+ """Start the loopback server once; return its ``/v1`` base URL."""
292
+ global _server, _server_url
293
+ if _server_url:
294
+ return _server_url
295
+ srv = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
296
+ srv.daemon_threads = True
297
+ threading.Thread(target=srv.serve_forever, name="local-llm",
298
+ daemon=True).start()
299
+ _server = srv
300
+ _server_url = f"http://127.0.0.1:{srv.server_address[1]}/v1"
301
+ return _server_url
302
+
303
+
304
+ def prefetch() -> None:
305
+ """Make the checkpoint available before the first GPU call.
306
+
307
+ A mounted repo needs nothing. Otherwise the 48 GB download runs in the
308
+ background — but only after checking free disk, because the Space's
309
+ ephemeral disk is 50 GB and an eviction mid-download takes the whole app
310
+ down with it.
311
+ """
312
+ if _stub():
313
+ return
314
+ src = _model_source()
315
+ if src != LOCAL_MODEL:
316
+ PREFETCH.update(state="mounted", error="", source=src)
317
+ return
318
+ if PREFETCH.get("state") in ("running", "done"):
319
+ return
320
+
321
+ def _run():
322
+ PREFETCH.update(state="running", error="", source=LOCAL_MODEL)
323
+ try:
324
+ import shutil
325
+ cache = os.environ.get("HF_HOME") or os.path.expanduser("~")
326
+ free_gb = shutil.disk_usage(cache).free / 1e9
327
+ if free_gb < LOCAL_DISK_GB:
328
+ raise RuntimeError(
329
+ f"needs {LOCAL_DISK_GB:.0f} GB of disk, {free_gb:.0f} GB "
330
+ f"free under {cache}")
331
+ from huggingface_hub import snapshot_download
332
+ snapshot_download(LOCAL_MODEL, allow_patterns=[
333
+ "*.json", "*.safetensors", "*.txt", "*.model"])
334
+ PREFETCH.update(state="done")
335
+ except Exception as e:
336
+ PREFETCH.update(state="error", error=f"{type(e).__name__}: {e}")
337
+
338
+ threading.Thread(target=_run, name="local-llm-prefetch",
339
+ daemon=True).start()
340
+
341
+
342
+ def local_model_id() -> str:
343
+ return LOCAL_MODEL
344
+
345
+
346
+ def is_local_url(url: str) -> bool:
347
+ return bool(url) and url.startswith("http://127.0.0.1:")
348
+
349
+
350
+ def _ping() -> dict:
351
+ if not _server_url:
352
+ return {"ok": False, "error": "no server"}
353
+ try:
354
+ with urllib.request.urlopen(_server_url + "/models", timeout=3) as r:
355
+ return {"ok": True, "status": r.status}
356
+ except Exception as e:
357
+ return {"ok": False, "error": f"{type(e).__name__}: {e}"}
358
+
359
+
360
+ def probe() -> dict:
361
+ """One real chat call through the local server (loads the model)."""
362
+ if not _server_url:
363
+ return {"ok": False, "error": "no server"}
364
+ payload = {
365
+ "model": LOCAL_MODEL,
366
+ "messages": [{"role": "user",
367
+ "content": "Reply with the single word: ok"}],
368
+ "max_tokens": 8,
369
+ "temperature": 0.0,
370
+ }
371
+ req = urllib.request.Request(
372
+ _server_url + "/chat/completions",
373
+ data=json.dumps(payload).encode("utf-8"),
374
+ headers={"Content-Type": "application/json"},
375
+ method="POST",
376
+ )
377
+ try:
378
+ with urllib.request.urlopen(req, timeout=180) as r:
379
+ data = json.loads(r.read().decode("utf-8"))
380
+ return {"ok": True,
381
+ "text": (data["choices"][0]["message"]["content"] or "")[:200]}
382
+ except Exception as e:
383
+ return {"ok": False, "error": f"{type(e).__name__}: {e}"}
384
+
385
+
386
+ def health() -> dict:
387
+ """What the UI shows when the local model misbehaves."""
388
+ return {
389
+ "model": LOCAL_MODEL,
390
+ "source": _model_source(),
391
+ "device": LOCAL_DEVICE,
392
+ "dtype": LOCAL_DTYPE,
393
+ "loaded": _model is not None,
394
+ "stub": _stub(),
395
+ "gpu_duration_s": GPU_DURATION_S,
396
+ "gpu_size": GPU_SIZE,
397
+ "prefetch": dict(PREFETCH),
398
+ "server": _server_url or "",
399
+ "ping": _ping(),
400
+ "requests": REQUESTS,
401
+ "last_request": LAST_REQUEST,
402
+ "last_response": LAST_RESPONSE,
403
+ "proxy": {k: v for k, v in os.environ.items()
404
+ if "proxy" in k.lower()},
405
+ "last_error": LAST_ERROR,
406
+ }
traceforge/space/pipeline.py CHANGED
@@ -1,404 +1,406 @@
1
- """The pipeline behind the web UI.
2
-
3
- `SpacePipeline` is the only thing the Gradio app talks to. It wraps the
4
- windowed splice engine (`improve_trace_splice`) so a trace that arrives as
5
- JSON — not as a session id in a local store — can be processed:
6
-
7
- submit(record) hash -> dedupe -> enqueue (idempotent)
8
- process(hash) run triage + rewrite + gates, store the output
9
-
10
- Configuration is environment-only, because a Space has no `~/.hermes`:
11
-
12
- TRACEFORGE_BASE_URL OpenAI-compatible endpoint (…/v1)
13
- TRACEFORGE_LLM_KEY bearer token
14
- TRACEFORGE_MODEL writer/judge model id
15
- TRACEFORGE_TRIAGE_MODEL cheap labeler (default: TRACEFORGE_MODEL)
16
- TRACEFORGE_JUDGE_MODEL judge override (default: TRACEFORGE_MODEL)
17
- TRACEFORGE_DATA_DIR store root (default: <repo>/data/space)
18
-
19
- `split_long` is deliberately OFF: splitting one submitted seed into N
20
- segments would produce N outputs for one hash, which is exactly what the
21
- one-seed-one-output rule forbids. The splice engine already windows a long
22
- trace internally.
23
- """
24
- from __future__ import annotations
25
-
26
- import json
27
- import os
28
- import shutil
29
- import time
30
- from dataclasses import dataclass, field
31
- from typing import Any, Optional
32
-
33
- from traceforge.config import Config, repo_root_from_pkg
34
- from traceforge.space.hashing import fingerprint, output_hash, seed_hash
35
- from traceforge.space.ingest import trace_from_record
36
- from traceforge.space.store import (DONE, FAILED, QUEUED, REJECTED, RUNNING,
37
- TraceStore)
38
- from traceforge.trace import Trace
39
-
40
-
41
- # ------------------------------------------------------------------- config
42
-
43
- def _env_int(name: str, default: int) -> int:
44
- try:
45
- return int(os.environ.get(name, "") or default)
46
- except ValueError:
47
- return default
48
-
49
-
50
- def _local_mode() -> bool:
51
- """No remote endpoint and no key -> run the model on ZeroGPU."""
52
- return (not (os.environ.get("TRACEFORGE_BASE_URL") or "").strip()
53
- and not (os.environ.get("TRACEFORGE_LLM_KEY") or "").strip())
54
-
55
-
56
- def space_config(data_dir: Optional[str] = None, **overrides: Any) -> Config:
57
- """A Config that needs no Hermes profile — env vars only."""
58
- root = repo_root_from_pkg()
59
- cfg = Config()
60
- cfg.data_dir = (data_dir or os.environ.get("TRACEFORGE_DATA_DIR")
61
- or os.path.join(root, "data", "space"))
62
- cfg.guidelines_path = os.path.join(root, "guidelines",
63
- "IDEAL_TRACE_GUIDELINES.md")
64
- cfg.provider = os.environ.get("TRACEFORGE_PROVIDER") or None
65
- cfg.triage = True
66
- cfg.splice = True
67
- # The Space processes one submitted trace as one unit.
68
- cfg.split_long = False
69
- cfg.raw_cache = False
70
- cfg.verify = False
71
- cfg.review = False
72
- if _local_mode():
73
- # No remote endpoint: a small model on the visitor's ZeroGPU quota.
74
- from traceforge.space.local_llm import (LOCAL_MODEL,
75
- ensure_local_server, prefetch)
76
- cfg.model = os.environ.get("TRACEFORGE_LOCAL_MODEL") or LOCAL_MODEL
77
- cfg.triage_model = cfg.model
78
- cfg.judge_model = ""
79
- cfg.triage_base_url = ensure_local_server()
80
- prefetch()
81
- # A 3B model on a 120 s GPU slice: small windows, short outputs,
82
- # one request at a time. Every call is paid by the visitor's quota.
83
- cfg.splice_max_chars = _env_int("TRACEFORGE_LOCAL_WINDOW_CHARS", 6000)
84
- cfg.triage_max_chars = cfg.splice_max_chars
85
- cfg.rewrite_max_tokens = _env_int("TRACEFORGE_LOCAL_REWRITE_TOKENS",
86
- 2000)
87
- cfg.judge_max_tokens = _env_int("TRACEFORGE_LOCAL_JUDGE_TOKENS", 800)
88
- # A 3B judge often omits answer_quality; the structural and
89
- # grounding gates still run, and real violations still fail.
90
- cfg.judge_require_answer_quality = False
91
- cfg.judge_enabled = False
92
- cfg.splice_workers = 1
93
- cfg.triage_workers = 1
94
- cfg.rewrite_timeout_s = _env_int("TRACEFORGE_REWRITE_TIMEOUT_S", 600)
95
- cfg.judge_timeout_s = _env_int("TRACEFORGE_JUDGE_TIMEOUT_S", 600)
96
- else:
97
- cfg.model = os.environ.get("TRACEFORGE_MODEL") or "qwen-fable-selfhosted"
98
- cfg.triage_model = (os.environ.get("TRACEFORGE_TRIAGE_MODEL")
99
- or cfg.model)
100
- cfg.judge_model = os.environ.get("TRACEFORGE_JUDGE_MODEL") or ""
101
- cfg.triage_base_url = (os.environ.get("TRACEFORGE_BASE_URL")
102
- or "").rstrip("/")
103
- cfg.splice_workers = _env_int("TRACEFORGE_SPLICE_WORKERS", 4)
104
- cfg.triage_workers = _env_int("TRACEFORGE_TRIAGE_WORKERS", 4)
105
- cfg.rewrite_timeout_s = _env_int("TRACEFORGE_REWRITE_TIMEOUT_S", 300)
106
- cfg.judge_timeout_s = _env_int("TRACEFORGE_JUDGE_TIMEOUT_S", 300)
107
- for k, v in overrides.items():
108
- setattr(cfg, k, v)
109
- return cfg.resolve(root)
110
-
111
-
112
- def pipeline_ready(cfg: Config) -> tuple[bool, str]:
113
- """(ready, reason). A Space without an endpoint must say so, not hang."""
114
- from traceforge.space.local_llm import is_local_url, local_model_id
115
- if is_local_url(cfg.triage_base_url):
116
- return True, (f"local ZeroGPU model {local_model_id()} — GPU time is "
117
- "charged to the visitor's HF quota")
118
- try:
119
- from traceforge.triage import _resolve_endpoint
120
- base, _key, model = _resolve_endpoint(cfg)
121
- except Exception as e: # RuntimeError from the resolver, or a bad config
122
- return False, f"no LLM endpoint: {e}"
123
- if not base:
124
- return False, "no LLM endpoint configured (TRACEFORGE_BASE_URL)"
125
- if not model:
126
- return False, "no model configured (TRACEFORGE_MODEL)"
127
- return True, f"{model} @ {base}"
128
-
129
-
130
- def _same_conversation(record: Any, improved: Trace) -> bool:
131
- """True when the rewrite is the seed, message for message.
132
-
133
- ``record`` may be the submitted dict or the raw ``Trace`` — the batch
134
- passes the trace it loaded, the Space passes the stored seed.
135
- """
136
- try:
137
- from traceforge.space.hashing import canonical_messages
138
- seed = record.to_dict() if isinstance(record, Trace) else record
139
- return (canonical_messages(seed) ==
140
- canonical_messages(improved.to_dict()))
141
- except Exception:
142
- return False
143
-
144
-
145
- # ------------------------------------------------------- the one pipeline
146
-
147
- @dataclass
148
- class ProcessResult:
149
- """What one trace produced. The only shape a caller has to understand."""
150
-
151
- ok: bool
152
- improved: Optional[Trace] = None
153
- route: str = ""
154
- attempts: int = 0
155
- duration_s: float = 0.0
156
- errors: list[str] = field(default_factory=list)
157
- no_change: bool = False
158
- task: str = ""
159
- time_sensitivity: dict = field(default_factory=dict)
160
- time_sensitivity_improved: dict = field(default_factory=dict)
161
-
162
-
163
- def _time_sensitivity(obj: Any) -> dict:
164
- try:
165
- from traceforge.timesensitivity import detect
166
- d = detect(obj)
167
- return {k: d[k] for k in ("class", "label", "temporal_facts",
168
- "retrieval_available", "retrieval_called",
169
- "needs_rewrite", "score")}
170
- except Exception as e:
171
- return {"class": "", "label": f"unavailable: {e}"}
172
-
173
-
174
- def process_trace(cfg: Config, trace: Trace, *, max_attempts: int = 2,
175
- seed: Any = None, reset_workdir: bool = False,
176
- label_task: bool = True,
177
- status_cb: Any = None) -> ProcessResult:
178
- """THE pipeline: one trace in, one ideal trace out (or a reason).
179
-
180
- Both the Gradio app (``SpacePipeline.process``) and the local batch
181
- (``cli._improve_one``) call this, so the rewrite, the gates, the no-op
182
- check and the time-sensitivity measurement exist once.
183
-
184
- ``seed`` is the submitted record when the caller has one (the Space
185
- does, and it may carry a ``tools`` array the Trace lost); the raw
186
- ``trace`` is used otherwise. ``reset_workdir`` is True for the Space,
187
- where a re-queued seed must not regate a stale artifact, and False for
188
- the batch, where the workdir cache is the resume mechanism.
189
- """
190
- started = time.time()
191
- sid = trace.session_id
192
- if reset_workdir:
193
- shutil.rmtree(os.path.join(cfg.improver_workdir, sid),
194
- ignore_errors=True)
195
-
196
- try:
197
- from traceforge.splice import improve_trace_splice
198
- raw = improve_trace_splice(cfg, trace, max_attempts=max_attempts,
199
- status_cb=status_cb)
200
- res = {"ok": raw.get("ok"), "improved": raw.get("improved"),
201
- "errors": list(raw.get("errors") or []),
202
- "route": raw.get("route") or "",
203
- "attempts": raw.get("attempts") or 0,
204
- "duration_s": raw.get("duration_s") or 0.0}
205
- except Exception as e:
206
- return ProcessResult(ok=False, errors=[f"pipeline crashed: {e}"],
207
- duration_s=time.time() - started)
208
-
209
- if not res.get("ok") or res.get("improved") is None:
210
- return ProcessResult(
211
- ok=False, errors=list(res.get("errors") or ["no output"]),
212
- route=res.get("route") or "", attempts=res.get("attempts") or 0,
213
- duration_s=time.time() - started)
214
-
215
- improved: Trace = res["improved"]
216
-
217
- # The splice engine gates before returning; re-checking here makes the
218
- # pipeline's own contract explicit and catches a regate path that
219
- # bypassed the engine's gate chain.
220
- try:
221
- from traceforge.validate import validate_structural
222
- errs = validate_structural(improved)
223
- except Exception as e:
224
- errs = [f"validator crashed: {e}"]
225
- if not errs:
226
- # The ideal must carry its own evidence: a rewrite that drops the
227
- # tool result but keeps the claim passes source-grounding yet
228
- # teaches the model to assert facts it never saw.
229
- try:
230
- from traceforge.grounding import self_grounding_errors
231
- errs = self_grounding_errors(improved)
232
- except Exception as e:
233
- errs = [f"self-grounding check crashed: {e}"]
234
- if errs:
235
- return ProcessResult(
236
- ok=False, errors=errs, route=res.get("route") or "",
237
- attempts=res.get("attempts") or 0,
238
- duration_s=time.time() - started)
239
-
240
- # Task bucket: heuristic only — neither caller should spend an LLM call
241
- # on a label the dashboard can derive for free. The batch turns this off
242
- # because `traceforge classify` writes the authoritative LLM label and
243
- # the benchmark prefers `trace.task` when it is set.
244
- if label_task:
245
- try:
246
- from traceforge.classify import classify_heuristic
247
- improved.task = classify_heuristic(improved).get("task") or ""
248
- except Exception:
249
- pass
250
-
251
- seed_obj = seed if seed is not None else trace
252
- return ProcessResult(
253
- ok=True, improved=improved,
254
- route=res.get("route") or "", attempts=res.get("attempts") or 0,
255
- duration_s=time.time() - started,
256
- no_change=_same_conversation(seed_obj, improved),
257
- task=improved.task or "",
258
- time_sensitivity=_time_sensitivity(seed_obj),
259
- time_sensitivity_improved=_time_sensitivity(improved),
260
- )
261
-
262
-
263
- # ----------------------------------------------------------------- pipeline
264
-
265
- class SpacePipeline:
266
- """Submit / process / report, with the store as the only state."""
267
-
268
- def __init__(self, store: TraceStore, cfg: Optional[Config] = None,
269
- *, max_attempts: int = 2):
270
- self.store = store
271
- self.cfg = cfg or space_config()
272
- self.max_attempts = max_attempts
273
-
274
- # ------------------------------------------------------------- submit
275
-
276
- def submit(self, record: Any, *, sponsor: str = "",
277
- source: str = "space-upload") -> dict:
278
- return self.store.submit(record, sponsor=sponsor, source=source)
279
-
280
- def submit_many(self, records: list[Any], *, sponsor: str = "",
281
- source: str = "space-sponsor") -> list[dict]:
282
- out = []
283
- for rec in records:
284
- try:
285
- out.append(self.submit(rec, sponsor=sponsor, source=source))
286
- except Exception as e:
287
- out.append({"hash": "", "status": "error",
288
- "error": str(e), "duplicate": False})
289
- return out
290
-
291
- # ------------------------------------------------------------ process
292
-
293
- def process(self, h: str) -> dict:
294
- """Run the pipeline for one queued hash. Never raises."""
295
- rec = self.store.get(h)
296
- if not rec:
297
- return {"hash": h, "status": FAILED, "error": "unknown hash"}
298
- if rec.get("status") == DONE:
299
- return {**rec, "skipped": True}
300
-
301
- sid = f"space-{h[:16]}"
302
- started = time.time()
303
- self.store.update(h, status=RUNNING, started_at=started,
304
- attempts=int(rec.get("attempts") or 0) + 1,
305
- endpoint=self.cfg.triage_base_url)
306
- try:
307
- record = self.store.load_seed(h)
308
- from traceforge.space.local_llm import is_local_url
309
- if is_local_url(self.cfg.triage_base_url):
310
- size = len(json.dumps(record, ensure_ascii=False))
311
- cap = _env_int("TRACEFORGE_LOCAL_MAX_CHARS", 24000)
312
- if size > cap:
313
- return self._reject(
314
- h,
315
- f"local ZeroGPU mode handles seeds up to {cap // 1000} "
316
- f"KB; this one is {size // 1000} KB. Configure a "
317
- "remote endpoint for larger traces.",
318
- started)
319
- trace = trace_from_record(record, fallback_id=sid,
320
- source="space-upload")
321
- trace.session_id = sid
322
- trace.source = "space-upload"
323
- trace.improved = False
324
- trace.parent_session_id = None
325
- except Exception as e:
326
- return self._fail(h, [f"unparseable seed: {e}"], started)
327
-
328
- # A fresh attempt must not regate a stale artifact from a previous
329
- # run (the splice engine's stage-0 fast path would promote it as-is).
330
- res = process_trace(self.cfg, trace, max_attempts=self.max_attempts,
331
- seed=record, reset_workdir=True)
332
- if not res.ok or res.improved is None:
333
- return self._fail(h, res.errors, started, route=res.route,
334
- attempts=res.attempts)
335
-
336
- improved: Trace = res.improved
337
- improved.session_id = sid
338
- improved.parent_session_id = sid
339
- improved.improved = True
340
- improved.source = "traceforge-space"
341
-
342
- # A no-op is not an ideal rewrite. The splice engine can return the
343
- # seed unchanged when the writer decides nothing needs changing, and
344
- # the judge accepts it because it violates nothing — but the Space
345
- # would then count the input as an output. Measured on the deployed
346
- # Space: a stale "current president" answer came back byte-identical
347
- # in 11 s and was stored as `done`.
348
- #
349
- # It is not a failure either: a seed that already reads as ideal
350
- # legitimately needs no edit. Record it as `no_change` so the UI can
351
- # say so instead of counting it against the visitor.
352
- if res.no_change:
353
- return self._reject(
354
- h, "no change needed: the rewrite is identical to the seed, "
355
- "so the seed already reads as an ideal trace", started,
356
- route=res.route, attempts=res.attempts,
357
- time_sensitivity=res.time_sensitivity, no_change=True,
358
- )
359
-
360
- self.store.put_output(h, improved)
361
- duration = time.time() - started
362
- row = self.store.update(
363
- h, status=DONE, finished_at=time.time(), duration_s=round(duration, 1),
364
- output_hash=output_hash(improved),
365
- route=res.route,
366
- attempts=res.attempts,
367
- n_messages_out=len(improved.messages),
368
- task=res.task or rec.get("task") or "",
369
- time_sensitivity=res.time_sensitivity,
370
- time_sensitivity_improved=res.time_sensitivity_improved,
371
- error="",
372
- )
373
- return row
374
-
375
- def _fail(self, h: str, errors: list[str], started: float, *,
376
- route: Optional[str] = None, attempts: Any = None) -> dict:
377
- msg = "; ".join(str(e) for e in errors)[:1200]
378
- return self.store.update(
379
- h, status=FAILED, finished_at=time.time(),
380
- duration_s=round(time.time() - started, 1),
381
- route=route or "", attempts=attempts or 0, error=msg,
382
- )
383
-
384
- def _reject(self, h: str, reason: str, started: float, *,
385
- route: Optional[str] = None, attempts: Any = None,
386
- **extra: Any) -> dict:
387
- """A pipeline outcome that is not a failure and not an output."""
388
- return self.store.update(
389
- h, status=REJECTED, finished_at=time.time(),
390
- duration_s=round(time.time() - started, 1),
391
- route=route or "", attempts=attempts or 0, error=reason, **extra,
392
- )
393
-
394
- # -------------------------------------------------------------- report
395
-
396
- def result(self, h: str) -> dict:
397
- """Everything the UI needs about one hash."""
398
- rec = self.store.get(h) or {"hash": h, "status": "unknown"}
399
- out = self.store.load_output(h)
400
- rec = dict(rec)
401
- rec["has_output"] = out is not None
402
- if out is not None:
403
- rec["output_messages"] = len(out.messages)
404
- return rec
 
 
 
1
+ """The pipeline behind the web UI.
2
+
3
+ `SpacePipeline` is the only thing the Gradio app talks to. It wraps the
4
+ windowed splice engine (`improve_trace_splice`) so a trace that arrives as
5
+ JSON — not as a session id in a local store — can be processed:
6
+
7
+ submit(record) hash -> dedupe -> enqueue (idempotent)
8
+ process(hash) run triage + rewrite + gates, store the output
9
+
10
+ Configuration is environment-only, because a Space has no `~/.hermes`:
11
+
12
+ TRACEFORGE_BASE_URL OpenAI-compatible endpoint (…/v1)
13
+ TRACEFORGE_LLM_KEY bearer token
14
+ TRACEFORGE_MODEL writer/judge model id
15
+ TRACEFORGE_TRIAGE_MODEL cheap labeler (default: TRACEFORGE_MODEL)
16
+ TRACEFORGE_JUDGE_MODEL judge override (default: TRACEFORGE_MODEL)
17
+ TRACEFORGE_DATA_DIR store root (default: <repo>/data/space)
18
+
19
+ `split_long` is deliberately OFF: splitting one submitted seed into N
20
+ segments would produce N outputs for one hash, which is exactly what the
21
+ one-seed-one-output rule forbids. The splice engine already windows a long
22
+ trace internally.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import shutil
29
+ import time
30
+ from dataclasses import dataclass, field
31
+ from typing import Any, Optional
32
+
33
+ from traceforge.config import Config, repo_root_from_pkg
34
+ from traceforge.space.hashing import fingerprint, output_hash, seed_hash
35
+ from traceforge.space.ingest import trace_from_record
36
+ from traceforge.space.store import (DONE, FAILED, QUEUED, REJECTED, RUNNING,
37
+ TraceStore)
38
+ from traceforge.trace import Trace
39
+
40
+
41
+ # ------------------------------------------------------------------- config
42
+
43
+ def _env_int(name: str, default: int) -> int:
44
+ try:
45
+ return int(os.environ.get(name, "") or default)
46
+ except ValueError:
47
+ return default
48
+
49
+
50
+ def _local_mode() -> bool:
51
+ """No remote endpoint and no key -> run the model on ZeroGPU."""
52
+ return (not (os.environ.get("TRACEFORGE_BASE_URL") or "").strip()
53
+ and not (os.environ.get("TRACEFORGE_LLM_KEY") or "").strip())
54
+
55
+
56
+ def space_config(data_dir: Optional[str] = None, **overrides: Any) -> Config:
57
+ """A Config that needs no Hermes profile — env vars only."""
58
+ root = repo_root_from_pkg()
59
+ cfg = Config()
60
+ cfg.data_dir = (data_dir or os.environ.get("TRACEFORGE_DATA_DIR")
61
+ or os.path.join(root, "data", "space"))
62
+ cfg.guidelines_path = os.path.join(root, "guidelines",
63
+ "IDEAL_TRACE_GUIDELINES.md")
64
+ cfg.provider = os.environ.get("TRACEFORGE_PROVIDER") or None
65
+ cfg.triage = True
66
+ cfg.splice = True
67
+ # The Space processes one submitted trace as one unit.
68
+ cfg.split_long = False
69
+ cfg.raw_cache = False
70
+ cfg.verify = False
71
+ cfg.review = False
72
+ if _local_mode():
73
+ # No remote endpoint: LFM2.5-24B-A2B on the visitor's ZeroGPU quota.
74
+ from traceforge.space.local_llm import (LOCAL_MODEL,
75
+ ensure_local_server, prefetch)
76
+ cfg.model = os.environ.get("TRACEFORGE_LOCAL_MODEL") or LOCAL_MODEL
77
+ cfg.triage_model = cfg.model
78
+ cfg.judge_model = ""
79
+ cfg.triage_base_url = ensure_local_server()
80
+ prefetch()
81
+ # One 120 s xlarge slice per call, paid by the visitor's quota: small
82
+ # windows, one request at a time. The model always thinks before it
83
+ # answers, so the rewrite budget has to cover reasoning *and* JSON.
84
+ cfg.splice_max_chars = _env_int("TRACEFORGE_LOCAL_WINDOW_CHARS", 6000)
85
+ cfg.triage_max_chars = cfg.splice_max_chars
86
+ cfg.rewrite_max_tokens = _env_int("TRACEFORGE_LOCAL_REWRITE_TOKENS",
87
+ 3000)
88
+ cfg.judge_max_tokens = _env_int("TRACEFORGE_LOCAL_JUDGE_TOKENS", 1200)
89
+ # The LLM judge stays off in local mode: it doubles the visitor's
90
+ # quota cost per trace. The structural, grounding and identity gates
91
+ # still run, and real violations still fail.
92
+ cfg.judge_require_answer_quality = False
93
+ cfg.judge_enabled = False
94
+ cfg.splice_workers = 1
95
+ cfg.triage_workers = 1
96
+ cfg.rewrite_timeout_s = _env_int("TRACEFORGE_REWRITE_TIMEOUT_S", 600)
97
+ cfg.judge_timeout_s = _env_int("TRACEFORGE_JUDGE_TIMEOUT_S", 600)
98
+ else:
99
+ cfg.model = os.environ.get("TRACEFORGE_MODEL") or "qwen-fable-selfhosted"
100
+ cfg.triage_model = (os.environ.get("TRACEFORGE_TRIAGE_MODEL")
101
+ or cfg.model)
102
+ cfg.judge_model = os.environ.get("TRACEFORGE_JUDGE_MODEL") or ""
103
+ cfg.triage_base_url = (os.environ.get("TRACEFORGE_BASE_URL")
104
+ or "").rstrip("/")
105
+ cfg.splice_workers = _env_int("TRACEFORGE_SPLICE_WORKERS", 4)
106
+ cfg.triage_workers = _env_int("TRACEFORGE_TRIAGE_WORKERS", 4)
107
+ cfg.rewrite_timeout_s = _env_int("TRACEFORGE_REWRITE_TIMEOUT_S", 300)
108
+ cfg.judge_timeout_s = _env_int("TRACEFORGE_JUDGE_TIMEOUT_S", 300)
109
+ for k, v in overrides.items():
110
+ setattr(cfg, k, v)
111
+ return cfg.resolve(root)
112
+
113
+
114
+ def pipeline_ready(cfg: Config) -> tuple[bool, str]:
115
+ """(ready, reason). A Space without an endpoint must say so, not hang."""
116
+ from traceforge.space.local_llm import is_local_url, local_model_id
117
+ if is_local_url(cfg.triage_base_url):
118
+ return True, (f"local ZeroGPU model {local_model_id()} — GPU time is "
119
+ "charged to the visitor's HF quota")
120
+ try:
121
+ from traceforge.triage import _resolve_endpoint
122
+ base, _key, model = _resolve_endpoint(cfg)
123
+ except Exception as e: # RuntimeError from the resolver, or a bad config
124
+ return False, f"no LLM endpoint: {e}"
125
+ if not base:
126
+ return False, "no LLM endpoint configured (TRACEFORGE_BASE_URL)"
127
+ if not model:
128
+ return False, "no model configured (TRACEFORGE_MODEL)"
129
+ return True, f"{model} @ {base}"
130
+
131
+
132
+ def _same_conversation(record: Any, improved: Trace) -> bool:
133
+ """True when the rewrite is the seed, message for message.
134
+
135
+ ``record`` may be the submitted dict or the raw ``Trace`` — the batch
136
+ passes the trace it loaded, the Space passes the stored seed.
137
+ """
138
+ try:
139
+ from traceforge.space.hashing import canonical_messages
140
+ seed = record.to_dict() if isinstance(record, Trace) else record
141
+ return (canonical_messages(seed) ==
142
+ canonical_messages(improved.to_dict()))
143
+ except Exception:
144
+ return False
145
+
146
+
147
+ # ------------------------------------------------------- the one pipeline
148
+
149
+ @dataclass
150
+ class ProcessResult:
151
+ """What one trace produced. The only shape a caller has to understand."""
152
+
153
+ ok: bool
154
+ improved: Optional[Trace] = None
155
+ route: str = ""
156
+ attempts: int = 0
157
+ duration_s: float = 0.0
158
+ errors: list[str] = field(default_factory=list)
159
+ no_change: bool = False
160
+ task: str = ""
161
+ time_sensitivity: dict = field(default_factory=dict)
162
+ time_sensitivity_improved: dict = field(default_factory=dict)
163
+
164
+
165
+ def _time_sensitivity(obj: Any) -> dict:
166
+ try:
167
+ from traceforge.timesensitivity import detect
168
+ d = detect(obj)
169
+ return {k: d[k] for k in ("class", "label", "temporal_facts",
170
+ "retrieval_available", "retrieval_called",
171
+ "needs_rewrite", "score")}
172
+ except Exception as e:
173
+ return {"class": "", "label": f"unavailable: {e}"}
174
+
175
+
176
+ def process_trace(cfg: Config, trace: Trace, *, max_attempts: int = 2,
177
+ seed: Any = None, reset_workdir: bool = False,
178
+ label_task: bool = True,
179
+ status_cb: Any = None) -> ProcessResult:
180
+ """THE pipeline: one trace in, one ideal trace out (or a reason).
181
+
182
+ Both the Gradio app (``SpacePipeline.process``) and the local batch
183
+ (``cli._improve_one``) call this, so the rewrite, the gates, the no-op
184
+ check and the time-sensitivity measurement exist once.
185
+
186
+ ``seed`` is the submitted record when the caller has one (the Space
187
+ does, and it may carry a ``tools`` array the Trace lost); the raw
188
+ ``trace`` is used otherwise. ``reset_workdir`` is True for the Space,
189
+ where a re-queued seed must not regate a stale artifact, and False for
190
+ the batch, where the workdir cache is the resume mechanism.
191
+ """
192
+ started = time.time()
193
+ sid = trace.session_id
194
+ if reset_workdir:
195
+ shutil.rmtree(os.path.join(cfg.improver_workdir, sid),
196
+ ignore_errors=True)
197
+
198
+ try:
199
+ from traceforge.splice import improve_trace_splice
200
+ raw = improve_trace_splice(cfg, trace, max_attempts=max_attempts,
201
+ status_cb=status_cb)
202
+ res = {"ok": raw.get("ok"), "improved": raw.get("improved"),
203
+ "errors": list(raw.get("errors") or []),
204
+ "route": raw.get("route") or "",
205
+ "attempts": raw.get("attempts") or 0,
206
+ "duration_s": raw.get("duration_s") or 0.0}
207
+ except Exception as e:
208
+ return ProcessResult(ok=False, errors=[f"pipeline crashed: {e}"],
209
+ duration_s=time.time() - started)
210
+
211
+ if not res.get("ok") or res.get("improved") is None:
212
+ return ProcessResult(
213
+ ok=False, errors=list(res.get("errors") or ["no output"]),
214
+ route=res.get("route") or "", attempts=res.get("attempts") or 0,
215
+ duration_s=time.time() - started)
216
+
217
+ improved: Trace = res["improved"]
218
+
219
+ # The splice engine gates before returning; re-checking here makes the
220
+ # pipeline's own contract explicit and catches a regate path that
221
+ # bypassed the engine's gate chain.
222
+ try:
223
+ from traceforge.validate import validate_structural
224
+ errs = validate_structural(improved)
225
+ except Exception as e:
226
+ errs = [f"validator crashed: {e}"]
227
+ if not errs:
228
+ # The ideal must carry its own evidence: a rewrite that drops the
229
+ # tool result but keeps the claim passes source-grounding yet
230
+ # teaches the model to assert facts it never saw.
231
+ try:
232
+ from traceforge.grounding import self_grounding_errors
233
+ errs = self_grounding_errors(improved)
234
+ except Exception as e:
235
+ errs = [f"self-grounding check crashed: {e}"]
236
+ if errs:
237
+ return ProcessResult(
238
+ ok=False, errors=errs, route=res.get("route") or "",
239
+ attempts=res.get("attempts") or 0,
240
+ duration_s=time.time() - started)
241
+
242
+ # Task bucket: heuristic only — neither caller should spend an LLM call
243
+ # on a label the dashboard can derive for free. The batch turns this off
244
+ # because `traceforge classify` writes the authoritative LLM label and
245
+ # the benchmark prefers `trace.task` when it is set.
246
+ if label_task:
247
+ try:
248
+ from traceforge.classify import classify_heuristic
249
+ improved.task = classify_heuristic(improved).get("task") or ""
250
+ except Exception:
251
+ pass
252
+
253
+ seed_obj = seed if seed is not None else trace
254
+ return ProcessResult(
255
+ ok=True, improved=improved,
256
+ route=res.get("route") or "", attempts=res.get("attempts") or 0,
257
+ duration_s=time.time() - started,
258
+ no_change=_same_conversation(seed_obj, improved),
259
+ task=improved.task or "",
260
+ time_sensitivity=_time_sensitivity(seed_obj),
261
+ time_sensitivity_improved=_time_sensitivity(improved),
262
+ )
263
+
264
+
265
+ # ----------------------------------------------------------------- pipeline
266
+
267
+ class SpacePipeline:
268
+ """Submit / process / report, with the store as the only state."""
269
+
270
+ def __init__(self, store: TraceStore, cfg: Optional[Config] = None,
271
+ *, max_attempts: int = 2):
272
+ self.store = store
273
+ self.cfg = cfg or space_config()
274
+ self.max_attempts = max_attempts
275
+
276
+ # ------------------------------------------------------------- submit
277
+
278
+ def submit(self, record: Any, *, sponsor: str = "",
279
+ source: str = "space-upload") -> dict:
280
+ return self.store.submit(record, sponsor=sponsor, source=source)
281
+
282
+ def submit_many(self, records: list[Any], *, sponsor: str = "",
283
+ source: str = "space-sponsor") -> list[dict]:
284
+ out = []
285
+ for rec in records:
286
+ try:
287
+ out.append(self.submit(rec, sponsor=sponsor, source=source))
288
+ except Exception as e:
289
+ out.append({"hash": "", "status": "error",
290
+ "error": str(e), "duplicate": False})
291
+ return out
292
+
293
+ # ------------------------------------------------------------ process
294
+
295
+ def process(self, h: str) -> dict:
296
+ """Run the pipeline for one queued hash. Never raises."""
297
+ rec = self.store.get(h)
298
+ if not rec:
299
+ return {"hash": h, "status": FAILED, "error": "unknown hash"}
300
+ if rec.get("status") == DONE:
301
+ return {**rec, "skipped": True}
302
+
303
+ sid = f"space-{h[:16]}"
304
+ started = time.time()
305
+ self.store.update(h, status=RUNNING, started_at=started,
306
+ attempts=int(rec.get("attempts") or 0) + 1,
307
+ endpoint=self.cfg.triage_base_url)
308
+ try:
309
+ record = self.store.load_seed(h)
310
+ from traceforge.space.local_llm import is_local_url
311
+ if is_local_url(self.cfg.triage_base_url):
312
+ size = len(json.dumps(record, ensure_ascii=False))
313
+ cap = _env_int("TRACEFORGE_LOCAL_MAX_CHARS", 24000)
314
+ if size > cap:
315
+ return self._reject(
316
+ h,
317
+ f"local ZeroGPU mode handles seeds up to {cap // 1000} "
318
+ f"KB; this one is {size // 1000} KB. Configure a "
319
+ "remote endpoint for larger traces.",
320
+ started)
321
+ trace = trace_from_record(record, fallback_id=sid,
322
+ source="space-upload")
323
+ trace.session_id = sid
324
+ trace.source = "space-upload"
325
+ trace.improved = False
326
+ trace.parent_session_id = None
327
+ except Exception as e:
328
+ return self._fail(h, [f"unparseable seed: {e}"], started)
329
+
330
+ # A fresh attempt must not regate a stale artifact from a previous
331
+ # run (the splice engine's stage-0 fast path would promote it as-is).
332
+ res = process_trace(self.cfg, trace, max_attempts=self.max_attempts,
333
+ seed=record, reset_workdir=True)
334
+ if not res.ok or res.improved is None:
335
+ return self._fail(h, res.errors, started, route=res.route,
336
+ attempts=res.attempts)
337
+
338
+ improved: Trace = res.improved
339
+ improved.session_id = sid
340
+ improved.parent_session_id = sid
341
+ improved.improved = True
342
+ improved.source = "traceforge-space"
343
+
344
+ # A no-op is not an ideal rewrite. The splice engine can return the
345
+ # seed unchanged when the writer decides nothing needs changing, and
346
+ # the judge accepts it because it violates nothing — but the Space
347
+ # would then count the input as an output. Measured on the deployed
348
+ # Space: a stale "current president" answer came back byte-identical
349
+ # in 11 s and was stored as `done`.
350
+ #
351
+ # It is not a failure either: a seed that already reads as ideal
352
+ # legitimately needs no edit. Record it as `no_change` so the UI can
353
+ # say so instead of counting it against the visitor.
354
+ if res.no_change:
355
+ return self._reject(
356
+ h, "no change needed: the rewrite is identical to the seed, "
357
+ "so the seed already reads as an ideal trace", started,
358
+ route=res.route, attempts=res.attempts,
359
+ time_sensitivity=res.time_sensitivity, no_change=True,
360
+ )
361
+
362
+ self.store.put_output(h, improved)
363
+ duration = time.time() - started
364
+ row = self.store.update(
365
+ h, status=DONE, finished_at=time.time(), duration_s=round(duration, 1),
366
+ output_hash=output_hash(improved),
367
+ route=res.route,
368
+ attempts=res.attempts,
369
+ n_messages_out=len(improved.messages),
370
+ task=res.task or rec.get("task") or "",
371
+ time_sensitivity=res.time_sensitivity,
372
+ time_sensitivity_improved=res.time_sensitivity_improved,
373
+ error="",
374
+ )
375
+ return row
376
+
377
+ def _fail(self, h: str, errors: list[str], started: float, *,
378
+ route: Optional[str] = None, attempts: Any = None) -> dict:
379
+ msg = "; ".join(str(e) for e in errors)[:1200]
380
+ return self.store.update(
381
+ h, status=FAILED, finished_at=time.time(),
382
+ duration_s=round(time.time() - started, 1),
383
+ route=route or "", attempts=attempts or 0, error=msg,
384
+ )
385
+
386
+ def _reject(self, h: str, reason: str, started: float, *,
387
+ route: Optional[str] = None, attempts: Any = None,
388
+ **extra: Any) -> dict:
389
+ """A pipeline outcome that is not a failure and not an output."""
390
+ return self.store.update(
391
+ h, status=REJECTED, finished_at=time.time(),
392
+ duration_s=round(time.time() - started, 1),
393
+ route=route or "", attempts=attempts or 0, error=reason, **extra,
394
+ )
395
+
396
+ # -------------------------------------------------------------- report
397
+
398
+ def result(self, h: str) -> dict:
399
+ """Everything the UI needs about one hash."""
400
+ rec = self.store.get(h) or {"hash": h, "status": "unknown"}
401
+ out = self.store.load_output(h)
402
+ rec = dict(rec)
403
+ rec["has_output"] = out is not None
404
+ if out is not None:
405
+ rec["output_messages"] = len(out.messages)
406
+ return rec
traceforge/splice.py CHANGED
The diff for this file is too large to render. See raw diff
 
traceforge/synth.py ADDED
@@ -0,0 +1,634 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Synthetic traces from scratch, to fill the balance gaps.
2
+
3
+ The pipeline rewrites traces that already exist. It cannot invent a German
4
+ `data-analysis` session, because no such session was ever recorded — and that
5
+ is exactly the hole the balance report measures. This module fills it:
6
+
7
+ plan read the balance gap -> the (language, task, difficulty) cells
8
+ tasks an LLM writes a SELF-CONTAINED task per cell, plus the starter
9
+ files it operates on (no dependency on the owner's repos)
10
+ run a real agent executes the task in a throwaway workspace, so the
11
+ tool calls and their results are real, not fabricated
12
+ ingest the captured session becomes a raw trace, and the existing
13
+ triage -> improve -> gate chain takes it from there
14
+
15
+ Why execute instead of writing the trace directly: the gate chain rejects
16
+ fabricated tool results (`self_grounding_errors`), and it is right to. A
17
+ synthetic trace is only worth having if an agent actually ran the commands.
18
+ That is also why this is expensive — one task is one agent session — and why
19
+ the plan is targeted at the cells with the largest deficit instead of
20
+ generating uniformly.
21
+
22
+ State is an append-only journal (`data/synth/tasks.jsonl`, last row per id
23
+ wins), so a killed run resumes: `run` picks up `pending` and re-queues
24
+ `running` rows whose workspace has no captured trace.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import re
31
+ import shutil
32
+ import subprocess
33
+ import sys
34
+ import time
35
+ import uuid
36
+ from dataclasses import asdict, dataclass, field
37
+ from typing import Any, Optional
38
+
39
+ from traceforge import balance as bal
40
+ from traceforge.classify import LANGUAGE_NAMES, TASKS
41
+ from traceforge.difficulty import label as band_label
42
+
43
+ # --------------------------------------------------------------- constants
44
+
45
+ # How many tool calls each band should take. The generator gets this as a
46
+ # concrete instruction ("aim for 6-10 tool calls"), because "make it hard" is
47
+ # not actionable and produces either a one-liner or an unbounded epic.
48
+ BAND_HINT = {
49
+ "D1": "1-2 tool calls: a single lookup or one edit",
50
+ "D2": "3-5 tool calls: read a file, change it, verify",
51
+ "D3": "6-10 tool calls: several files, a command run, a fix",
52
+ "D4": "11-20 tool calls: a small project, tests, an iteration",
53
+ "D5": "21+ tool calls: a multi-stage task with verification loops",
54
+ }
55
+
56
+ # A task must be runnable in an empty directory with only the files the
57
+ # generator provides. These are the shapes that work; anything that needs the
58
+ # owner's repos, credentials or the network is out of scope.
59
+ TASK_SHAPES = {
60
+ "debug": "a small program that fails, with the failure reproducible from "
61
+ "the provided files",
62
+ "implement": "a small feature to add to the provided starter code",
63
+ "refactor": "existing code to restructure without changing behaviour",
64
+ "test": "code that needs tests written and run",
65
+ "review": "a diff or module to audit read-only, with findings to report",
66
+ "research": "a question answerable from the provided documents (or from "
67
+ "web search when the agent has it)",
68
+ "data-analysis": "a CSV/JSON dataset to analyse and summarise",
69
+ "ops": "a service/config to set up, run and verify locally",
70
+ "office-docs": "a spreadsheet/deck/document to produce from given data",
71
+ "creative": "a writing or design brief with a concrete deliverable",
72
+ "other": "a general task that does not fit the other buckets",
73
+ }
74
+
75
+ _SAFE_PATH_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
76
+
77
+
78
+ # ------------------------------------------------------------------- specs
79
+
80
+
81
+ @dataclass
82
+ class TaskSpec:
83
+ """One synthetic task: what to ask, what to start from, where it stands."""
84
+
85
+ id: str
86
+ language: str
87
+ task: str
88
+ difficulty: str
89
+ prompt: str
90
+ files: dict[str, str] = field(default_factory=dict)
91
+ status: str = "pending" # pending|running|done|failed|rejected
92
+ session_id: Optional[str] = None
93
+ error: Optional[str] = None
94
+ created_at: str = ""
95
+ finished_at: Optional[str] = None
96
+ duration_s: Optional[float] = None
97
+ turns: Optional[int] = None
98
+
99
+ def to_dict(self) -> dict[str, Any]:
100
+ return asdict(self)
101
+
102
+ @classmethod
103
+ def from_dict(cls, d: dict[str, Any]) -> "TaskSpec":
104
+ known = {f for f in cls.__dataclass_fields__}
105
+ return cls(**{k: v for k, v in d.items() if k in known})
106
+
107
+
108
+ def _now() -> str:
109
+ import datetime
110
+
111
+ return datetime.datetime.now().astimezone().isoformat(timespec="seconds")
112
+
113
+
114
+ def synth_dir(cfg) -> str:
115
+ return os.path.join(cfg.data_dir, "synth")
116
+
117
+
118
+ def store_path(cfg) -> str:
119
+ return os.path.join(synth_dir(cfg), "tasks.jsonl")
120
+
121
+
122
+ def ws_root(cfg) -> str:
123
+ """Where task workspaces live — OUTSIDE the repo by default.
124
+
125
+ The executor runs an agent with `--yolo` in this directory. Putting it
126
+ under `data/` would let a confused agent `cd ..` into the corpus and
127
+ rewrite it, so the default is a sibling of the repo's parent. Override
128
+ with `synth_ws_root` in the config.
129
+ """
130
+ configured = getattr(cfg, "synth_ws_root", "") or ""
131
+ if configured:
132
+ return os.path.expanduser(configured)
133
+ return os.path.expanduser(os.path.join("~", "traceforge-synth", "ws"))
134
+
135
+
136
+ def runs_dir(cfg) -> str:
137
+ return os.path.join(synth_dir(cfg), "runs")
138
+
139
+
140
+ def load_specs(cfg) -> dict[str, TaskSpec]:
141
+ """Last row per id wins — the journal is append-only."""
142
+ out: dict[str, TaskSpec] = {}
143
+ path = store_path(cfg)
144
+ if not os.path.exists(path):
145
+ return out
146
+ with open(path, "r", encoding="utf-8") as f:
147
+ for line in f:
148
+ line = line.strip()
149
+ if not line:
150
+ continue
151
+ try:
152
+ row = json.loads(line)
153
+ except json.JSONDecodeError:
154
+ continue
155
+ spec = TaskSpec.from_dict(row)
156
+ if spec.id:
157
+ out[spec.id] = spec
158
+ return out
159
+
160
+
161
+ def append_spec(cfg, spec: TaskSpec) -> None:
162
+ os.makedirs(synth_dir(cfg), exist_ok=True)
163
+ with open(store_path(cfg), "a", encoding="utf-8") as f:
164
+ f.write(json.dumps(spec.to_dict(), ensure_ascii=False) + "\n")
165
+
166
+
167
+ # ------------------------------------------------------------ task writing
168
+
169
+ _TASK_SCHEMA: dict[str, Any] = {
170
+ "type": "object",
171
+ "properties": {
172
+ "prompt": {"type": "string"},
173
+ "files": {
174
+ "type": "array",
175
+ "items": {
176
+ "type": "object",
177
+ "properties": {
178
+ "path": {"type": "string"},
179
+ "content": {"type": "string"},
180
+ },
181
+ "required": ["path", "content"],
182
+ },
183
+ },
184
+ "why": {"type": "string"},
185
+ },
186
+ "required": ["prompt", "files"],
187
+ }
188
+
189
+
190
+ def _task_prompt(language: str, task: str, difficulty: str,
191
+ avoid: list[str]) -> str:
192
+ lang_name = LANGUAGE_NAMES.get(language, language)
193
+ avoid_block = ""
194
+ if avoid:
195
+ avoid_block = (
196
+ "\nDo NOT repeat or lightly reword any of these existing tasks:\n"
197
+ + "\n".join(f"- {a}" for a in avoid[:12])
198
+ )
199
+ return (
200
+ "Write ONE self-contained agent task that a coding agent can complete "
201
+ "in an empty directory, using only the files you provide.\n\n"
202
+ f"Language: write the task prompt in {lang_name}. Every sentence the "
203
+ f"user would say is in {lang_name}; file contents and code may be in "
204
+ "any language, but prefer "
205
+ f"{lang_name} for prose inside the files.\n"
206
+ f"Task bucket: {task} — {TASK_SHAPES.get(task, '')}\n"
207
+ f"Difficulty: {BAND_HINT.get(difficulty, difficulty)} "
208
+ f"({band_label(difficulty)})\n\n"
209
+ "Hard requirements:\n"
210
+ "- The task must be completable offline in a fresh directory with only "
211
+ "the files you provide. No credentials, no private repos, no services "
212
+ "that need the internet.\n"
213
+ "- It must require REAL tool use: reading files, running commands, "
214
+ "editing code, or producing a file. A task answerable from memory is "
215
+ "useless.\n"
216
+ "- It must have a verifiable deliverable the agent can check itself "
217
+ "(a passing test, a file that exists, a command that exits 0).\n"
218
+ "- Provide 1-6 starter files with real, coherent content — enough that "
219
+ "the task is concrete, not a blank page. Keep each file under 120 "
220
+ "lines.\n"
221
+ "- Starter files must be syntactically valid and self-consistent: no "
222
+ "truncated imports, no placeholder bodies, no test that cannot run. If "
223
+ "the task is to FIX a bug, the failure must be a runtime failure the "
224
+ "agent can reproduce, never a syntax error in your own scaffold.\n"
225
+ "- The prompt is what the USER types: state the goal and the "
226
+ "constraints, not the steps. Do not write the solution.\n"
227
+ "- Paths are relative, no '..', no absolute paths.\n"
228
+ f"{avoid_block}\n"
229
+ "Answer with JSON only: {\"prompt\": str, \"files\": [{\"path\": str, "
230
+ "\"content\": str}], \"why\": str}."
231
+ )
232
+
233
+
234
+ def _clean_files(raw: Any) -> dict[str, str]:
235
+ """Validate generated starter files: relative, safe, bounded."""
236
+ files: dict[str, str] = {}
237
+ if not isinstance(raw, list):
238
+ return files
239
+ for item in raw:
240
+ if not isinstance(item, dict):
241
+ continue
242
+ path = str(item.get("path") or "").strip().lstrip("./")
243
+ content = item.get("content")
244
+ if not path or not isinstance(content, str):
245
+ continue
246
+ if not _SAFE_PATH_RE.match(path) or ".." in path.split("/"):
247
+ continue
248
+ if len(content) > 60_000:
249
+ continue
250
+ files[path] = content
251
+ return files
252
+
253
+
254
+ def _similar(a: str, b: str) -> float:
255
+ import difflib
256
+
257
+ return difflib.SequenceMatcher(None, a[:600].lower(), b[:600].lower()).ratio()
258
+
259
+
260
+ def existing_task_texts(cfg, limit: int = 1200) -> list[str]:
261
+ """First user message of the corpus, for dedup. Sampled, not exhaustive."""
262
+ path = os.path.join(cfg.data_dir, "dataset", "train.jsonl")
263
+ if not os.path.exists(path):
264
+ return []
265
+ texts: list[str] = []
266
+ step = 1
267
+ try:
268
+ total = sum(1 for _ in open(path, "rb"))
269
+ step = max(1, total // limit)
270
+ except OSError:
271
+ pass
272
+ with open(path, "r", encoding="utf-8") as f:
273
+ for i, line in enumerate(f):
274
+ if i % step:
275
+ continue
276
+ try:
277
+ row = json.loads(line)
278
+ except json.JSONDecodeError:
279
+ continue
280
+ first = next((m.get("content") or "" for m in row.get("messages") or []
281
+ if m.get("role") == "user"), "")
282
+ if first:
283
+ texts.append(first[:600])
284
+ if len(texts) >= limit:
285
+ break
286
+ return texts
287
+
288
+
289
+ def generate_tasks(
290
+ cfg,
291
+ policy: bal.BalancePolicy,
292
+ cells: int = 0,
293
+ per_cell: int = 1,
294
+ model: Optional[str] = None,
295
+ ) -> list[TaskSpec]:
296
+ """Ask the LLM for one task per (cell, slot) and journal the specs."""
297
+ from traceforge import triage
298
+
299
+ base, key, default_model = triage._resolve_endpoint(cfg)
300
+ model = model or default_model
301
+ rep, cen = _report(cfg, policy)
302
+ work = bal.plan(rep, policy, limit=cells or None)
303
+ if not work:
304
+ print(" no under-target cell — nothing to generate")
305
+ return []
306
+ avoid = existing_task_texts(cfg)
307
+ specs: list[TaskSpec] = []
308
+ for cell in work:
309
+ for slot in range(per_cell):
310
+ band = _pick_band(cell["difficulty_mix"])
311
+ prompt = _task_prompt(cell["language"], cell["task"], band, avoid)
312
+ payload = {
313
+ "model": model,
314
+ "messages": [
315
+ {"role": "system",
316
+ "content": "You design agent training tasks. Answer with "
317
+ "JSON only."},
318
+ {"role": "user", "content": prompt},
319
+ ],
320
+ "temperature": 0.9,
321
+ "max_tokens": 4000,
322
+ "response_format": {
323
+ "type": "json_schema",
324
+ "json_schema": {"name": "synth_task",
325
+ "schema": _TASK_SCHEMA, "strict": True},
326
+ },
327
+ }
328
+ try:
329
+ data = triage._post_json(
330
+ triage._chat_url(base), payload, key, cfg.triage_timeout_s)
331
+ obj = json.loads(triage._extract_content(data) or "{}")
332
+ except Exception as e:
333
+ print(f" ! {cell['language']}/{cell['task']}: {e}")
334
+ continue
335
+ text = str(obj.get("prompt") or "").strip()
336
+ files = _clean_files(obj.get("files"))
337
+ if len(text) < 40 or not files:
338
+ print(f" ! {cell['language']}/{cell['task']}: rejected "
339
+ f"(prompt {len(text)} chars, {len(files)} file(s))")
340
+ continue
341
+ if any(_similar(text, a) > 0.8 for a in avoid):
342
+ print(f" ! {cell['language']}/{cell['task']}: rejected "
343
+ f"(near-duplicate of an existing task)")
344
+ continue
345
+ spec = TaskSpec(
346
+ id=f"synth-{uuid.uuid4().hex[:12]}",
347
+ language=cell["language"],
348
+ task=cell["task"],
349
+ difficulty=band,
350
+ prompt=text,
351
+ files=files,
352
+ status="pending",
353
+ created_at=_now(),
354
+ )
355
+ append_spec(cfg, spec)
356
+ specs.append(spec)
357
+ avoid.append(text[:600])
358
+ print(f" + {spec.id} {spec.language}/{spec.task} "
359
+ f"{spec.difficulty} {len(files)} file(s), "
360
+ f"{len(text)} chars")
361
+ return specs
362
+
363
+
364
+ def _pick_band(mix: dict[str, float]) -> str:
365
+ """Deterministic draw from the difficulty deficit mix (no RNG: the band
366
+ with the largest remaining deficit wins, so a batch spreads out)."""
367
+ if not mix:
368
+ return "D3"
369
+ return max(mix.items(), key=lambda kv: kv[1])[0]
370
+
371
+
372
+ # -------------------------------------------------------------- execution
373
+
374
+
375
+ def _hermes_bin() -> str:
376
+ exe = shutil.which("hermes")
377
+ if not exe:
378
+ raise RuntimeError("hermes CLI not found on PATH — the executor needs "
379
+ "it to run a task for real")
380
+ return exe
381
+
382
+
383
+ def materialize(cfg, spec: TaskSpec) -> str:
384
+ """Write the starter files into a fresh workspace and return its path.
385
+
386
+ NOTE (2026-10-29): a fresh HOST dir is not a fresh CONTAINER dir. The
387
+ executor's cwd is a fixed container path (`/data/agent/choucisan`) that
388
+ persists across tasks, so a task writing a file whose name an earlier task
389
+ already used gets `File has not been read yet`, falls back to `cat >` or
390
+ edits the stale file, and the captured trace then describes classes the
391
+ task never mentioned. Measured on the synth cohort: 494 raws / 393
392
+ attempted / 116 promoted = 29.5%, with refactoring 7.1% (2/28), debugging
393
+ 6.7% (1/15) and hf_trace 0% (0/17); 3 of 3 sampled failures traced to a
394
+ stale file (a refactor task's inline `DataProcessor` was replaced by a
395
+ previous task's `data_processor.py`). Clear the container dir before each
396
+ run, or give each task a unique container path. Suite rule R-110.
397
+ """
398
+ ws = os.path.join(ws_root(cfg), spec.id)
399
+ if os.path.isdir(ws):
400
+ shutil.rmtree(ws, ignore_errors=True)
401
+ os.makedirs(ws, exist_ok=True)
402
+ for rel, content in spec.files.items():
403
+ dest = os.path.join(ws, *rel.split("/"))
404
+ os.makedirs(os.path.dirname(dest) or ws, exist_ok=True)
405
+ with open(dest, "w", encoding="utf-8", newline="\n") as f:
406
+ f.write(content)
407
+ return ws
408
+
409
+
410
+ def _find_session(state_db: str, cwd: str, since: float) -> Optional[str]:
411
+ """The session the executor just created: newest session in this cwd.
412
+
413
+ The cwd is unique per task, so it is the discriminator — `--source synth`
414
+ would be cleaner but any non-default source tag makes the Hermes CLI fail
415
+ its model connection ("invalid_params"), so the executor runs with the
416
+ default source and the workspace path carries the provenance instead.
417
+ """
418
+ import sqlite3
419
+
420
+ if not os.path.exists(state_db):
421
+ return None
422
+ con = sqlite3.connect(f"file:{state_db}?mode=ro", uri=True)
423
+ try:
424
+ row = con.execute(
425
+ "SELECT id FROM sessions WHERE cwd=? AND started_at >= ? "
426
+ "ORDER BY started_at DESC LIMIT 1",
427
+ (cwd, since),
428
+ ).fetchone()
429
+ return row[0] if row else None
430
+ finally:
431
+ con.close()
432
+
433
+
434
+ def execute_task(
435
+ cfg,
436
+ spec: TaskSpec,
437
+ max_turns: int = 40,
438
+ timeout: int = 1800,
439
+ model: Optional[str] = None,
440
+ provider: Optional[str] = None,
441
+ ) -> TaskSpec:
442
+ """Run one task with a real agent and capture the session as a raw trace."""
443
+ from traceforge.readers.hermes import HermesTraceReader
444
+
445
+ ws = materialize(cfg, spec)
446
+ spec.status = "running"
447
+ spec.error = None
448
+ append_spec(cfg, spec)
449
+ started = time.time()
450
+ cmd = [
451
+ _hermes_bin(), "chat", "--oneshot",
452
+ "--in", ws,
453
+ "--max-turns", str(max_turns),
454
+ "--format", "text",
455
+ "--yolo",
456
+ ]
457
+ if model:
458
+ cmd += ["-m", model]
459
+ if provider:
460
+ cmd += ["--provider", provider]
461
+ cmd += ["-q", spec.prompt]
462
+ try:
463
+ proc = subprocess.run(
464
+ cmd, capture_output=True, text=True, timeout=timeout,
465
+ encoding="utf-8", errors="replace",
466
+ )
467
+ rc = proc.returncode
468
+ except subprocess.TimeoutExpired:
469
+ spec.status = "failed"
470
+ spec.error = f"timeout after {timeout}s"
471
+ spec.finished_at = _now()
472
+ spec.duration_s = round(time.time() - started, 1)
473
+ append_spec(cfg, spec)
474
+ return spec
475
+ sid = _find_session(cfg.state_db, ws, started - 5)
476
+ if not sid:
477
+ spec.status = "failed"
478
+ spec.error = (f"no session captured (exit {rc}); "
479
+ f"stderr: {(proc.stderr or '')[-300:]}")
480
+ spec.finished_at = _now()
481
+ spec.duration_s = round(time.time() - started, 1)
482
+ append_spec(cfg, spec)
483
+ return spec
484
+ try:
485
+ trace = HermesTraceReader([cfg.state_db]).load_trace(sid)
486
+ except Exception as e:
487
+ spec.status = "failed"
488
+ spec.error = f"session {sid} could not be read: {e}"
489
+ spec.finished_at = _now()
490
+ append_spec(cfg, spec)
491
+ return spec
492
+ os.makedirs(runs_dir(cfg), exist_ok=True)
493
+ trace.save(os.path.join(runs_dir(cfg), f"{spec.id}.json"))
494
+ spec.session_id = sid
495
+ spec.status = "done"
496
+ spec.turns = sum(1 for m in trace.messages if m.tool_calls)
497
+ spec.finished_at = _now()
498
+ spec.duration_s = round(time.time() - started, 1)
499
+ append_spec(cfg, spec)
500
+ return spec
501
+
502
+
503
+ def ingest(cfg, specs: Optional[dict[str, TaskSpec]] = None) -> int:
504
+ """Copy captured runs into data/traces/raw/ so the pipeline picks them up."""
505
+ from traceforge.trace import Trace
506
+
507
+ specs = specs if specs is not None else load_specs(cfg)
508
+ os.makedirs(cfg.raw_dir, exist_ok=True)
509
+ n = 0
510
+ for spec in specs.values():
511
+ if spec.status != "done" or not spec.session_id:
512
+ continue
513
+ src = os.path.join(runs_dir(cfg), f"{spec.id}.json")
514
+ if not os.path.exists(src):
515
+ continue
516
+ dest = os.path.join(cfg.raw_dir, f"{spec.session_id}.json")
517
+ if os.path.exists(dest):
518
+ continue
519
+ trace = Trace.load(src)
520
+ trace.save(dest)
521
+ n += 1
522
+ return n
523
+
524
+
525
+ # -------------------------------------------------------------------- CLI
526
+
527
+
528
+ def _report(cfg, policy: bal.BalancePolicy):
529
+ path = os.path.join(cfg.data_dir, "dataset", "train.jsonl")
530
+ labels = bal.load_labels(os.path.join(cfg.data_dir, "tasks.jsonl"))
531
+ records = []
532
+ if os.path.exists(path):
533
+ with open(path, "r", encoding="utf-8") as f:
534
+ for line in f:
535
+ line = line.strip()
536
+ if line:
537
+ records.append(json.loads(line))
538
+ cen = bal.census(records, labels)
539
+ return bal.report(cen, policy), cen
540
+
541
+
542
+ def _policy(args) -> bal.BalancePolicy:
543
+ return bal.BalancePolicy.load(getattr(args, "policy", None) or bal.POLICY_PATH)
544
+
545
+
546
+ def cli_plan(cfg, args) -> int:
547
+ policy = _policy(args)
548
+ rep, cen = _report(cfg, policy)
549
+ rows = bal.plan(rep, policy, limit=getattr(args, "cells", 0) or None)
550
+ print(bal.format_report(rep, top=0))
551
+ print()
552
+ print(bal.format_plan(rows, limit=40,
553
+ fill=bal.fill_to(cen, policy),
554
+ grow=bal.converge(cen, policy)))
555
+ return 0
556
+
557
+
558
+ def cli_tasks(cfg, args) -> int:
559
+ policy = _policy(args)
560
+ specs = generate_tasks(
561
+ cfg, policy,
562
+ cells=getattr(args, "cells", 0) or 0,
563
+ per_cell=getattr(args, "per_cell", 1) or 1,
564
+ model=getattr(args, "model", None),
565
+ )
566
+ print(f"\n wrote {len(specs)} task(s) -> {store_path(cfg)}")
567
+ return 0 if specs else 1
568
+
569
+
570
+ def cli_run(cfg, args) -> int:
571
+ specs = load_specs(cfg)
572
+ wanted = ("pending", "running")
573
+ if getattr(args, "retry_failed", False):
574
+ wanted = wanted + ("failed",)
575
+ pending = [s for s in specs.values() if s.status in wanted]
576
+ pending.sort(key=lambda s: s.created_at)
577
+ limit = getattr(args, "limit", 0) or 0
578
+ if limit:
579
+ pending = pending[:limit]
580
+ if not pending:
581
+ print(" no pending task — run `traceforge synth tasks` first")
582
+ return 0
583
+ print(f" executing {len(pending)} task(s) "
584
+ f"(max {args.max_turns} turns, {args.timeout}s each)")
585
+ ok = 0
586
+ for spec in pending:
587
+ res = execute_task(
588
+ cfg, spec,
589
+ max_turns=getattr(args, "max_turns", 40),
590
+ timeout=getattr(args, "timeout", 1800),
591
+ model=getattr(args, "model", None),
592
+ provider=getattr(args, "provider", None),
593
+ )
594
+ mark = "OK " if res.status == "done" else "FAIL"
595
+ print(f" {mark} {res.id} {res.language}/{res.task} "
596
+ f"{res.difficulty} {res.duration_s}s "
597
+ f"{res.turns or 0} turns"
598
+ + (f" ({res.error})" if res.error else ""))
599
+ ok += res.status == "done"
600
+ print(f"\n {ok}/{len(pending)} executed; "
601
+ f"run `traceforge synth ingest` to queue them for the pipeline")
602
+ return 0 if ok else 1
603
+
604
+
605
+ def cli_ingest(cfg, args) -> int:
606
+ n = ingest(cfg)
607
+ print(f" ingested {n} trace(s) into {cfg.raw_dir}")
608
+ print(" next: ./scripts/generate.sh (triage -> improve -> gates -> export)")
609
+ return 0
610
+
611
+
612
+ def cli_status(cfg, args) -> int:
613
+ specs = load_specs(cfg)
614
+ if not specs:
615
+ print(" no synthetic tasks yet — `traceforge synth plan` shows the gap")
616
+ return 0
617
+ by_status: dict[str, int] = {}
618
+ by_cell: dict[str, dict[str, int]] = {}
619
+ for s in specs.values():
620
+ by_status[s.status] = by_status.get(s.status, 0) + 1
621
+ cell = f"{s.language}/{s.task}"
622
+ by_cell.setdefault(cell, {})
623
+ by_cell[cell][s.status] = by_cell[cell].get(s.status, 0) + 1
624
+ print(f" synthetic tasks: {len(specs)} total · "
625
+ + " · ".join(f"{k} {v}" for k, v in sorted(by_status.items())))
626
+ for cell, counts in sorted(by_cell.items()):
627
+ print(f" {cell:<22s} " + " ".join(f"{k}={v}"
628
+ for k, v in sorted(counts.items())))
629
+ done = [s for s in specs.values() if s.status == "done"]
630
+ if done:
631
+ turns = sorted(s.turns or 0 for s in done)
632
+ print(f" executed: median {turns[len(turns)//2]} tool-call turns, "
633
+ f"median {sorted(s.duration_s or 0 for s in done)[len(done)//2]:.0f}s")
634
+ return 0
traceforge/trace.py CHANGED
@@ -102,6 +102,12 @@ class Trace:
102
  # task/topic bucket (see traceforge/classify.py). Persisted so export
103
  # can split the corpus deterministically and the label is reviewable.
104
  task: Optional[str] = None
 
 
 
 
 
 
105
 
106
  # ------------------------------------------------------------------ meta
107
 
@@ -183,6 +189,7 @@ class Trace:
183
  "improved": self.improved,
184
  "parent_session_id": self.parent_session_id,
185
  "task": self.task,
 
186
  "messages": [m.to_dict() for m in self.messages],
187
  }
188
 
@@ -199,6 +206,7 @@ class Trace:
199
  improved=bool(d.get("improved", False)),
200
  parent_session_id=d.get("parent_session_id"),
201
  task=d.get("task"),
 
202
  )
203
 
204
  def save(self, path: str) -> None:
@@ -332,6 +340,41 @@ def json_loads_lenient(raw: Optional[str]) -> Any:
332
  break
333
  return None
334
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  def _is_error_result(content: Optional[str]) -> bool:
336
  """True when a tool-result row is a FAILED call (P2 route-around
337
  signal for the splice window rewriter). Recognizes the common
 
102
  # task/topic bucket (see traceforge/classify.py). Persisted so export
103
  # can split the corpus deterministically and the label is reviewable.
104
  task: Optional[str] = None
105
+ # The harness the SOURCE session came from (claude-code, codex, tui,
106
+ # desktop, ...). `source` cannot carry it: the improve stage overwrites
107
+ # it with "traceforge-splice", so a promoted trace lost the one field
108
+ # the harness-balance axis needs. Set from the raw trace at improve
109
+ # time and preserved through every rewrite.
110
+ harness: Optional[str] = None
111
 
112
  # ------------------------------------------------------------------ meta
113
 
 
189
  "improved": self.improved,
190
  "parent_session_id": self.parent_session_id,
191
  "task": self.task,
192
+ "harness": self.harness,
193
  "messages": [m.to_dict() for m in self.messages],
194
  }
195
 
 
206
  improved=bool(d.get("improved", False)),
207
  parent_session_id=d.get("parent_session_id"),
208
  task=d.get("task"),
209
+ harness=d.get("harness"),
210
  )
211
 
212
  def save(self, path: str) -> None:
 
340
  break
341
  return None
342
 
343
+ _TEST_SUMMARY_RE = re.compile(
344
+ r"(?:=+ FAILURES =+"
345
+ r"|short test summary info"
346
+ r"|^\s*\d+ (?:failed|passed|error)[,)]"
347
+ r"|^\s*FAILED \S+::"
348
+ r"|npm ERR!"
349
+ r"|error TS\d+"
350
+ r"|^\s*E\s+assert)", re.M)
351
+
352
+ _EXEC_TOOLS = frozenset({
353
+ "exec", "bash", "shell", "sh", "run", "terminal", "exec_command",
354
+ "run_command", "run_terminal_cmd",
355
+ })
356
+
357
+
358
+ def is_failed_run(content: Optional[str],
359
+ tool_name: Optional[str] = None) -> bool:
360
+ """A test/build run that FAILED, though the wrapper reported success.
361
+
362
+ `_is_error_result` reads the call's exit status. A wrapped exec
363
+ ("Script completed Wall time 5.6 seconds Output: {...}") can report
364
+ success while the pytest run inside printed a failure summary — the
365
+ judge then cites that result as P2 forever and the mechanical drop
366
+ never fires, because the result is not an "error result". Measured
367
+ 2026-10-28: 16 of 56 P2-cited failures in one batch were this shape.
368
+
369
+ Only SUMMARY-shaped evidence counts. A file dump of a test file also
370
+ contains "assert", but no summary line — that distinction is what
371
+ keeps this from dropping legitimate reads.
372
+ """
373
+ if (tool_name or "").strip().lower() not in _EXEC_TOOLS:
374
+ return False
375
+ return bool(_TEST_SUMMARY_RE.search(content or ""))
376
+
377
+
378
  def _is_error_result(content: Optional[str]) -> bool:
379
  """True when a tool-result row is a FAILED call (P2 route-around
380
  signal for the splice window rewriter). Recognizes the common
traceforge/triage.py CHANGED
@@ -29,6 +29,7 @@ from __future__ import annotations
29
  import json
30
  import os
31
  import threading
 
32
  import urllib.error
33
  import urllib.request
34
  from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -36,6 +37,7 @@ from typing import Any, Optional
36
 
37
  from traceforge import chunker
38
  from traceforge.config import Config
 
39
  from traceforge.trace import Trace, json_loads_lenient
40
 
41
  ACTIONS = ("keep", "drop", "rephrase", "fact_check", "summarize")
@@ -131,8 +133,13 @@ def _resolve_endpoint(cfg: Config) -> tuple[str, str, str]:
131
  ) as f:
132
  prof = yaml.safe_load(f) or {}
133
  m = prof.get("model") or {}
134
- # pinned provider block first (matches cfg.provider)
135
- pv = (prof.get("providers") or {}).get(cfg.provider or "") or {}
 
 
 
 
 
136
  if not base:
137
  base = (pv.get("base_url") or m.get("base_url") or "").rstrip("/")
138
  if not key:
@@ -357,14 +364,26 @@ def _classify_window(
357
  if not model:
358
  payload.pop("model")
359
  payload.update(variant)
360
- try:
361
- data = _post_json(url, payload, key, cfg.triage_timeout_s)
362
- except urllib.error.HTTPError as e:
363
- # 400 on an unknown structured-output field: try next variant
364
- last_err = f"HTTP {e.code}"
365
- continue
366
- except (urllib.error.URLError, TimeoutError, OSError) as e:
367
- last_err = f"network: {e}"
 
 
 
 
 
 
 
 
 
 
 
 
368
  continue
369
  raw = _extract_content(data)
370
  labels = parse_labels(raw, valid)
 
29
  import json
30
  import os
31
  import threading
32
+ import time
33
  import urllib.error
34
  import urllib.request
35
  from concurrent.futures import ThreadPoolExecutor, as_completed
 
37
 
38
  from traceforge import chunker
39
  from traceforge.config import Config
40
+ from traceforge.net import backoff_s, is_transient_status
41
  from traceforge.trace import Trace, json_loads_lenient
42
 
43
  ACTIONS = ("keep", "drop", "rephrase", "fact_check", "summarize")
 
133
  ) as f:
134
  prof = yaml.safe_load(f) or {}
135
  m = prof.get("model") or {}
136
+ # The pinned provider may come from the traceforge config
137
+ # (cfg.provider) or, when that is unset, from the profile's own
138
+ # model.provider. Without the fallback a config with no
139
+ # triage_base_url resolves nothing — providers[""] is empty — even
140
+ # though the profile has a working provider block with the URL.
141
+ provider = cfg.provider or m.get("provider") or ""
142
+ pv = (prof.get("providers") or {}).get(provider) or {}
143
  if not base:
144
  base = (pv.get("base_url") or m.get("base_url") or "").rstrip("/")
145
  if not key:
 
364
  if not model:
365
  payload.pop("model")
366
  payload.update(variant)
367
+ data: Optional[dict[str, Any]] = None
368
+ for attempt in range(3):
369
+ try:
370
+ data = _post_json(url, payload, key, cfg.triage_timeout_s)
371
+ break
372
+ except urllib.error.HTTPError as e:
373
+ last_err = f"HTTP {e.code}"
374
+ if not is_transient_status(e.code):
375
+ # 400 on an unknown structured-output field: the
376
+ # request body is wrong, so try the next variant.
377
+ break
378
+ # 503/429/5xx is the endpoint, not the body. Retrying the
379
+ # same variant is the only thing that can help; the old
380
+ # code fell through all three variants in milliseconds and
381
+ # the trace died as "triage failed for every window".
382
+ time.sleep(backoff_s(attempt, e))
383
+ except (urllib.error.URLError, TimeoutError, OSError) as e:
384
+ last_err = f"network: {e}"
385
+ time.sleep(backoff_s(attempt, e))
386
+ if data is None:
387
  continue
388
  raw = _extract_content(data)
389
  labels = parse_labels(raw, valid)
traceforge/validate.py CHANGED
@@ -1,412 +1,488 @@
1
- """Structural validation of an (improved) trace.
2
-
3
- Checks that are format-level facts, independent of the quality guidelines —
4
- guideline-scoring (quality rules) will be defined together with the user and
5
- plugged in as a separate, stricter validator later.
6
- """
7
- from __future__ import annotations
8
-
9
- import difflib
10
- import json
11
- import re
12
- from typing import Optional
13
-
14
- from traceforge.trace import Message, Trace
15
-
16
- # template substitution tokens that leaked into message content, e.g.
17
- # "exec_reader_search_placeholder_0#" (agent-VM prompt template bug in
18
- # the source session). Word-ish identifier + "_placeholder_" + digits.
19
- _TEMPLATE_LEAK_RE = re.compile(
20
- r"[a-z][a-z0-9_]*_placeholder_\d+", re.IGNORECASE)
21
-
22
- # rendering markers from trace.py's prompt rendering (the improver's INPUT
23
- # format). The improver occasionally echoes them into assistant prose —
24
- # e.g. a row whose whole content is "(tool-only turn)" — which a trainee
25
- # must never see. Length-based degenerate checks miss these because the
26
- # marker is 16 chars.
27
- _RENDER_MARKER_RE = re.compile(
28
- r"^\s*(?:\(tool-only turn\)"
29
- r"|\[\d+\]\s+(?:ASSISTANT|USER|SYSTEM|TOOL)\b"
30
- r"|->\s*TOOL\s+\S+\s+args=)",
31
- re.IGNORECASE)
32
-
33
- VALID_ROLES = {"system", "user", "assistant", "tool"}
34
-
35
- # An explicit translation request makes a language switch the CORRECT answer,
36
- # so the P5 gate must not fire on it.
37
- _LANG_SWITCH_RE = re.compile(
38
- r"\b(?:übersetz|translate|translation|auf englisch|auf deutsch|"
39
- r"in english|in german|ins englische|ins deutsche)\b", re.IGNORECASE)
40
-
41
- # Pipeline framing markers that belong to the improver's INPUT, not to the
42
- # training target: the splitter prepends "[trace segment k of n — ...]" and
43
- # the codex reader prepends "[subagent brief — role: ...]". A user never
44
- # types these, so they are stripped from user rows before promotion.
45
- _SCAFFOLD_RE = re.compile(
46
- r"\[trace segment \d+ of \d+[^\]]*\]|\[subagent brief[^\]]*\]")
47
-
48
- # Code-ish spans removed before language scoring: fenced/inline code,
49
- # markdown link targets, URLs, absolute paths, and filenames. Their
50
- # identifiers are English by convention and would otherwise outvote the
51
- # prose's function words.
52
- _CODEISH_RE = re.compile(
53
- r"```.*?```"
54
- r"|`[^`\n]*`"
55
- r"|!?\[[^\]]*\]\([^)]*\)"
56
- r"|https?://\S+"
57
- r"|(?:[A-Za-z]:)?(?:/[\w.\-]+){2,}"
58
- r"|[\w.\-]+\.(?:py|ts|tsx|js|mjs|go|rs|md|json|ya?ml|sh|toml|txt)\b",
59
- re.S)
60
-
61
-
62
- def _confident_lang(text: str, min_words: int) -> Optional[str]:
63
- """'de'/'en' only when the call is unambiguous, else None.
64
-
65
- Requires min_words function words AND a 70% dominant share, so a mixed
66
- task or an answer quoting the other language does not trip the gate.
67
- Code, paths, URLs and markdown link targets are removed first: an
68
- English file path inside a German answer ("...-api-and-paired-...")
69
- otherwise supplies enough English function words to flip the call.
70
- """
71
- from traceforge.classify import language_scores
72
-
73
- de, en = language_scores(_CODEISH_RE.sub(" ", text))
74
- total = de + en
75
- if total < min_words:
76
- return None
77
- if max(de, en) / total < 0.7:
78
- return None
79
- return "de" if de > en else "en"
80
-
81
-
82
- def strip_scaffold_markers(t: Trace) -> int:
83
- """Remove pipeline framing markers from user rows (in place).
84
-
85
- Returns the number of rows changed. A row that is ONLY a marker is left
86
- untouched (stripping it would create an empty user message).
87
- """
88
- changed = 0
89
- for m in t.messages:
90
- if m.role != "user" or not m.content:
91
- continue
92
- new = _SCAFFOLD_RE.sub("", m.content)
93
- if new == m.content:
94
- continue
95
- new = re.sub(r"[ \t]+\n", "\n", new)
96
- new = re.sub(r"\n{3,}", "\n\n", new).strip()
97
- if not new:
98
- continue
99
- m.content = new
100
- changed += 1
101
- return changed
102
-
103
-
104
- def _norm_for_similarity(text: str) -> str:
105
- """Collapse whitespace and case so formatting noise can't hide an echo."""
106
- return re.sub(r"\s+", " ", text).strip().lower()
107
-
108
-
109
- # Unmistakable reframe-prompt text. The reframe model echoes its own rules
110
- # into the task message; a user never types any of this. The sanitizer cuts
111
- # it at reframe time, and this gate is the backstop that makes a leak fail
112
- # loudly instead of promoting silently (measured: 68 of 1044 traces).
113
- _REFRAME_PROMPT_RE = re.compile(
114
- r"Same language as the current task message"
115
- r"|The new task must ask ONLY"
116
- r"|no meta commentary"
117
- r"|answerable EXACTLY by the final answer"
118
- r"|Reply with ONLY the JSON object"
119
- r"|write that request as the new opening task message"
120
- r"|A synthetic ideal-world agent trace"
121
- r"|no mention of traces or reframing", re.IGNORECASE)
122
-
123
- # Harness dispatch briefs are NOT user messages. A codex subagent rollout opens
124
- # with the coordinator's dispatch text ("You are working inside Orca, a
125
- # multi-agent IDE. You are a dispatched worker. Your coordinator's Orca session
126
- # ID is: ... === CLI COMMANDS ==="), 6-7KB of orchestration boilerplate with
127
- # session/task ids and CLI docs. Promoted as-is, the trainee learns to emit
128
- # harness plumbing as the user turn. Measured 2026-10-22: 6 of 1046 improved
129
- # traces, 4 of them promoted by the running batch — so gate it, don't just
130
- # clean it.
131
- #
132
- # STRONG markers only (2026-10-24). The bare phrase `orca orchestration` used
133
- # to be an alternative here, and it is a CLI NAME, not boilerplate: it appears
134
- # in legitimate task rows ("...report via orca orchestration"), in the
135
- # harness's own "You have N orchestration messages" notifications, and quoted
136
- # inside compaction summaries. Measured on the raw pool: 43 traces matched
137
- # only that phrase, 42 of them with a real task row and no brief at all, and
138
- # 2 of the 20 recorded gate failures were clean traces killed by a quoted
139
- # mention at row 228. The four strong markers below appear in every real
140
- # brief and in nothing else.
141
- _HARNESS_BRIEF_RE = re.compile(
142
- r"You are working inside Orca, a multi-agent IDE"
143
- r"|You are a dispatched worker"
144
- r"|Your coordinator's Orca session ID is"
145
- r"|=== CLI COMMANDS ===", re.IGNORECASE)
146
-
147
- # Plumbing user rows: harness/validator chatter that an ideal trace routes
148
- # around. These MAY be dropped. A substantive user turn may NOT.
149
- _PLUMBING_USER_RE = re.compile(
150
- r"(previous (final )?(response|answer|attempt).{0,40}"
151
- r"(reject|invalid|not valid|validation error)"
152
- r"|(rejected? by the).{0,40}(validator|contract)"
153
- r"|^validation errors?\b|output contract validator"
154
- r"|\[trace segment \d+ of \d+"
155
- r"|\[subagent brief"
156
- r"|\[CONTEXT COMPACTION"
157
- r"|This session is being continued"
158
- r"|^You have \d+ orchestration messages?\.)", re.IGNORECASE)
159
-
160
-
161
- def _is_plumbing_user_row(m) -> bool:
162
- """A user row that is harness/validator plumbing, not a user request."""
163
- if m.role != "user":
164
- return False
165
- c = (m.content or "")[:400]
166
- return bool(_PLUMBING_USER_RE.search(c)) or bool(_HARNESS_BRIEF_RE.search(c))
167
-
168
-
169
- def substantive_user_turns(t: Trace) -> int:
170
- """Count genuine user turns, ignoring plumbing and consecutive merges.
171
-
172
- A turn group is a maximal run of user rows; it counts once when at least
173
- one row in it is substantive. Consecutive rows are one turn because the
174
- gate shape merges them (`_merge_same_role`), so a merge must not read as
175
- a dropped turn.
176
- """
177
- n = 0
178
- in_group = False
179
- has_substantive = False
180
- for m in t.messages:
181
- if m.role == "user":
182
- if not in_group:
183
- in_group, has_substantive = True, False
184
- if not _is_plumbing_user_row(m):
185
- has_substantive = True
186
- else:
187
- if in_group and has_substantive:
188
- n += 1
189
- in_group = False
190
- if in_group and has_substantive:
191
- n += 1
192
- return n
193
-
194
-
195
- def validate_structural(t: Trace) -> list[str]:
196
- """Return a list of structural errors ([] = valid)."""
197
- errors: list[str] = []
198
- if not t.messages:
199
- return ["trace has no messages"]
200
-
201
- roles = [m.role for m in t.messages]
202
- if roles[0] == "assistant":
203
- errors.append("first message is assistant (expected user or system)")
204
- if "user" not in roles:
205
- errors.append("no user message in trace")
206
- if roles[-1] not in ("assistant",):
207
- errors.append(f"last message role is {roles[-1]!r} (expected assistant)")
208
-
209
- # tool-call / tool-result pairing: a rewrite that drops a call but
210
- # keeps its result (or strips a call's name) is not a valid
211
- # conversation — the trainee would learn to answer without evidence.
212
- # Measured on the Space's 3B local mode: it dropped the successful
213
- # call and kept the failed call's error row, leaving an orphan result.
214
- calls: dict[str, str] = {}
215
- for i, m in enumerate(t.messages):
216
- for tc in (m.tool_calls or []):
217
- if not (tc.name or "").strip():
218
- errors.append(f"[{i}] tool call has no function name")
219
- if tc.id:
220
- calls[tc.id] = tc.name
221
- for i, m in enumerate(t.messages):
222
- if m.role != "tool":
223
- continue
224
- cid = m.tool_call_id or ""
225
- if cid and cid not in calls:
226
- errors.append(
227
- f"[{i}] tool result has no matching tool call ({cid!r}) "
228
- "— the evidence was dropped")
229
-
230
- # reframe-prompt leak: the reframe model echoes its own rule list into
231
- # the task message. A user never types it, so the row is not a user row.
232
- for i, m in enumerate(t.messages):
233
- if m.role != "user" or not m.content:
234
- continue
235
- hit = _REFRAME_PROMPT_RE.search(m.content)
236
- if hit:
237
- errors.append(
238
- f"[{i}] user message contains reframe-prompt text "
239
- f"({hit.group(0)!r}) — the task must be the user's own words")
240
- hb = _HARNESS_BRIEF_RE.search(m.content)
241
- if hb:
242
- errors.append(
243
- f"[{i}] user message is a harness dispatch brief "
244
- f"({hb.group(0)!r}) — not the user's own words")
245
-
246
- # template placeholder leak: source-agent prompt templates sometimes
247
- # bleed their substitution tokens ("exec_reader_search_placeholder_0#")
248
- # into the assistant prose — a trainee must never see them. Any
249
- # message STARTING with one fails structurally (repair gets a
250
- # precise citation instead of the judge discovering it late).
251
- for i, m in enumerate(t.messages):
252
- if m.role != "assistant":
253
- continue
254
- head = (m.content or "").lstrip()[:120]
255
- leak = _TEMPLATE_LEAK_RE.match(head)
256
- if leak:
257
- errors.append(
258
- f"[{i}] assistant message starts with a leaked template "
259
- f"placeholder token ({leak.group(0)!r}) "
260
- "— rewrite the row as clean prose")
261
- marker = _RENDER_MARKER_RE.match(head)
262
- if marker:
263
- errors.append(
264
- f"[{i}] assistant message starts with a leaked rendering "
265
- f"marker ({marker.group(0).strip()!r}) "
266
- "— rewrite the row as clean prose")
267
-
268
- # brief echo: the writer sometimes answers a long subagent brief by
269
- # re-presenting it ("verbatim copy of the user's brief, no new
270
- # reasoning"). The judge catches it late and expensively; a similarity
271
- # check against the opening task catches it for free. Only the FIRST
272
- # user task message is compared — quoting a later user message back is
273
- # legitimate (confirming a correction), echoing the brief never is.
274
- brief = t.first_task_message() or ""
275
- if len(brief) >= 200:
276
- brief_norm = _norm_for_similarity(brief)
277
- for i, m in enumerate(t.messages):
278
- if m.role != "assistant" or not m.content:
279
- continue
280
- if len(m.content) < 200:
281
- continue
282
- ratio = difflib.SequenceMatcher(
283
- None, brief_norm, _norm_for_similarity(m.content),
284
- autojunk=False).ratio()
285
- if ratio >= 0.85:
286
- errors.append(
287
- f"[{i}] assistant message is a {ratio:.0%} verbatim echo "
288
- "of the user's opening brief — answer the task instead "
289
- "of re-presenting it")
290
-
291
- # First-turn echo: the writer sometimes opens by restating the task
292
- # verbatim instead of doing work. The brief-echo check above needs 200
293
- # chars on both sides, so a short echo slips through — measured
294
- # 2026-10-22: 121 of 1094 traces (11%) had a 1.00-similarity first
295
- # assistant turn with no tool call, and the user row was the agent's own
296
- # plan ("Ich prüfe die drei Artefakte zuerst …").
297
- first_asst = next((i for i, m in enumerate(t.messages)
298
- if m.role == "assistant"), None)
299
- if first_asst is not None and brief:
300
- m = t.messages[first_asst]
301
- body = (m.content or "").strip()
302
- if body and not m.tool_calls and len(body) >= 60:
303
- ratio = difflib.SequenceMatcher(
304
- None, _norm_for_similarity(brief[:600]),
305
- _norm_for_similarity(body[:600]), autojunk=False).ratio()
306
- if ratio >= 0.9:
307
- errors.append(
308
- f"[{first_asst}] first assistant turn is a {ratio:.0%} "
309
- "verbatim echo of the user's task with no tool call — "
310
- "do work instead of restating the brief")
311
-
312
- # P5 language: one language per trace. The judge catches a German task
313
- # answered in English but misses the reverse, and the source traces are
314
- # themselves mixed (an English brief answered in German by the source
315
- # agent). A trainee must learn to answer in the task's language, so a
316
- # CONFIDENT mismatch is a structural error with a precise citation.
317
- # Confidence guards: both sides need enough function words and a clear
318
- # ratio, so a German answer quoting English code (or a mixed task) does
319
- # not trip it; an explicit translation request is exempt.
320
- final_i = next((i for i in range(len(t.messages) - 1, -1, -1)
321
- if t.messages[i].role == "assistant"), None)
322
- if final_i is not None and not _LANG_SWITCH_RE.search(brief):
323
- final = t.messages[final_i].content or ""
324
- if len(final) >= 120:
325
- # 300 chars of the task: the instruction is at the top; a long
326
- # quoted log further down (a German report pasting an English
327
- # CI log) would otherwise flip the task's language.
328
- task_lang = _confident_lang(brief[:300], 3)
329
- ans_lang = _confident_lang(final[:800], 4)
330
- if task_lang and ans_lang and task_lang != ans_lang:
331
- errors.append(
332
- f"[{final_i}] final answer is in {ans_lang} but the task "
333
- f"is in {task_lang} — answer in the task's language (P5)")
334
-
335
- # role alternation: no two user or two assistant messages back-to-back
336
- prev: Optional[str] = None
337
- for i, m in enumerate(t.messages):
338
- if m.role not in VALID_ROLES:
339
- errors.append(f"[{i}] invalid role {m.role!r}")
340
- if m.role == "user" and prev == "user":
341
- errors.append(f"[{i}] two consecutive user messages")
342
- if m.role == "assistant" and prev == "assistant" and not (
343
- m.tool_calls or t.messages[i - 1].tool_calls
344
- ):
345
- errors.append(f"[{i}] two consecutive text-only assistant turns")
346
- prev = m.role
347
-
348
- # tool pairing: every call answered exactly once, every result has a call
349
- called: dict[str, str] = {} # id -> tool name
350
- answered: dict[str, int] = {}
351
- for i, m in enumerate(t.messages):
352
- for tc in m.tool_calls:
353
- if not tc.id:
354
- errors.append(f"[{i}] tool_call without id ({tc.name})")
355
- elif tc.id in called:
356
- errors.append(f"[{i}] duplicate tool_call id {tc.id}")
357
- else:
358
- called[tc.id] = tc.name
359
- if not tc.name:
360
- errors.append(f"[{i}] tool_call without name")
361
- args = tc.arguments or "{}"
362
- if not isinstance(args, str):
363
- errors.append(
364
- f"[{i}] tool_call {tc.name} arguments must be a JSON string"
365
- )
366
- else:
367
- try:
368
- json.loads(args)
369
- except json.JSONDecodeError as e:
370
- errors.append(
371
- f"[{i}] tool_call {tc.name} arguments not valid JSON: {e}"
372
- )
373
- if m.role == "tool":
374
- tcid = m.tool_call_id
375
- if not tcid:
376
- errors.append(f"[{i}] tool result without tool_call_id")
377
- elif tcid not in called:
378
- errors.append(f"[{i}] tool result references unknown call {tcid}")
379
- else:
380
- answered[tcid] = answered.get(tcid, 0) + 1
381
- if not (m.content or "").strip():
382
- # empty result text (content None/"" — 019ff135 class:
383
- # send_message {} -> content=None with tool_name set, so
384
- # the old content-None-AND-tool_name-None check passed
385
- # it). An SFT trainee must never learn to expect or emit
386
- # result-less tool rows.
387
- errors.append(f"[{i}] tool result is empty")
388
-
389
- for tcid, name in called.items():
390
- n = answered.get(tcid, 0)
391
- if n == 0:
392
- errors.append(f"tool call {tcid} ({name}) has no result")
393
- elif n > 1:
394
- errors.append(f"tool call {tcid} ({name}) has {n} results")
395
-
396
- # empty-content sanity
397
- for i, m in enumerate(t.messages):
398
- if m.role in ("user", "assistant") and m.content in ("", None) and not m.tool_calls:
399
- errors.append(f"[{i}] {m.role} message with empty content and no tool calls")
400
-
401
- # degenerate short-prose assistant rows ("\n\n" connectors, "Now
402
- # the fix:" fragments) — training noise; only NON-FINAL rows (the
403
- # final answer may legitimately be terse) and rows without calls.
404
- last = len(t.messages) - 1
405
- for i, m in enumerate(t.messages):
406
- if (m.role == "assistant" and i != last and not m.tool_calls
407
- and m.content and len(m.content.strip()) < 15):
408
- errors.append(
409
- f"[{i}] degenerate assistant prose ({len(m.content.strip())} chars): "
410
- f"{m.content.strip()[:40]!r}")
411
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  return errors
 
1
+ """Structural validation of an (improved) trace.
2
+
3
+ Checks that are format-level facts, independent of the quality guidelines —
4
+ guideline-scoring (quality rules) will be defined together with the user and
5
+ plugged in as a separate, stricter validator later.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import difflib
10
+ import json
11
+ import re
12
+ from typing import Optional
13
+
14
+ from traceforge.trace import Message, Trace
15
+
16
+ # template substitution tokens that leaked into message content, e.g.
17
+ # "exec_reader_search_placeholder_0#" (agent-VM prompt template bug in
18
+ # the source session). Word-ish identifier + "_placeholder_" + digits.
19
+ _TEMPLATE_LEAK_RE = re.compile(
20
+ r"[a-z][a-z0-9_]*_placeholder_\d+", re.IGNORECASE)
21
+
22
+ # rendering markers from trace.py's prompt rendering (the improver's INPUT
23
+ # format). The improver occasionally echoes them into assistant prose —
24
+ # e.g. a row whose whole content is "(tool-only turn)" — which a trainee
25
+ # must never see. Length-based degenerate checks miss these because the
26
+ # marker is 16 chars.
27
+ _RENDER_MARKER_RE = re.compile(
28
+ r"^\s*(?:\(tool-only turn\)"
29
+ r"|\[\d+\]\s+(?:ASSISTANT|USER|SYSTEM|TOOL)\b"
30
+ r"|->\s*TOOL\s+\S+\s+args=)",
31
+ re.IGNORECASE)
32
+
33
+ VALID_ROLES = {"system", "user", "assistant", "tool"}
34
+
35
+ # An explicit translation request makes a language switch the CORRECT answer,
36
+ # so the P5 gate must not fire on it.
37
+ _LANG_SWITCH_RE = re.compile(
38
+ r"\b(?:übersetz|translate|translation|auf englisch|auf deutsch|"
39
+ r"in english|in german|ins englische|ins deutsche)\b", re.IGNORECASE)
40
+
41
+ # Pipeline framing markers that belong to the improver's INPUT, not to the
42
+ # training target: the splitter prepends "[trace segment k of n — ...]" and
43
+ # the codex reader prepends "[subagent brief — role: ...]". A user never
44
+ # types these, so they are stripped from user rows before promotion.
45
+ _SCAFFOLD_RE = re.compile(
46
+ r"\[trace segment \d+ of \d+[^\]]*\]|\[subagent brief[^\]]*\]")
47
+
48
+ # Code-ish spans removed before language scoring: fenced/inline code,
49
+ # markdown link targets, URLs, absolute paths, and filenames. Their
50
+ # identifiers are English by convention and would otherwise outvote the
51
+ # prose's function words.
52
+ _CODEISH_RE = re.compile(
53
+ r"```.*?```"
54
+ r"|`[^`\n]*`"
55
+ r"|!?\[[^\]]*\]\([^)]*\)"
56
+ r"|https?://\S+"
57
+ r"|(?:[A-Za-z]:)?(?:/[\w.\-]+){2,}"
58
+ r"|[\w.\-]+\.(?:py|ts|tsx|js|mjs|go|rs|md|json|ya?ml|sh|toml|txt)\b",
59
+ re.S)
60
+
61
+
62
+ def _confident_lang(text: str, min_words: int) -> Optional[str]:
63
+ """'de'/'en' only when the call is unambiguous, else None.
64
+
65
+ Requires min_words function words AND a 70% dominant share, so a mixed
66
+ task or an answer quoting the other language does not trip the gate.
67
+ Code, paths, URLs and markdown link targets are removed first: an
68
+ English file path inside a German answer ("...-api-and-paired-...")
69
+ otherwise supplies enough English function words to flip the call.
70
+ """
71
+ from traceforge.classify import language_scores
72
+
73
+ de, en = language_scores(_CODEISH_RE.sub(" ", text))
74
+ total = de + en
75
+ if total < min_words:
76
+ return None
77
+ if max(de, en) / total < 0.7:
78
+ return None
79
+ return "de" if de > en else "en"
80
+
81
+
82
+ def strip_scaffold_markers(t: Trace) -> int:
83
+ """Remove pipeline framing markers from user rows (in place).
84
+
85
+ Returns the number of rows changed. A row that is ONLY a marker is left
86
+ untouched (stripping it would create an empty user message).
87
+ """
88
+ changed = 0
89
+ for m in t.messages:
90
+ if m.role != "user" or not m.content:
91
+ continue
92
+ new = _SCAFFOLD_RE.sub("", m.content)
93
+ if new == m.content:
94
+ continue
95
+ new = re.sub(r"[ \t]+\n", "\n", new)
96
+ new = re.sub(r"\n{3,}", "\n\n", new).strip()
97
+ if not new:
98
+ continue
99
+ m.content = new
100
+ changed += 1
101
+ return changed
102
+
103
+
104
+ # Harness memory-citation markup is not assistant prose. Codex memory makes
105
+ # the model emit a trailing
106
+ # `<oai-mem-citation><citation_entries>MEMORY.md:396-400|note=[...]</citation_entries></oai-mem-citation>`
107
+ # block; the improver carries it into the ideal trace, where it teaches the
108
+ # trainee to emit product-specific XML. Measured 2026-10-28: 62 of 4,487
109
+ # promoted traces (1.4%), 66 assistant rows + 2 user rows, ALWAYS trailing
110
+ # (the closing tag is the last thing in the row), and 411 raws carry it, so
111
+ # the batch keeps producing them. Strip at promote, gate as backstop.
112
+ _HARNESS_CITATION_RE = re.compile(
113
+ r"<oai-mem-citation>.*?</oai-mem-citation>"
114
+ r"|<citation_entries>.*?</citation_entries>", re.S)
115
+
116
+
117
+ def strip_harness_citation(t: Trace) -> int:
118
+ # Remove harness memory-citation blocks from any row (in place).
119
+ # Returns the number of rows changed; a row that is ONLY the block is
120
+ # left untouched (stripping it would create an empty row).
121
+ changed = 0
122
+ for m in t.messages:
123
+ c = m.content
124
+ if not c or ("oai-mem-citation" not in c and "citation_entries" not in c):
125
+ continue
126
+ new = _HARNESS_CITATION_RE.sub("", c)
127
+ if new == c:
128
+ continue
129
+ new = re.sub(r"[ \t]+\n", "\n", new)
130
+ new = re.sub(r"\n{3,}", "\n\n", new).rstrip()
131
+ if not new.strip():
132
+ continue
133
+ m.content = new
134
+ changed += 1
135
+ return changed
136
+
137
+
138
+ def _norm_for_similarity(text: str) -> str:
139
+ """Collapse whitespace and case so formatting noise can't hide an echo."""
140
+ return re.sub(r"\s+", " ", text).strip().lower()
141
+
142
+
143
+ # Unmistakable reframe-prompt text. The reframe model echoes its own rules
144
+ # into the task message; a user never types any of this. The sanitizer cuts
145
+ # it at reframe time, and this gate is the backstop that makes a leak fail
146
+ # loudly instead of promoting silently (measured: 68 of 1044 traces).
147
+ _REFRAME_PROMPT_RE = re.compile(
148
+ r"Same language as the current task message"
149
+ r"|The new task must ask ONLY"
150
+ r"|no meta commentary"
151
+ r"|answerable EXACTLY by the final answer"
152
+ r"|Reply with ONLY the JSON object"
153
+ r"|write that request as the new opening task message"
154
+ r"|A synthetic ideal-world agent trace"
155
+ r"|no mention of traces or reframing", re.IGNORECASE)
156
+
157
+ # Harness dispatch briefs are NOT user messages. A codex subagent rollout opens
158
+ # with the coordinator's dispatch text ("You are working inside Orca, a
159
+ # multi-agent IDE. You are a dispatched worker. Your coordinator's Orca session
160
+ # ID is: ... === CLI COMMANDS ==="), 6-7KB of orchestration boilerplate with
161
+ # session/task ids and CLI docs. Promoted as-is, the trainee learns to emit
162
+ # harness plumbing as the user turn. Measured 2026-10-22: 6 of 1046 improved
163
+ # traces, 4 of them promoted by the running batch — so gate it, don't just
164
+ # clean it.
165
+ #
166
+ # STRONG markers only (2026-10-24). The bare phrase `orca orchestration` used
167
+ # to be an alternative here, and it is a CLI NAME, not boilerplate: it appears
168
+ # in legitimate task rows ("...report via orca orchestration"), in the
169
+ # harness's own "You have N orchestration messages" notifications, and quoted
170
+ # inside compaction summaries. Measured on the raw pool: 43 traces matched
171
+ # only that phrase, 42 of them with a real task row and no brief at all, and
172
+ # 2 of the 20 recorded gate failures were clean traces killed by a quoted
173
+ # mention at row 228. The four strong markers below appear in every real
174
+ # brief and in nothing else.
175
+ _HARNESS_BRIEF_RE = re.compile(
176
+ r"You are working inside Orca, a multi-agent IDE"
177
+ r"|You are a dispatched worker"
178
+ r"|Your coordinator's Orca session ID is"
179
+ r"|=== CLI COMMANDS ===", re.IGNORECASE)
180
+
181
+ # Plumbing user rows: harness/validator chatter that an ideal trace routes
182
+ # around. These MAY be dropped. A substantive user turn may NOT.
183
+ _PLUMBING_USER_RE = re.compile(
184
+ r"(previous (final )?(response|answer|attempt).{0,40}"
185
+ r"(reject|invalid|not valid|validation error)"
186
+ r"|(rejected? by the).{0,40}(validator|contract)"
187
+ r"|^validation errors?\b|output contract validator"
188
+ r"|\[trace segment \d+ of \d+"
189
+ r"|\[subagent brief"
190
+ r"|\[CONTEXT COMPACTION"
191
+ r"|This session is being continued"
192
+ r"|^You have \d+ orchestration messages?\."
193
+ r"|^\[System: The active model for this chat has changed"
194
+ r"|^\[IMPORTANT: Background process proc_"
195
+ r"|\[SYSTEM NOTIFICATION - NOT USER INPUT\]"
196
+ r"|<task-notification>"
197
+ r"|automated background-task event, NOT a message from the user)",
198
+ re.IGNORECASE)
199
+
200
+
201
+ # Runtime/process notices are NOT a task. The harness writes them into the
202
+ # user slot mid-session ("[System: The active model for this chat has changed
203
+ # to glm via provider ...]", "[IMPORTANT: Background process proc_... completed
204
+ # normally (exit code 0)."). Measured 2026-10-29 on the promoted corpus: 9 of
205
+ # 4,747 traces (0.19%) opened with one of these as the task row, and a 4,000-raw
206
+ # sample of the pool found ZERO raws whose first user row was a notice — so the
207
+ # splice/reframe path fabricated the task from a mid-session notification, not
208
+ # the reader. Three of the nine collapsed a 99-205 message session to 2-4
209
+ # messages (the whole tool loop dropped). Strong markers only: both strings are
210
+ # emitted by the harness and by nothing else.
211
+ _RUNTIME_NOTICE_RE = re.compile(
212
+ r"^\s*(?:\[System: The active model for this chat has changed"
213
+ r"|\[IMPORTANT: Background process proc_"
214
+ r"|\[SYSTEM NOTIFICATION - NOT USER INPUT\]"
215
+ r"|<task-notification>)", re.IGNORECASE)
216
+
217
+
218
+ def _is_plumbing_user_row(m) -> bool:
219
+ """A user row that is harness/validator plumbing, not a user request."""
220
+ if m.role != "user":
221
+ return False
222
+ c = (m.content or "")[:400]
223
+ return bool(_PLUMBING_USER_RE.search(c)) or bool(_HARNESS_BRIEF_RE.search(c))
224
+
225
+
226
+ def substantive_user_turns(t: Trace) -> int:
227
+ """Count genuine user turns, ignoring plumbing and consecutive merges.
228
+
229
+ A turn group is a maximal run of user rows; it counts once when at least
230
+ one row in it is substantive. Consecutive rows are one turn because the
231
+ gate shape merges them (`_merge_same_role`), so a merge must not read as
232
+ a dropped turn.
233
+ """
234
+ n = 0
235
+ in_group = False
236
+ has_substantive = False
237
+ for m in t.messages:
238
+ if m.role == "user":
239
+ if not in_group:
240
+ in_group, has_substantive = True, False
241
+ if not _is_plumbing_user_row(m):
242
+ has_substantive = True
243
+ else:
244
+ if in_group and has_substantive:
245
+ n += 1
246
+ in_group = False
247
+ if in_group and has_substantive:
248
+ n += 1
249
+ return n
250
+
251
+
252
+ def validate_structural(t: Trace) -> list[str]:
253
+ """Return a list of structural errors ([] = valid)."""
254
+ errors: list[str] = []
255
+ if not t.messages:
256
+ return ["trace has no messages"]
257
+
258
+ roles = [m.role for m in t.messages]
259
+ if roles[0] == "assistant":
260
+ errors.append("first message is assistant (expected user or system)")
261
+ if "user" not in roles:
262
+ errors.append("no user message in trace")
263
+ if roles[-1] not in ("assistant",):
264
+ errors.append(f"last message role is {roles[-1]!r} (expected assistant)")
265
+
266
+ # tool-call / tool-result pairing: a rewrite that drops a call but
267
+ # keeps its result (or strips a call's name) is not a valid
268
+ # conversation — the trainee would learn to answer without evidence.
269
+ # Measured on the Space's 3B local mode: it dropped the successful
270
+ # call and kept the failed call's error row, leaving an orphan result.
271
+ calls: dict[str, str] = {}
272
+ for i, m in enumerate(t.messages):
273
+ for tc in (m.tool_calls or []):
274
+ if not (tc.name or "").strip():
275
+ errors.append(f"[{i}] tool call has no function name")
276
+ if tc.id:
277
+ calls[tc.id] = tc.name
278
+ for i, m in enumerate(t.messages):
279
+ if m.role != "tool":
280
+ continue
281
+ cid = m.tool_call_id or ""
282
+ if cid and cid not in calls:
283
+ errors.append(
284
+ f"[{i}] tool result has no matching tool call ({cid!r}) "
285
+ "— the evidence was dropped")
286
+
287
+ # reframe-prompt leak: the reframe model echoes its own rule list into
288
+ # the task message. A user never types it, so the row is not a user row.
289
+ for i, m in enumerate(t.messages):
290
+ if m.role != "user" or not m.content:
291
+ continue
292
+ hit = _REFRAME_PROMPT_RE.search(m.content)
293
+ if hit:
294
+ errors.append(
295
+ f"[{i}] user message contains reframe-prompt text "
296
+ f"({hit.group(0)!r}) — the task must be the user's own words")
297
+ hb = _HARNESS_BRIEF_RE.search(m.content)
298
+ if hb:
299
+ errors.append(
300
+ f"[{i}] user message is a harness dispatch brief "
301
+ f"({hb.group(0)!r}) — not the user's own words")
302
+
303
+ # opening task row must be the user's own words, not a runtime notice.
304
+ first_user = next((i for i, m in enumerate(t.messages)
305
+ if m.role == "user"), None)
306
+ if first_user is not None:
307
+ c0 = t.messages[first_user].content or ""
308
+ notice = _RUNTIME_NOTICE_RE.match(c0)
309
+ if notice:
310
+ errors.append(
311
+ f"[{first_user}] opening user row is a runtime/process notice "
312
+ f"({notice.group(0).strip()[:60]!r}) — the task must be the "
313
+ "user's own request, not harness telemetry")
314
+
315
+ # harness memory-citation markup: product-specific XML in the target row.
316
+ for i, m in enumerate(t.messages):
317
+ if m.content and _HARNESS_CITATION_RE.search(m.content):
318
+ errors.append(
319
+ f"[{i}] {m.role} message carries harness memory-citation markup "
320
+ "— strip it, the trainee must not learn product XML")
321
+
322
+ # template placeholder leak: source-agent prompt templates sometimes
323
+ # bleed their substitution tokens ("exec_reader_search_placeholder_0#")
324
+ # into the assistant prose — a trainee must never see them. Any
325
+ # message STARTING with one fails structurally (repair gets a
326
+ # precise citation instead of the judge discovering it late).
327
+ for i, m in enumerate(t.messages):
328
+ if m.role != "assistant":
329
+ continue
330
+ head = (m.content or "").lstrip()[:120]
331
+ leak = _TEMPLATE_LEAK_RE.match(head)
332
+ if leak:
333
+ errors.append(
334
+ f"[{i}] assistant message starts with a leaked template "
335
+ f"placeholder token ({leak.group(0)!r}) "
336
+ "— rewrite the row as clean prose")
337
+ marker = _RENDER_MARKER_RE.match(head)
338
+ if marker:
339
+ errors.append(
340
+ f"[{i}] assistant message starts with a leaked rendering "
341
+ f"marker ({marker.group(0).strip()!r}) "
342
+ "— rewrite the row as clean prose")
343
+
344
+ # brief echo: the writer sometimes answers a long subagent brief by
345
+ # re-presenting it ("verbatim copy of the user's brief, no new
346
+ # reasoning"). The judge catches it late and expensively; a similarity
347
+ # check against the opening task catches it for free. Only the FIRST
348
+ # user task message is compared — quoting a later user message back is
349
+ # legitimate (confirming a correction), echoing the brief never is.
350
+ brief = t.first_task_message() or ""
351
+ if len(brief) >= 200:
352
+ brief_norm = _norm_for_similarity(brief)
353
+ for i, m in enumerate(t.messages):
354
+ if m.role != "assistant" or not m.content:
355
+ continue
356
+ if len(m.content) < 200:
357
+ continue
358
+ ratio = difflib.SequenceMatcher(
359
+ None, brief_norm, _norm_for_similarity(m.content),
360
+ autojunk=False).ratio()
361
+ if ratio >= 0.85:
362
+ errors.append(
363
+ f"[{i}] assistant message is a {ratio:.0%} verbatim echo "
364
+ "of the user's opening brief — answer the task instead "
365
+ "of re-presenting it")
366
+
367
+ # First-turn echo: the writer sometimes opens by restating the task
368
+ # verbatim instead of doing work. The brief-echo check above needs 200
369
+ # chars on both sides, so a short echo slips through — measured
370
+ # 2026-10-22: 121 of 1094 traces (11%) had a 1.00-similarity first
371
+ # assistant turn with no tool call, and the user row was the agent's own
372
+ # plan ("Ich prüfe die drei Artefakte zuerst …").
373
+ first_asst = next((i for i, m in enumerate(t.messages)
374
+ if m.role == "assistant"), None)
375
+ if first_asst is not None and brief:
376
+ m = t.messages[first_asst]
377
+ body = (m.content or "").strip()
378
+ if body and not m.tool_calls and len(body) >= 60:
379
+ ratio = difflib.SequenceMatcher(
380
+ None, _norm_for_similarity(brief[:600]),
381
+ _norm_for_similarity(body[:600]), autojunk=False).ratio()
382
+ if ratio >= 0.9:
383
+ errors.append(
384
+ f"[{first_asst}] first assistant turn is a {ratio:.0%} "
385
+ "verbatim echo of the user's task with no tool call — "
386
+ "do work instead of restating the brief")
387
+
388
+ # P5 language: one language per trace. The judge catches a German task
389
+ # answered in English but misses the reverse, and the source traces are
390
+ # themselves mixed (an English brief answered in German by the source
391
+ # agent). A trainee must learn to answer in the task's language, so a
392
+ # CONFIDENT mismatch is a structural error with a precise citation.
393
+ # Confidence guards: both sides need enough function words and a clear
394
+ # ratio, so a German answer quoting English code (or a mixed task) does
395
+ # not trip it; an explicit translation request is exempt.
396
+ final_i = next((i for i in range(len(t.messages) - 1, -1, -1)
397
+ if t.messages[i].role == "assistant"), None)
398
+ if final_i is not None and not _LANG_SWITCH_RE.search(brief):
399
+ final = t.messages[final_i].content or ""
400
+ if len(final) >= 120:
401
+ # 300 chars of the task: the instruction is at the top; a long
402
+ # quoted log further down (a German report pasting an English
403
+ # CI log) would otherwise flip the task's language.
404
+ task_lang = _confident_lang(brief[:300], 3)
405
+ ans_lang = _confident_lang(final[:800], 4)
406
+ if task_lang and ans_lang and task_lang != ans_lang:
407
+ errors.append(
408
+ f"[{final_i}] final answer is in {ans_lang} but the task "
409
+ f"is in {task_lang} — answer in the task's language (P5)")
410
+
411
+ # role alternation: no two user or two assistant messages back-to-back
412
+ prev: Optional[str] = None
413
+ for i, m in enumerate(t.messages):
414
+ if m.role not in VALID_ROLES:
415
+ errors.append(f"[{i}] invalid role {m.role!r}")
416
+ if m.role == "user" and prev == "user":
417
+ errors.append(f"[{i}] two consecutive user messages")
418
+ if m.role == "assistant" and prev == "assistant" and not (
419
+ m.tool_calls or t.messages[i - 1].tool_calls
420
+ ):
421
+ errors.append(f"[{i}] two consecutive text-only assistant turns")
422
+ prev = m.role
423
+
424
+ # tool pairing: every call answered exactly once, every result has a call
425
+ called: dict[str, str] = {} # id -> tool name
426
+ answered: dict[str, int] = {}
427
+ for i, m in enumerate(t.messages):
428
+ for tc in m.tool_calls:
429
+ if not tc.id:
430
+ errors.append(f"[{i}] tool_call without id ({tc.name})")
431
+ elif tc.id in called:
432
+ errors.append(f"[{i}] duplicate tool_call id {tc.id}")
433
+ else:
434
+ called[tc.id] = tc.name
435
+ if not tc.name:
436
+ errors.append(f"[{i}] tool_call without name")
437
+ args = tc.arguments or "{}"
438
+ if not isinstance(args, str):
439
+ errors.append(
440
+ f"[{i}] tool_call {tc.name} arguments must be a JSON string"
441
+ )
442
+ else:
443
+ try:
444
+ json.loads(args)
445
+ except json.JSONDecodeError as e:
446
+ errors.append(
447
+ f"[{i}] tool_call {tc.name} arguments not valid JSON: {e}"
448
+ )
449
+ if m.role == "tool":
450
+ tcid = m.tool_call_id
451
+ if not tcid:
452
+ errors.append(f"[{i}] tool result without tool_call_id")
453
+ elif tcid not in called:
454
+ errors.append(f"[{i}] tool result references unknown call {tcid}")
455
+ else:
456
+ answered[tcid] = answered.get(tcid, 0) + 1
457
+ if not (m.content or "").strip():
458
+ # empty result text (content None/"" — 019ff135 class:
459
+ # send_message {} -> content=None with tool_name set, so
460
+ # the old content-None-AND-tool_name-None check passed
461
+ # it). An SFT trainee must never learn to expect or emit
462
+ # result-less tool rows.
463
+ errors.append(f"[{i}] tool result is empty")
464
+
465
+ for tcid, name in called.items():
466
+ n = answered.get(tcid, 0)
467
+ if n == 0:
468
+ errors.append(f"tool call {tcid} ({name}) has no result")
469
+ elif n > 1:
470
+ errors.append(f"tool call {tcid} ({name}) has {n} results")
471
+
472
+ # empty-content sanity
473
+ for i, m in enumerate(t.messages):
474
+ if m.role in ("user", "assistant") and m.content in ("", None) and not m.tool_calls:
475
+ errors.append(f"[{i}] {m.role} message with empty content and no tool calls")
476
+
477
+ # degenerate short-prose assistant rows ("\n\n" connectors, "Now
478
+ # the fix:" fragments) — training noise; only NON-FINAL rows (the
479
+ # final answer may legitimately be terse) and rows without calls.
480
+ last = len(t.messages) - 1
481
+ for i, m in enumerate(t.messages):
482
+ if (m.role == "assistant" and i != last and not m.tool_calls
483
+ and m.content and len(m.content.strip()) < 15):
484
+ errors.append(
485
+ f"[{i}] degenerate assistant prose ({len(m.content.strip())} chars): "
486
+ f"{m.content.strip()[:40]!r}")
487
+
488
  return errors