sync chart feature audit script 2026-07-03
Browse files
workspace/scripts/audit_chart_feature_sources.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import argparse
|
| 5 |
+
import json
|
| 6 |
+
import math
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
from collections import Counter, defaultdict
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 14 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 15 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 16 |
+
|
| 17 |
+
import numpy as np # noqa: E402
|
| 18 |
+
|
| 19 |
+
from cil.chart_features import CHART_FEATURE_MODES, chart_feature_dim # noqa: E402
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def main(argv: list[str] | None = None) -> int:
|
| 23 |
+
parser = argparse.ArgumentParser(
|
| 24 |
+
description="Audit deployment-visible chart feature sources in CIL chart shards."
|
| 25 |
+
)
|
| 26 |
+
parser.add_argument(
|
| 27 |
+
"--indexes",
|
| 28 |
+
nargs="+",
|
| 29 |
+
type=Path,
|
| 30 |
+
default=[
|
| 31 |
+
Path("data/cil_charts/train/index.json"),
|
| 32 |
+
Path("data/cil_charts/val/index.json"),
|
| 33 |
+
Path("data/cil_charts/test/index.json"),
|
| 34 |
+
],
|
| 35 |
+
)
|
| 36 |
+
parser.add_argument("--out-dir", type=Path, default=Path("runs/chart_feature_audit"))
|
| 37 |
+
args = parser.parse_args(argv)
|
| 38 |
+
|
| 39 |
+
out_dir = args.out_dir
|
| 40 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 41 |
+
_write_provenance(out_dir, args)
|
| 42 |
+
|
| 43 |
+
split_rows = []
|
| 44 |
+
row_details = []
|
| 45 |
+
for index_path in args.indexes:
|
| 46 |
+
index = json.loads(index_path.read_text())
|
| 47 |
+
split = str(index.get("split", index_path.parent.name))
|
| 48 |
+
counts: Counter[str] = Counter()
|
| 49 |
+
task_counts: Counter[str] = Counter()
|
| 50 |
+
feature_dims: dict[str, int] = {}
|
| 51 |
+
for shard in index.get("shards", []):
|
| 52 |
+
shard_path = index_path.parent / shard["path"]
|
| 53 |
+
with np.load(shard_path, allow_pickle=False) as data:
|
| 54 |
+
metadata_values = data["metadata_json"]
|
| 55 |
+
base_actions = data["base_action"]
|
| 56 |
+
action_shapes = data["action_shape"]
|
| 57 |
+
task_ids = data["task_id"]
|
| 58 |
+
chart_ids = data["chart_id"]
|
| 59 |
+
for row in range(metadata_values.shape[0]):
|
| 60 |
+
metadata = _json_loads(str(metadata_values[row]))
|
| 61 |
+
task_id = str(task_ids[row])
|
| 62 |
+
task_counts[task_id] += 1
|
| 63 |
+
counts["rows"] += 1
|
| 64 |
+
for key in (
|
| 65 |
+
"observation_embedding_path",
|
| 66 |
+
"observation_ref",
|
| 67 |
+
"scene_id",
|
| 68 |
+
"instruction",
|
| 69 |
+
"source_dataset",
|
| 70 |
+
):
|
| 71 |
+
if metadata.get(key):
|
| 72 |
+
counts[f"{key}_present"] += 1
|
| 73 |
+
if counts["sample_details"] < 5:
|
| 74 |
+
shape = tuple(int(value) for value in action_shapes[row])
|
| 75 |
+
flat_count = int(math.prod(shape))
|
| 76 |
+
base = np.asarray(base_actions[row][:flat_count], dtype=np.float32).reshape(shape)
|
| 77 |
+
for mode in CHART_FEATURE_MODES:
|
| 78 |
+
feature_dims[mode] = chart_feature_dim(base, mode=mode)
|
| 79 |
+
row_details.append(
|
| 80 |
+
{
|
| 81 |
+
"split": split,
|
| 82 |
+
"chart_id": str(chart_ids[row]),
|
| 83 |
+
"task_id": task_id,
|
| 84 |
+
"has_observation_embedding_path": bool(
|
| 85 |
+
metadata.get("observation_embedding_path")
|
| 86 |
+
),
|
| 87 |
+
"has_observation_ref": bool(metadata.get("observation_ref")),
|
| 88 |
+
"has_scene_id": bool(metadata.get("scene_id")),
|
| 89 |
+
"has_instruction": bool(metadata.get("instruction")),
|
| 90 |
+
}
|
| 91 |
+
)
|
| 92 |
+
counts["sample_details"] += 1
|
| 93 |
+
rows = int(counts["rows"])
|
| 94 |
+
split_rows.append(
|
| 95 |
+
{
|
| 96 |
+
"split": split,
|
| 97 |
+
"index": str(index_path),
|
| 98 |
+
"rows": rows,
|
| 99 |
+
"charts": int(index.get("num_groups_exported", 0)),
|
| 100 |
+
"retrieval_index_allowed": bool(index.get("retrieval_index_allowed")),
|
| 101 |
+
"include_outcomes": bool(index.get("include_outcomes")),
|
| 102 |
+
"observation_embedding_path_present": int(
|
| 103 |
+
counts["observation_embedding_path_present"]
|
| 104 |
+
),
|
| 105 |
+
"observation_embedding_path_rate": _rate(
|
| 106 |
+
counts["observation_embedding_path_present"], rows
|
| 107 |
+
),
|
| 108 |
+
"observation_ref_present": int(counts["observation_ref_present"]),
|
| 109 |
+
"observation_ref_rate": _rate(counts["observation_ref_present"], rows),
|
| 110 |
+
"scene_id_present": int(counts["scene_id_present"]),
|
| 111 |
+
"scene_id_rate": _rate(counts["scene_id_present"], rows),
|
| 112 |
+
"instruction_present": int(counts["instruction_present"]),
|
| 113 |
+
"instruction_rate": _rate(counts["instruction_present"], rows),
|
| 114 |
+
"source_dataset_present": int(counts["source_dataset_present"]),
|
| 115 |
+
"source_dataset_rate": _rate(counts["source_dataset_present"], rows),
|
| 116 |
+
"feature_dims": feature_dims,
|
| 117 |
+
"task_counts": dict(sorted(task_counts.items())),
|
| 118 |
+
}
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
metrics = {
|
| 122 |
+
"report_type": "chart_feature_source_audit",
|
| 123 |
+
"schema_version": 1,
|
| 124 |
+
"indexes": [str(path) for path in args.indexes],
|
| 125 |
+
"splits": split_rows,
|
| 126 |
+
"sample_details": row_details,
|
| 127 |
+
"conclusion": _conclusion(split_rows),
|
| 128 |
+
}
|
| 129 |
+
(out_dir / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n")
|
| 130 |
+
(out_dir / "metrics_by_task.json").write_text(_metrics_by_task(split_rows) + "\n")
|
| 131 |
+
(out_dir / "metrics_by_seed.json").write_text("{}\n")
|
| 132 |
+
(out_dir / "table.tex").write_text(_table(split_rows) + "\n")
|
| 133 |
+
(out_dir / "report.md").write_text(_report(metrics) + "\n")
|
| 134 |
+
(out_dir / "train.log").write_text("not a training run; audited chart feature sources\n")
|
| 135 |
+
(out_dir / "eval.log").write_text("audited chart feature sources in exported chart indexes\n")
|
| 136 |
+
print(json.dumps({"out_dir": str(out_dir), "splits": len(split_rows)}, indent=2))
|
| 137 |
+
return 0
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _json_loads(value: str) -> dict[str, Any]:
|
| 141 |
+
try:
|
| 142 |
+
payload = json.loads(value)
|
| 143 |
+
except json.JSONDecodeError:
|
| 144 |
+
return {}
|
| 145 |
+
return payload if isinstance(payload, dict) else {}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _rate(count: int, total: int) -> float:
|
| 149 |
+
return float(count) / float(total) if total else 0.0
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _conclusion(rows: list[dict[str, Any]]) -> str:
|
| 153 |
+
if all(float(row["observation_embedding_path_rate"]) == 0.0 for row in rows):
|
| 154 |
+
return (
|
| 155 |
+
"Current chart exports do not contain observation embeddings or raw observation refs; "
|
| 156 |
+
"visual/object-centric chart tokens require a new export or embedding pass."
|
| 157 |
+
)
|
| 158 |
+
return "At least one split exposes observation references; visual chart features can be wired next."
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _metrics_by_task(rows: list[dict[str, Any]]) -> str:
|
| 162 |
+
payload: dict[str, dict[str, int]] = defaultdict(dict)
|
| 163 |
+
for row in rows:
|
| 164 |
+
for task, count in row["task_counts"].items():
|
| 165 |
+
payload[task][str(row["split"])] = int(count)
|
| 166 |
+
return json.dumps(payload, indent=2, sort_keys=True)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _table(rows: list[dict[str, Any]]) -> str:
|
| 170 |
+
lines = [
|
| 171 |
+
"% Auto-generated by scripts/audit_chart_feature_sources.py",
|
| 172 |
+
"\\begin{tabular}{lrrrr}",
|
| 173 |
+
"\\toprule",
|
| 174 |
+
"Split & Rows & ObsEmbed & ObsRef & Instruction \\\\",
|
| 175 |
+
"\\midrule",
|
| 176 |
+
]
|
| 177 |
+
for row in rows:
|
| 178 |
+
lines.append(
|
| 179 |
+
f"{row['split']} & {row['rows']} & "
|
| 180 |
+
f"{row['observation_embedding_path_present']} & "
|
| 181 |
+
f"{row['observation_ref_present']} & "
|
| 182 |
+
f"{row['instruction_present']} \\\\"
|
| 183 |
+
)
|
| 184 |
+
lines.extend(["\\bottomrule", "\\end{tabular}"])
|
| 185 |
+
return "\n".join(lines)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _report(metrics: dict[str, Any]) -> str:
|
| 189 |
+
lines = [
|
| 190 |
+
"# Chart Feature Source Audit",
|
| 191 |
+
"",
|
| 192 |
+
metrics["conclusion"],
|
| 193 |
+
"",
|
| 194 |
+
"| Split | Rows | Obs embedding path | Obs ref | Scene id | Instruction | Feature dims |",
|
| 195 |
+
"| --- | ---: | ---: | ---: | ---: | ---: | --- |",
|
| 196 |
+
]
|
| 197 |
+
for row in metrics["splits"]:
|
| 198 |
+
lines.append(
|
| 199 |
+
f"| {row['split']} | {row['rows']} | "
|
| 200 |
+
f"{row['observation_embedding_path_present']} ({row['observation_embedding_path_rate']:.2%}) | "
|
| 201 |
+
f"{row['observation_ref_present']} ({row['observation_ref_rate']:.2%}) | "
|
| 202 |
+
f"{row['scene_id_present']} ({row['scene_id_rate']:.2%}) | "
|
| 203 |
+
f"{row['instruction_present']} ({row['instruction_rate']:.2%}) | "
|
| 204 |
+
f"{json.dumps(row['feature_dims'], sort_keys=True)} |"
|
| 205 |
+
)
|
| 206 |
+
return "\n".join(lines)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _write_provenance(out_dir: Path, args: argparse.Namespace) -> None:
|
| 210 |
+
(out_dir / "config.yaml").write_text(
|
| 211 |
+
"\n".join(f"{key}: {value}" for key, value in sorted(vars(args).items())) + "\n"
|
| 212 |
+
)
|
| 213 |
+
(out_dir / "command.txt").write_text(
|
| 214 |
+
"python scripts/audit_chart_feature_sources.py " + " ".join(sys.argv[1:]) + "\n"
|
| 215 |
+
)
|
| 216 |
+
(out_dir / "git_hash.txt").write_text(_run(["git", "rev-parse", "HEAD"]) + "\n")
|
| 217 |
+
hashes = {}
|
| 218 |
+
for index_path in args.indexes:
|
| 219 |
+
index = json.loads(index_path.read_text())
|
| 220 |
+
hashes[str(index_path)] = {
|
| 221 |
+
"content_hash": index.get("content_hash"),
|
| 222 |
+
"split_hash": index.get("split_hash"),
|
| 223 |
+
}
|
| 224 |
+
(out_dir / "data_hash.txt").write_text(json.dumps(hashes, indent=2, sort_keys=True) + "\n")
|
| 225 |
+
(out_dir / "split_hash.txt").write_text(json.dumps(hashes, indent=2, sort_keys=True) + "\n")
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _run(command: list[str]) -> str:
|
| 229 |
+
try:
|
| 230 |
+
return subprocess.check_output(command, cwd=PROJECT_ROOT, text=True).strip()
|
| 231 |
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
| 232 |
+
return ""
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
if __name__ == "__main__":
|
| 236 |
+
raise SystemExit(main())
|