anhtld commited on
Commit
f97ea87
·
verified ·
1 Parent(s): 71f2269

ctt artifacts 2026-07-02 workspace/scripts/build_data_accounting.py

Browse files
workspace/scripts/build_data_accounting.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import hashlib
6
+ import json
7
+ import sys
8
+ from collections import Counter, defaultdict
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
13
+ if str(PROJECT_ROOT) not in sys.path:
14
+ sys.path.insert(0, str(PROJECT_ROOT))
15
+
16
+ from dovla_cil.data.datasets import CILDataset # noqa: E402
17
+ from dovla_cil.generation.tangent_targets import ( # noqa: E402
18
+ DEFAULT_BASE_CANDIDATE_TYPES,
19
+ action_matrix,
20
+ action_shape,
21
+ choose_base_record,
22
+ label_from_delta_utility,
23
+ record_utility,
24
+ )
25
+
26
+
27
+ def main(argv: list[str] | None = None) -> int:
28
+ parser = argparse.ArgumentParser(description="Build CIL chart data accounting tables.")
29
+ parser.add_argument("--dataset", type=Path, required=True)
30
+ parser.add_argument("--out-dir", type=Path, default=Path("runs/data_accounting"))
31
+ parser.add_argument("--epsilon", type=float, default=0.05)
32
+ parser.add_argument("--split-fractions", default="0.70,0.15,0.15")
33
+ parser.add_argument("--split-seed", type=int, default=0)
34
+ parser.add_argument(
35
+ "--base-candidate-types",
36
+ default=",".join(DEFAULT_BASE_CANDIDATE_TYPES),
37
+ )
38
+ args = parser.parse_args(argv)
39
+
40
+ fractions = _parse_split_fractions(args.split_fractions)
41
+ base_candidate_types = _parse_csv(args.base_candidate_types)
42
+ dataset = CILDataset(args.dataset)
43
+ rows: dict[str, dict[str, Any]] = {
44
+ split: _empty_row(split) for split in ("train", "val", "test")
45
+ }
46
+ skip_counts: Counter[str] = Counter()
47
+
48
+ for group in dataset.iter_groups():
49
+ if not group:
50
+ skip_counts["empty_group"] += 1
51
+ continue
52
+ split = _assign_split(group[0].group_id, fractions=fractions, seed=args.split_seed)
53
+ row = rows[split]
54
+ base = choose_base_record(group, base_candidate_types=base_candidate_types)
55
+ if base is None:
56
+ skip_counts["missing_base"] += 1
57
+ continue
58
+ base_action = action_matrix(base)
59
+ if not base_action:
60
+ skip_counts["empty_base_action"] += 1
61
+ continue
62
+ base_shape = action_shape(base_action)
63
+ base_utility = record_utility(base)
64
+ row["chart_ids"].add(group[0].group_id)
65
+ row["state_hashes"].add(group[0].state_hash)
66
+ row["task_ids"].add(group[0].task_id)
67
+ seed = group[0].metadata.get("episode_seed", group[0].metadata.get("random_seed"))
68
+ if seed is not None:
69
+ row["seeds"].add(str(seed))
70
+ for record in group:
71
+ action = action_matrix(record)
72
+ if not action or action_shape(action) != base_shape:
73
+ skip_counts["unusable_branch"] += 1
74
+ continue
75
+ row["num_branches"] += 1
76
+ candidate_type = str(record.candidate_type)
77
+ row["candidate_type_counts"][candidate_type] += 1
78
+ if record.record_id == base.record_id:
79
+ row["num_base_branches"] += 1
80
+ if candidate_type == "expert" or candidate_type.endswith("_expert"):
81
+ row["num_expert"] += 1
82
+ if record.record_id == base.record_id:
83
+ label = "neutral"
84
+ else:
85
+ label = label_from_delta_utility(
86
+ record_utility(record) - base_utility,
87
+ epsilon=args.epsilon,
88
+ )
89
+ row[f"num_{label}"] += 1
90
+
91
+ materialized = [_finalize_row(row) for row in rows.values()]
92
+ payload = {
93
+ "schema_version": 1,
94
+ "dataset": str(args.dataset),
95
+ "epsilon": args.epsilon,
96
+ "split_fractions": fractions,
97
+ "split_seed": args.split_seed,
98
+ "split_hash": _split_hash(materialized),
99
+ "skip_counts": dict(sorted(skip_counts.items())),
100
+ "rows": materialized,
101
+ }
102
+ out_dir = args.out_dir
103
+ out_dir.mkdir(parents=True, exist_ok=True)
104
+ (out_dir / "table.json").write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
105
+ (out_dir / "table.tex").write_text(_latex_table(materialized) + "\n")
106
+ (out_dir / "report.md").write_text(_markdown_report(payload) + "\n")
107
+ print(json.dumps({"out_dir": str(out_dir), "split_hash": payload["split_hash"]}, indent=2))
108
+ return 0
109
+
110
+
111
+ def _empty_row(split: str) -> dict[str, Any]:
112
+ return {
113
+ "split": split,
114
+ "task_ids": set(),
115
+ "seeds": set(),
116
+ "chart_ids": set(),
117
+ "state_hashes": set(),
118
+ "num_branches": 0,
119
+ "num_base_branches": 0,
120
+ "num_positive": 0,
121
+ "num_neutral": 0,
122
+ "num_negative": 0,
123
+ "num_expert": 0,
124
+ "candidate_type_counts": Counter(),
125
+ }
126
+
127
+
128
+ def _finalize_row(row: dict[str, Any]) -> dict[str, Any]:
129
+ split = row["split"]
130
+ num_branches = int(row["num_branches"])
131
+ return {
132
+ "split": split,
133
+ "num_tasks": len(row["task_ids"]),
134
+ "num_seeds": len(row["seeds"]),
135
+ "num_charts": len(row["chart_ids"]),
136
+ "num_branches": num_branches,
137
+ "num_base_branches": int(row["num_base_branches"]),
138
+ "num_positive": int(row["num_positive"]),
139
+ "num_neutral": int(row["num_neutral"]),
140
+ "num_negative": int(row["num_negative"]),
141
+ "num_expert": int(row["num_expert"]),
142
+ "num_train_only_retrieval_rows": num_branches if split == "train" else 0,
143
+ "num_eval_only_rows": num_branches if split != "train" else 0,
144
+ "candidate_type_counts": dict(sorted(row["candidate_type_counts"].items())),
145
+ "chart_hashes": sorted(_hash_id(value) for value in row["chart_ids"]),
146
+ "state_hashes": sorted(_hash_id(value) for value in row["state_hashes"]),
147
+ }
148
+
149
+
150
+ def _latex_table(rows: list[dict[str, Any]]) -> str:
151
+ lines = [
152
+ "% Auto-generated by scripts/build_data_accounting.py",
153
+ "\\begin{tabular}{lrrrrrrrrrrr}",
154
+ "\\toprule",
155
+ "Split & Tasks & Seeds & Charts & Branches & Base & Positive & Neutral & Negative & Expert & TrainRows & EvalRows \\\\",
156
+ "\\midrule",
157
+ ]
158
+ for row in rows:
159
+ lines.append(
160
+ f"{row['split']} & {row['num_tasks']} & {row['num_seeds']} & "
161
+ f"{row['num_charts']} & {row['num_branches']} & {row['num_base_branches']} & "
162
+ f"{row['num_positive']} & {row['num_neutral']} & {row['num_negative']} & "
163
+ f"{row['num_expert']} & {row['num_train_only_retrieval_rows']} & "
164
+ f"{row['num_eval_only_rows']} \\\\"
165
+ )
166
+ lines.extend(["\\bottomrule", "\\end{tabular}"])
167
+ return "\n".join(lines)
168
+
169
+
170
+ def _markdown_report(payload: dict[str, Any]) -> str:
171
+ lines = [
172
+ "# CIL Chart Data Accounting",
173
+ "",
174
+ f"Dataset: `{payload['dataset']}`",
175
+ f"Split seed: `{payload['split_seed']}`",
176
+ f"Split hash: `{payload['split_hash']}`",
177
+ "",
178
+ "| Split | Tasks | Seeds | Charts | Branches | Base | Positive | Neutral | Negative | Expert | Train rows | Eval rows |",
179
+ "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
180
+ ]
181
+ for row in payload["rows"]:
182
+ lines.append(
183
+ f"| {row['split']} | {row['num_tasks']} | {row['num_seeds']} | "
184
+ f"{row['num_charts']} | {row['num_branches']} | {row['num_base_branches']} | "
185
+ f"{row['num_positive']} | {row['num_neutral']} | {row['num_negative']} | "
186
+ f"{row['num_expert']} | {row['num_train_only_retrieval_rows']} | "
187
+ f"{row['num_eval_only_rows']} |"
188
+ )
189
+ if payload["skip_counts"]:
190
+ lines.extend(["", "Skip counts:", ""])
191
+ lines.extend(f"- `{key}`: {value}" for key, value in payload["skip_counts"].items())
192
+ return "\n".join(lines)
193
+
194
+
195
+ def _parse_csv(value: str) -> tuple[str, ...]:
196
+ return tuple(item.strip() for item in value.split(",") if item.strip())
197
+
198
+
199
+ def _parse_split_fractions(value: str) -> dict[str, float]:
200
+ parts = [float(item.strip()) for item in value.split(",") if item.strip()]
201
+ if len(parts) != 3:
202
+ raise ValueError("--split-fractions must contain train,val,test values")
203
+ total = sum(parts)
204
+ if total <= 0.0 or any(part < 0.0 for part in parts):
205
+ raise ValueError("split fractions must be non-negative with positive sum")
206
+ train, val, test = [part / total for part in parts]
207
+ return {"train": train, "val": val, "test": test}
208
+
209
+
210
+ def _assign_split(group_id: str, *, fractions: dict[str, float], seed: int) -> str:
211
+ value = _stable_uniform(group_id, seed=seed)
212
+ if value < fractions["train"]:
213
+ return "train"
214
+ if value < fractions["train"] + fractions["val"]:
215
+ return "val"
216
+ return "test"
217
+
218
+
219
+ def _stable_uniform(value: str, *, seed: int) -> float:
220
+ digest = hashlib.sha256(f"{seed}:{value}".encode("utf-8")).digest()
221
+ return int.from_bytes(digest[:8], "big") / float(2**64)
222
+
223
+
224
+ def _hash_id(value: str) -> str:
225
+ return hashlib.sha256(str(value).encode()).hexdigest()
226
+
227
+
228
+ def _split_hash(rows: list[dict[str, Any]]) -> str:
229
+ digest_rows = [
230
+ {
231
+ "split": row["split"],
232
+ "chart_hashes": row["chart_hashes"],
233
+ "state_hashes": row["state_hashes"],
234
+ }
235
+ for row in rows
236
+ ]
237
+ return hashlib.sha256(json.dumps(digest_rows, sort_keys=True).encode()).hexdigest()
238
+
239
+
240
+ if __name__ == "__main__":
241
+ raise SystemExit(main())