Skip to content

Commit 1a49d64

Browse files
zzzhr97claude
andcommitted
Add dag_construction example module
Reference implementation of the partition -> build_dag -> merge_view pipeline for turning reasoning traces into DAGs, plus per-step customization notes and CI. Closes #1 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 83ab62f commit 1a49d64

25 files changed

Lines changed: 1946 additions & 0 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: dag-construction-ci
2+
3+
on:
4+
push:
5+
paths:
6+
- "dag_construction/**"
7+
- ".github/workflows/dag-construction-ci.yml"
8+
pull_request:
9+
paths:
10+
- "dag_construction/**"
11+
- ".github/workflows/dag-construction-ci.yml"
12+
13+
jobs:
14+
test:
15+
runs-on: ubuntu-latest
16+
defaults:
17+
run:
18+
working-directory: dag_construction
19+
steps:
20+
- uses: actions/checkout@v4
21+
- uses: actions/setup-python@v5
22+
with:
23+
python-version: "3.11"
24+
- run: python -m pip install --upgrade pip
25+
- run: pip install -e ".[dev]"
26+
- run: ruff check .
27+
- run: mypy trm_dag
28+
- run: pytest -q

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121
This repository provides the TRM-Preference dataset, TRM weights, and implementations for TRM training and TRM-guided policy optimization.
2222

23+
For modular reasoning-trace DAG construction, see [`dag_construction/`](dag_construction/).
24+
2325
The **Thinking Reward Model (TRM)** evaluates the quality of *reasoning traces* rather than final answers. We study how to optimize reasoning itself: instead of only asking “Is the answer correct?”, we ask:
2426

2527
> **Is this a good way to think?**

dag_construction/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Whatever your OpenAI-compatible endpoint expects.
2+
# This may be a full URL (e.g. https://api.example.com/v1) or a proxy token.
3+
# The value is passed to the OpenAI client unchanged.
4+
DAG_CONSTRUCTION_BASE_URL="..."
5+
DAG_CONSTRUCTION_MODEL="deepseek-v3.2"
6+
DAG_CONSTRUCTION_API_KEY="..."

