Spaces:
Running
Running
zero-gpu: run LFM2.5-24B-A2B on the xlarge slice from a mounted volume
Browse files- BUILD.json +3 -3
- README.md +153 -146
- data/dataset/dataset_card.json +153 -153
- requirements.txt +6 -6
- traceforge/balance.py +833 -0
- traceforge/cli.py +0 -0
- traceforge/config.py +34 -0
- traceforge/defects.py +23 -1
- traceforge/distill.py +274 -0
- traceforge/export.py +7 -0
- traceforge/grounding.py +4 -2
- traceforge/identity.py +64 -0
- traceforge/improver/harness.py +940 -849
- traceforge/live.py +5 -1
- traceforge/net.py +72 -0
- traceforge/space/app.py +501 -499
- traceforge/space/local_llm.py +406 -291
- traceforge/space/pipeline.py +406 -404
- traceforge/splice.py +0 -0
- traceforge/synth.py +634 -0
- traceforge/trace.py +43 -0
- traceforge/triage.py +29 -10
- traceforge/validate.py +487 -411
BUILD.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
{
|
| 2 |
-
"built_at": "2026-10-
|
| 3 |
-
"commit": "
|
| 4 |
-
"files":
|
| 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 |
-
`
|
| 57 |
-
only while a call runs, and the time is
|
| 58 |
-
daily ZeroGPU quota, not to the Space
|
| 59 |
-
free account, 40 min PRO. Idle time
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
|
| 114 |
-
|
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 3 |
"languages": {
|
| 4 |
-
"en":
|
| 5 |
-
"de":
|
| 6 |
},
|
| 7 |
"language_pct": {
|
| 8 |
-
"en":
|
| 9 |
-
"de": 4.
|
| 10 |
},
|
| 11 |
"topic_language_matrix": {
|
| 12 |
"languages": [
|
|
@@ -15,99 +15,99 @@
|
|
| 15 |
],
|
| 16 |
"rows": {
|
| 17 |
"review": {
|
| 18 |
-
"en":
|
| 19 |
-
"de":
|
| 20 |
-
"total":
|
| 21 |
},
|
| 22 |
"implement": {
|
| 23 |
-
"en":
|
| 24 |
-
"de":
|
| 25 |
-
"total":
|
| 26 |
},
|
| 27 |
"debug": {
|
| 28 |
-
"en":
|
| 29 |
-
"de":
|
| 30 |
-
"total":
|
| 31 |
},
|
| 32 |
"research": {
|
| 33 |
-
"en":
|
| 34 |
-
"de":
|
| 35 |
-
"total":
|
| 36 |
},
|
| 37 |
"other": {
|
| 38 |
-
"en":
|
| 39 |
-
"de":
|
| 40 |
-
"total":
|
| 41 |
},
|
| 42 |
"test": {
|
| 43 |
-
"en":
|
| 44 |
-
"de":
|
| 45 |
-
"total":
|
| 46 |
},
|
| 47 |
"ops": {
|
| 48 |
-
"en":
|
| 49 |
-
"de": 2,
|
| 50 |
-
"total": 107
|
| 51 |
-
},
|
| 52 |
-
"data-analysis": {
|
| 53 |
-
"en": 83,
|
| 54 |
"de": 4,
|
| 55 |
-
"total":
|
| 56 |
},
|
| 57 |
"refactor": {
|
| 58 |
-
"en":
|
| 59 |
"de": 3,
|
| 60 |
-
"total":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
},
|
| 62 |
"creative": {
|
| 63 |
-
"en":
|
| 64 |
"de": 0,
|
| 65 |
-
"total":
|
| 66 |
},
|
| 67 |
"office-docs": {
|
| 68 |
"en": 10,
|
| 69 |
-
"de":
|
| 70 |
-
"total":
|
| 71 |
}
|
| 72 |
},
|
| 73 |
"totals": {
|
| 74 |
-
"en":
|
| 75 |
-
"de":
|
| 76 |
-
"total":
|
| 77 |
}
|
| 78 |
},
|
| 79 |
"tools_and_prompt": {
|
| 80 |
"tools": {
|
| 81 |
-
"rows":
|
| 82 |
-
"with_tools":
|
| 83 |
-
"pct":
|
| 84 |
"by_source": {
|
| 85 |
-
"derived-from-observed-calls":
|
| 86 |
-
"claude-request-shape":
|
| 87 |
-
"none":
|
| 88 |
}
|
| 89 |
},
|
| 90 |
"system_prompt": {
|
| 91 |
-
"rows":
|
| 92 |
-
"with_system":
|
| 93 |
-
"pct":
|
| 94 |
"by_source": {
|
| 95 |
-
"none":
|
| 96 |
"raw-system-row": 663,
|
| 97 |
-
"claude-request-shape":
|
| 98 |
-
"codex-base-instructions":
|
| 99 |
"in-trace": 292,
|
| 100 |
-
"hermes-system-prompts":
|
| 101 |
}
|
| 102 |
}
|
| 103 |
},
|
| 104 |
"provenance": {
|
| 105 |
"basis": "session-id prefix (the improver overwrites meta.source)",
|
| 106 |
"counts": {
|
| 107 |
-
"real-session":
|
| 108 |
-
"subagent":
|
| 109 |
"nemotron-agentic-ka": 581,
|
| 110 |
-
"split-segment":
|
| 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":
|
| 119 |
-
"subagent":
|
| 120 |
-
"nemotron-agentic-ka":
|
| 121 |
-
"split-segment":
|
| 122 |
-
"nemotron-agentic-tc":
|
| 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":
|
| 131 |
-
"subagent":
|
| 132 |
},
|
| 133 |
"tasks": {
|
| 134 |
-
"review":
|
| 135 |
-
"implement":
|
| 136 |
-
"debug":
|
| 137 |
-
"research":
|
| 138 |
-
"other":
|
| 139 |
-
"test":
|
| 140 |
-
"ops":
|
| 141 |
-
"
|
| 142 |
-
"
|
| 143 |
-
"creative":
|
| 144 |
-
"office-docs":
|
| 145 |
},
|
| 146 |
"messages": {
|
| 147 |
-
"total":
|
| 148 |
"by_role": {
|
| 149 |
-
"
|
| 150 |
-
"
|
| 151 |
-
"
|
| 152 |
-
"
|
| 153 |
},
|
| 154 |
"per_trace": {
|
| 155 |
-
"mean": 33.
|
| 156 |
-
"median":
|
| 157 |
-
"p90":
|
| 158 |
-
"max":
|
| 159 |
}
|
| 160 |
},
|
| 161 |
"tool_calls": {
|
| 162 |
-
"total":
|
| 163 |
"per_trace": {
|
| 164 |
-
"mean": 15.
|
| 165 |
-
"median": 10
|
| 166 |
-
"p90":
|
| 167 |
-
"max":
|
| 168 |
},
|
| 169 |
-
"traces_with_none":
|
| 170 |
-
"distinct_tools":
|
| 171 |
"top_tools": {
|
| 172 |
-
"Bash":
|
| 173 |
-
"exec":
|
| 174 |
-
"terminal":
|
| 175 |
-
"read_file":
|
| 176 |
-
"Read":
|
| 177 |
-
"Edit":
|
| 178 |
-
"search_files":
|
| 179 |
-
"execute_code":
|
| 180 |
-
"tool_call":
|
| 181 |
-
"Grep":
|
| 182 |
-
"skill_view":
|
| 183 |
-
"patch":
|
|
|
|
| 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
|
| 193 |
"p90": 1,
|
| 194 |
"max": 18
|
| 195 |
},
|
| 196 |
-
"single_turn_traces":
|
| 197 |
-
"multi_turn_traces":
|
| 198 |
},
|
| 199 |
"tokens": {
|
| 200 |
"method": "chars/4 (prose proxy); scale by ~1.15 for a central estimate",
|
| 201 |
"budget": 131072,
|
| 202 |
-
"chars_total":
|
| 203 |
-
"est_total":
|
| 204 |
"per_trace": {
|
| 205 |
-
"mean":
|
| 206 |
-
"median":
|
| 207 |
-
"p90":
|
| 208 |
"max": 121769
|
| 209 |
},
|
| 210 |
"fits_budget": {
|
| 211 |
"4096": {
|
| 212 |
-
"traces":
|
| 213 |
-
"pct":
|
| 214 |
},
|
| 215 |
"8192": {
|
| 216 |
-
"traces":
|
| 217 |
-
"pct": 32.
|
| 218 |
},
|
| 219 |
"16384": {
|
| 220 |
-
"traces":
|
| 221 |
-
"pct":
|
| 222 |
},
|
| 223 |
"65536": {
|
| 224 |
-
"traces":
|
| 225 |
-
"pct": 98.
|
| 226 |
},
|
| 227 |
"131072": {
|
| 228 |
-
"traces":
|
| 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 |
-
"
|
| 238 |
-
"
|
| 239 |
-
"
|
| 240 |
-
"
|
| 241 |
},
|
| 242 |
-
"assistant_share_pct": 3.
|
| 243 |
-
"tool_share_pct": 90.
|
| 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":
|
| 251 |
-
"pct":
|
| 252 |
},
|
| 253 |
"D2": {
|
| 254 |
"label": "short loop",
|
| 255 |
-
"traces":
|
| 256 |
-
"pct": 17.
|
| 257 |
},
|
| 258 |
"D3": {
|
| 259 |
"label": "multi-step",
|
| 260 |
-
"traces":
|
| 261 |
-
"pct":
|
| 262 |
},
|
| 263 |
"D4": {
|
| 264 |
"label": "long session",
|
| 265 |
-
"traces":
|
| 266 |
-
"pct":
|
| 267 |
},
|
| 268 |
"D5": {
|
| 269 |
"label": "very long session",
|
| 270 |
-
"traces":
|
| 271 |
-
"pct": 26.
|
| 272 |
}
|
| 273 |
},
|
| 274 |
"tool_calls": {
|
| 275 |
-
"mean": 15.
|
| 276 |
-
"median": 10
|
| 277 |
-
"p90":
|
| 278 |
-
"max":
|
| 279 |
},
|
| 280 |
"messages": {
|
| 281 |
-
"mean": 33.
|
| 282 |
-
"median":
|
| 283 |
-
"p90":
|
| 284 |
-
"max":
|
| 285 |
},
|
| 286 |
"est_tokens": {
|
| 287 |
-
"mean":
|
| 288 |
-
"median":
|
| 289 |
-
"p90":
|
| 290 |
"max": 121769
|
| 291 |
}
|
| 292 |
},
|
| 293 |
"safety_filter": {
|
| 294 |
"applied": true,
|
| 295 |
-
"traces_in":
|
| 296 |
-
"traces_kept":
|
| 297 |
-
"traces_excluded":
|
| 298 |
-
"excluded_secret":
|
| 299 |
-
"excluded_personal":
|
| 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":
|
| 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>=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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:.
|
| 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
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
"
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
"
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
"
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
)
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
)
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
)
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
os.
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
if
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
def
|
| 425 |
-
cfg: Config,
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
"""
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
)
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
errors = [f"
|
| 607 |
-
continue
|
| 608 |
-
reply =
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
cfg,
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"`
|
| 379 |
-
"to **your** HF daily quota, not to the Space owner
|
| 380 |
-
"
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
"
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
import
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
ap.add_argument("--
|
| 486 |
-
|
| 487 |
-
ap.add_argument("--
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
app
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
|
|
|
|
|
|
|
|
| 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
|
| 4 |
-
|
| 5 |
-
remote endpoint — a urllib POST to
|
| 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 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
#
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
return
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 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:
|
| 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 |
-
#
|
| 82 |
-
# one request at a time.
|
| 83 |
-
|
| 84 |
-
cfg.
|
| 85 |
-
cfg.
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
#
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
cfg.
|
| 93 |
-
cfg.
|
| 94 |
-
cfg.
|
| 95 |
-
cfg.
|
| 96 |
-
|
| 97 |
-
cfg.
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
cfg.
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
cfg.
|
| 104 |
-
|
| 105 |
-
cfg.
|
| 106 |
-
cfg.
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
return False, "no LLM endpoint
|
| 125 |
-
if not
|
| 126 |
-
return False, "no
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
``
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
"
|
| 204 |
-
"
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
#
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
errs =
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
#
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
errs =
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
#
|
| 243 |
-
# the
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
self.
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
out.append(
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
if
|
| 299 |
-
return {
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
"
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
trace
|
| 322 |
-
|
| 323 |
-
trace.
|
| 324 |
-
trace.
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
improved
|
| 339 |
-
improved.
|
| 340 |
-
improved.
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
#
|
| 345 |
-
#
|
| 346 |
-
#
|
| 347 |
-
#
|
| 348 |
-
#
|
| 349 |
-
#
|
| 350 |
-
#
|
| 351 |
-
#
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
rec =
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
|
|
|
|
|
|
|
|
| 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
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
#
|
| 110 |
-
#
|
| 111 |
-
#
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
r"
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
r"
|
| 151 |
-
r"
|
| 152 |
-
r"|
|
| 153 |
-
r"|
|
| 154 |
-
r"|
|
| 155 |
-
r"|
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
""
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
""
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
#
|
| 269 |
-
#
|
| 270 |
-
#
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
#
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
if
|
| 339 |
-
errors.append(
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
#
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 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
|