EviScene / scripts /trajectory /build_sft_case_split.py
ZiAnwang's picture
Initial EviScene release
c10636a
Raw History Blame Contribute Delete
10.7 kB
#!/usr/bin/env python3
"""Freeze a leakage-safe train/validation split for reviewed SFT cases."""
from __future__ import annotations
import argparse
import hashlib
import json
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any, Dict, Iterable, Mapping, Sequence
SCHEMA_VERSION = "ifv-sft-case-split-v1"
def _load_jsonl(path: Path) -> list[Dict[str, Any]]:
rows: list[Dict[str, Any]] = []
for line_number, line in enumerate(
path.read_text(encoding="utf-8").split("\n"), start=1
):
if not line.strip():
continue
value = json.loads(line)
if not isinstance(value, dict):
raise ValueError(f"{path}:{line_number} must be a JSON object")
rows.append(value)
return rows
def _index(rows: Sequence[Mapping[str, Any]], *, name: str) -> Dict[str, Mapping[str, Any]]:
result: Dict[str, Mapping[str, Any]] = {}
for row in rows:
case_id = str(row.get("case_id") or "").strip()
if not case_id:
raise ValueError(f"{name} row lacks case_id")
if case_id in result:
raise ValueError(f"duplicate {name} case_id: {case_id}")
result[case_id] = row
return result
def _label(row: Mapping[str, Any]) -> str:
label = str(row.get("label") or row.get("factual_status") or "").strip().lower()
if label in {"supported", "refuted"}:
return label
verdict = str(
row.get("expected_verdict") or row.get("gold_verdict") or ""
).strip().lower()
mapping = {"real": "supported", "fake": "refuted"}
if verdict in mapping:
return mapping[verdict]
raise ValueError("gold labels must be supported/refuted or real/fake")
class _UnionFind:
def __init__(self, values: Iterable[str]) -> None:
self.parent = {value: value for value in values}
def find(self, value: str) -> str:
parent = self.parent[value]
if parent != value:
self.parent[value] = self.find(parent)
return self.parent[value]
def union(self, left: str, right: str) -> None:
left_root = self.find(left)
right_root = self.find(right)
if left_root != right_root:
self.parent[max(left_root, right_root)] = min(left_root, right_root)
def _group_keys(
runtime: Mapping[str, Any],
gold: Mapping[str, Any],
) -> set[str]:
keys = {f"image:{str(runtime.get('image_sha256') or '').casefold()}"}
claim_atom = gold.get("claim_atom")
if isinstance(claim_atom, Mapping):
subject = str(claim_atom.get("subject") or "").strip().casefold()
event = str(claim_atom.get("event") or "").strip().casefold()
if subject and event:
keys.add(f"subject-event:{subject}|{event}")
evidence = gold.get("certifying_evidence")
if isinstance(evidence, list):
for row in evidence:
if not isinstance(row, Mapping):
continue
for field in ("source_id", "snapshot_sha256"):
value = str(row.get(field) or "").strip().casefold()
if value:
keys.add(f"{field}:{value}")
return keys
def build_split(
runtime_rows: Sequence[Mapping[str, Any]],
gold_rows: Sequence[Mapping[str, Any]],
*,
validation_count: int,
validation_supported: int,
seed: str,
prohibited_case_ids: set[str] | None = None,
prohibited_image_hashes: set[str] | None = None,
) -> tuple[list[Dict[str, Any]], Dict[str, Any]]:
runtime = _index(runtime_rows, name="runtime")
gold = _index(gold_rows, name="gold")
if set(runtime) != set(gold):
raise ValueError("runtime and gold case IDs must match exactly")
if validation_count < 1 or validation_count >= len(runtime):
raise ValueError("validation_count must leave non-empty train and validation")
if validation_supported < 0 or validation_supported > validation_count:
raise ValueError("validation_supported is outside validation_count")
blocked_ids = prohibited_case_ids or set()
blocked_hashes = {value.casefold() for value in (prohibited_image_hashes or set())}
overlap_ids = sorted(set(runtime) & blocked_ids)
overlap_hashes = sorted(
{
str(row.get("image_sha256") or "").casefold()
for row in runtime.values()
}
& blocked_hashes
)
if overlap_ids or overlap_hashes:
raise ValueError(
"training candidates overlap prohibited evaluation data: "
f"case_ids={overlap_ids[:5]}, image_hashes={overlap_hashes[:5]}"
)
labels = {case_id: _label(gold[case_id]) for case_id in gold}
union = _UnionFind(runtime)
members_by_key: Dict[str, list[str]] = defaultdict(list)
for case_id in runtime:
for key in _group_keys(runtime[case_id], gold[case_id]):
members_by_key[key].append(case_id)
for members in members_by_key.values():
for member in members[1:]:
union.union(members[0], member)
components: Dict[str, list[str]] = defaultdict(list)
for case_id in runtime:
components[union.find(case_id)].append(case_id)
groups: list[Dict[str, Any]] = []
for members in components.values():
ordered = sorted(members)
group_id = hashlib.sha256(
json.dumps(ordered, separators=(",", ":")).encode("utf-8")
).hexdigest()[:20]
groups.append(
{
"group_id": group_id,
"members": ordered,
"count": len(ordered),
"supported": sum(labels[item] == "supported" for item in ordered),
}
)
groups.sort(
key=lambda group: hashlib.sha256(
f"{seed}:{group['group_id']}".encode("utf-8")
).hexdigest()
)
target = (validation_count, validation_supported)
choices: Dict[tuple[int, int], tuple[int, ...]] = {(0, 0): ()}
for index, group in enumerate(groups):
next_choices = dict(choices)
for (count, supported), selected in choices.items():
state = (
count + int(group["count"]),
supported + int(group["supported"]),
)
if state[0] > target[0] or state[1] > target[1]:
continue
next_choices.setdefault(state, selected + (index,))
choices = next_choices
if target not in choices:
raise ValueError(
"group constraints cannot satisfy requested validation count/labels"
)
validation_groups = {groups[index]["group_id"] for index in choices[target]}
group_by_case = {
case_id: str(group["group_id"])
for group in groups
for case_id in group["members"]
}
rows = [
{
"schema_version": SCHEMA_VERSION,
"case_id": case_id,
"image_sha256": str(runtime[case_id].get("image_sha256") or ""),
"split_group_id": group_by_case[case_id],
"split": (
"validation"
if group_by_case[case_id] in validation_groups
else "train"
),
}
for case_id in sorted(runtime)
]
split_labels = {
split: Counter(labels[row["case_id"]] for row in rows if row["split"] == split)
for split in ("train", "validation")
}
summary = {
"schema_version": SCHEMA_VERSION,
"seed": seed,
"case_count": len(rows),
"group_count": len(groups),
"split_counts": dict(Counter(row["split"] for row in rows)),
"split_label_counts": {
split: dict(sorted(counts.items()))
for split, counts in split_labels.items()
},
"validation_group_ids": sorted(validation_groups),
}
return rows, summary
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--runtime", required=True, type=Path)
parser.add_argument("--gold", required=True, type=Path)
parser.add_argument("--prohibited-runtime", type=Path)
parser.add_argument("--output-dir", required=True, type=Path)
parser.add_argument("--validation-count", type=int, default=8)
parser.add_argument("--validation-supported", type=int, default=2)
parser.add_argument("--seed", default="ifv-qwen35-sft-v1")
return parser.parse_args()
def main() -> int:
args = _args()
output_dir = args.output_dir.expanduser().resolve()
if output_dir.exists() and any(output_dir.iterdir()):
raise FileExistsError(f"output directory must be new or empty: {output_dir}")
output_dir.mkdir(parents=True, exist_ok=True)
runtime_rows = _load_jsonl(args.runtime)
gold_rows = _load_jsonl(args.gold)
prohibited_rows = (
_load_jsonl(args.prohibited_runtime)
if args.prohibited_runtime is not None
else []
)
rows, summary = build_split(
runtime_rows,
gold_rows,
validation_count=args.validation_count,
validation_supported=args.validation_supported,
seed=args.seed,
prohibited_case_ids={str(row.get("case_id") or "") for row in prohibited_rows},
prohibited_image_hashes={
str(row.get("image_sha256") or "") for row in prohibited_rows
},
)
split_path = output_dir / "case_split.jsonl"
split_path.write_text(
"\n".join(json.dumps(row, sort_keys=True) for row in rows) + "\n",
encoding="utf-8",
)
manifest = {
**summary,
"inputs": {
"runtime": {"path": str(args.runtime.resolve()), "sha256": _sha256(args.runtime)},
"gold": {"path": str(args.gold.resolve()), "sha256": _sha256(args.gold)},
"prohibited_runtime": (
{
"path": str(args.prohibited_runtime.resolve()),
"sha256": _sha256(args.prohibited_runtime),
}
if args.prohibited_runtime is not None
else None
),
},
"artifacts": {
"case_split": "case_split.jsonl",
"case_split_sha256": _sha256(split_path),
},
}
(output_dir / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(json.dumps(manifest, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())