dag_construction/README.md

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# TRM DAG Construction
2+
3+
Build DAGs over reasoning traces.
4+
5+
> Example / reference implementation. Defaults reflect one specific setup — swap pieces out for your own data and models.
6+
7+
## Pipeline
8+
9+
Three steps, in order:
10+
11+
```text
12+
(1) partition -> (2) build_dag -> (3) merge_view
13+
```
14+
15+
1. **`partition`** — split a raw `trace` into an ordered list of steps.
16+
2. **`build_dag`** — for each new step, ask an LLM to classify how it attaches to earlier steps as `continue` / `backtrack` / `merge`, then validate and majority-vote into parents.
17+
3. **`merge_view`** — collapse linear `continue` chains into super-nodes.
18+
19+
## Quickstart
20+
21+
```bash
22+
cd dag_construction
23+
pip install -e ".[dev]"
24+
cp .env.example .env
25+
# Fill DAG_CONSTRUCTION_API_KEY, DAG_CONSTRUCTION_MODEL, optional DAG_CONSTRUCTION_BASE_URL.
26+
python examples/run_single.py
27+
```
28+
29+
Batch JSONL:
30+
31+
```bash
32+
python -m trm_dag build --input items.jsonl --output out.jsonl --num-threads 8 --resume
33+
```
34+
35+
## Customizing each step
36+
37+
### 1. `partition`
38+
39+
- Different keywords: `partition_keyword(..., keywords=("Step", "Stage", ...))` or CLI `--keywords ...`.
40+
- Skip it: pass rows with `"steps": [...]` directly to `build_dag_batch`.
41+
- Custom splitter: write your own `(steps, fillers)` function and feed `"steps"` in. See `trm_dag/partition.py` for the contract.
42+
43+
### 2. `build_dag`
44+
45+
- Prompt: edit `trm_dag/prompts/dag_construction.txt` (keep the `$current_id`, `$input_steps`, `$available_steps`, `$leaf_steps`, `$last_previous_step_id` placeholders).
46+
- LLM backend: any OpenAI-compatible endpoint via `DAG_CONSTRUCTION_BASE_URL` / `DAG_CONSTRUCTION_MODEL`. For non-OpenAI APIs, implement the `ChatClient` protocol in `trm_dag/client.py` and pass `client=` to `build_dag` / `build_dag_batch`.
47+
- Sampling: `OpenAIConfig(temperature, top_p, n, max_tokens)` and `DagParams(regen_limit)`, or matching CLI flags.
48+
- Attachment pool size: `DagParams(main_path_cap=..., other_leaf_cap=...)`.
49+
- Action set / voting: edit `parse_action_previous`, `_validate_candidate`, `majority_vote` in `trm_dag/components.py` (and the prompt).
50+
- Determinism: `DagParams(random_seed=0)`.
51+
52+
### 3. `merge_view`
53+
54+
- Different merge rule: edit `collapse_continue_chains` in `trm_dag/components.py` (`is_continue_edge`, `is_chain_head`).
55+
- Skip it: just read `result["dag_graph_raw"]`.
56+
- Per-super-node summarization: post-process `dag_graph_merged["merged_nodes"]` with your own summarizer.
57+
58+
## Python API
59+
60+
```python
61+
from trm_dag import DagParams, OpenAIConfig, build_dag
62+
63+
result = build_dag(
64+
prompt,
65+
steps,
66+
openai_config=OpenAIConfig.from_env(),
67+
dag_params=DagParams(random_seed=0),
68+
)
69+
70+
raw = result["dag_graph_raw"]
71+
merged = result["dag_graph_merged"]
72+
```
73+
74+
Batch:
75+
76+
```python
77+
from trm_dag import DagParams, OpenAIConfig, build_dag_batch
78+
79+
rows = [
80+
{"prompt": "...", "steps": ["Step one.", "Then two."]},
81+
{"prompt": "...", "trace": "Step one.\n\nThen two."},
82+
]
83+
84+
out_rows = build_dag_batch(
85+
rows,
86+
openai_config=OpenAIConfig.from_env(),
87+
dag_params=DagParams(regen_limit=5, random_seed=0),
88+
output_path="items_dag.jsonl",
89+
resume=True,
90+
)
91+
```
92+
93+
## CLI
94+
95+
```bash
96+
python -m trm_dag build --input items.jsonl --output items_dag.jsonl
97+
```
98+
99+
Build flags: `--input`, `--output`, `--num-threads`, `--resume` / `--no-resume` / `--rebuild`, `--keywords`, `--regen-limit`, `--main-path-cap`, `--other-leaf-cap`, `--random-seed`.
100+
101+
LLM flags: `--api-key`, `--base-url`, `--model`, `--temperature`, `--top-p`, `--max-tokens`, `--n`, `--timeout`.
102+
103+
## Input And Output
104+
105+
Input rows can contain pre-split steps:
106+
107+
```json
108+
{"prompt": "...", "steps": ["Step one.", "Then two."]}
109+
```
110+
111+
or a raw trace:
112+
113+
```json
114+
{"prompt": "...", "trace": "Step one.\\n\\nThen two."}
115+
```
116+
117+
Output rows add `dag_graph_raw`, `dag_graph_merged`, `dag_parse_errors`, `dag_usage`. When input contains `trace`, `build_dag_batch` also keeps generated `steps` and `fillers` on the output row.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[
2+
{
3+
"prompt": "A train trip has a fixed distance and the same short stop each time. The first plan takes 5 hours at speed v. A faster plan takes 4 hours at speed v+15. Find the total time for a third plan at speed v+10 with the same stop.",
4+
"steps": [
5+
"Let the distance be d miles and the stop time be h hours.",
6+
"From the first plan, d/v + h = 5.",
7+
"From the faster plan, d/(v+15) + h = 4.",
8+
"One tempting branch is to treat the one-hour saving as if it came directly from adding 15 mph.",
9+
"On that shortcut, adding 10 mph would save about two thirds of an hour, giving a rough answer near 4 hours 20 minutes.",
10+
"But that branch is shaky: the trip has a fixed distance, so the time saved cannot be scaled directly from the speed increase.",
11+
"I should go back to the two trip equations and compare the travel-time parts instead.",
12+
"Subtracting the two equations gives d/v - d/(v+15) = 1.",
13+
"Solving the equations gives v = 30, d = 90, and h = 2 hours.",
14+
"For the third plan, the travel time is 90/(30+10) = 2.25 hours.",
15+
"Therefore the total third-plan time is 2.25 + 2 = 4.25 hours, or 255 minutes."
16+
]
17+
}
18+
]
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import json
5+
from pathlib import Path
6+
7+
from trm_dag import DagParams, OpenAIConfig, build_dag_batch
8+
9+
10+
def main() -> None:
11+
parser = argparse.ArgumentParser()
12+
parser.add_argument("--input", default=str(Path(__file__).with_name("data.json")))
13+
parser.add_argument("--output", default="out.jsonl")
14+
parser.add_argument("--num-threads", type=int, default=4)
15+
args = parser.parse_args()
16+
17+
rows = json.loads(Path(args.input).read_text(encoding="utf-8"))
18+
build_dag_batch(
19+
rows,
20+
openai_config=OpenAIConfig.from_env(max_tokens=1024),
21+
dag_params=DagParams(random_seed=0),
22+
num_threads=args.num_threads,
23+
output_path=args.output,
24+
resume=True,
25+
)
26+
print(f"wrote {args.output}")
27+
28+
29+
if __name__ == "__main__":
30+
main()
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
6+
from trm_dag import DagParams, OpenAIConfig, build_dag
7+
8+
9+
def main() -> None:
10+
item = json.loads((Path(__file__).with_name("data.json")).read_text(encoding="utf-8"))[0]
11+
result = build_dag(
12+
item["prompt"],
13+
item["steps"],
14+
openai_config=OpenAIConfig.from_env(max_tokens=1024),
15+
dag_params=DagParams(random_seed=0),
16+
)
17+
print(
18+
json.dumps(
19+
{
20+
"dag_graph_raw": result["dag_graph_raw"],
21+
"dag_graph_merged": result["dag_graph_merged"],
22+
},
23+
ensure_ascii=False,
24+
indent=2,
25+
)
26+
)
27+
28+
29+
if __name__ == "__main__":
30+
main()

dag_construction/pyproject.toml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
[build-system]
2+
requires = ["setuptools>=68", "wheel"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "trm-dag"
7+
version = "0.1.0"
8+
description = "A small, hackable DAG builder for TRM reasoning traces."
9+
readme = "README.md"
10+
requires-python = ">=3.10"
11+
license = {text = "MIT"}
12+
authors = [{name = "Haoran Zhang"}]
13+
dependencies = [
14+
"openai>=1.0",
15+
"python-dotenv>=1.0",
16+
"rich>=13.0",
17+
]
18+
19+
[project.optional-dependencies]
20+
dev = [
21+
"pytest>=8.0",
22+
"ruff>=0.5",
23+
"mypy>=1.10",
24+
]
25+
26+
[project.scripts]
27+
trm-dag = "trm_dag.cli:main"
28+
29+
[tool.setuptools.packages.find]
30+
include = ["trm_dag*"]
31+
exclude = ["scripts*", "tests*"]
32+
33+
[tool.setuptools.package-data]
34+
trm_dag = ["prompts/*.txt"]
35+
36+
[tool.ruff]
37+
line-length = 100
38+
target-version = "py310"
39+
40+
[tool.ruff.lint]
41+
select = ["E", "F", "I", "UP", "B", "C4", "SIM"]
42+
ignore = ["E501"]
43+
44+
[tool.mypy]
45+
python_version = "3.10"
46+
strict = true
47+
warn_unused_ignores = true
48+
warn_return_any = true
49+
disallow_any_generics = false
50+
51+
[[tool.mypy.overrides]]
52+
module = ["openai", "dotenv", "rich.*"]
53+
ignore_missing_imports = true

dag_construction/tests/conftest.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from dataclasses import dataclass, field
5+
from pathlib import Path
6+
7+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
8+
9+
from trm_dag.config import OpenAIConfig # noqa: E402
10+
11+
12+
@dataclass
13+
class MockOpenAIClient:
14+
responses: list[list[str]]
15+
calls: int = 0
16+
prompts: list[str] = field(default_factory=list)
17+
18+
def complete(self, prompt: str, config: OpenAIConfig, *, n: int) -> tuple[list[str], dict[str, int]]:
19+
self.prompts.append(prompt)
20+
idx = self.calls
21+
self.calls += 1
22+
if idx >= len(self.responses):
23+
batch = self.responses[-1] if self.responses else ["ok\n<|action|>continue"]
24+
else:
25+
batch = self.responses[idx]
26+
return batch[:n], {
27+
"prompt_tokens": 1,
28+
"completion_tokens": 1,
29+
"total_tokens": 2,
30+
}
31+
32+
33+
def openai_config() -> OpenAIConfig:
34+
return OpenAIConfig(api_key="test", base_url=None, model="mock", n=1)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from collections import deque
2+
3+
from conftest import MockOpenAIClient, openai_config
4+
5+
from trm_dag import DagParams, build_dag
6+
7+
8+
def assert_valid_dag(dag: dict) -> None:
9+
parents = {int(k): [int(x) for x in v] for k, v in dag["parents"].items()}
10+
children = {int(k): [int(x) for x in v] for k, v in dag["children"].items()}
11+
actions = {int(k): v for k, v in dag["actions"].items()}
12+
assert actions[0] == "root"
13+
assert all(action in {"root", "continue", "backtrack", "merge"} for action in actions.values())
14+
for node, parent_list in parents.items():
15+
if node != 0:
16+
assert len(parent_list) >= 1
17+
18+
seen = {0}
19+
queue = deque([0])
20+
while queue:
21+
node = queue.popleft()
22+
for child in children.get(node, []):
23+
if child not in seen:
24+
seen.add(child)
25+
queue.append(child)
26+
assert seen == set(parents)
27+
28+
visiting: set[int] = set()
29+
done: set[int] = set()
30+
31+
def dfs(node: int) -> None:
32+
assert node not in visiting
33+
if node in done:
34+
return
35+
visiting.add(node)
36+
for child in children.get(node, []):
37+
dfs(child)
38+
visiting.remove(node)
39+
done.add(node)
40+
41+
dfs(0)
42+
43+
44+
def test_mock_pipeline_produces_valid_dag() -> None:
45+
client = MockOpenAIClient(
46+
[
47+
["s1 follows\n<|action|>continue"],
48+
["restart\n<|action|>backtrack\n<|previous|>0"],
49+
["join\n<|action|>merge\n<|previous|>1,2"],
50+
]
51+
)
52+
result = build_dag(
53+
"p",
54+
["root", "a", "b", "c"],
55+
openai_config=openai_config(),
56+
dag_params=DagParams(regen_limit=1, random_seed=0),
57+
client=client,
58+
)
59+
assert_valid_dag(result["dag_graph_raw"])
60+
assert result["dag_graph_raw"]["leaves"] == [3]

0 commit comments

Comments
 (0)