| |
| """Build the Stage 1 SFT mix from downloaded raw splits + the manifest. |
| |
| Pipeline per source key in ``training/configs/datasets.yaml``: |
| |
| raw.jsonl --adapter--> Example(s) --think routing--> ready / to_synthesize |
| --dedup, source caps, data card |
| |
| Outputs (default mix name ``stage1``): |
| data/processed/stage1.ready.normalized.jsonl rows with a <think> block, ready to split |
| data/think_synthesis/stage1.to_synthesize.jsonl rows needing rejection-sampled reasoning |
| reports/data/stage1_data_card.md provenance + counts + license/cap flags |
| |
| Typical flow: |
| 1. python training/scripts/hf_download.py --all --profile pilot |
| 2. python training/scripts/build_sft_dataset.py --profile pilot |
| 3. python training/scripts/synthesize_think.py --input data/think_synthesis/stage1.to_synthesize.jsonl ... |
| 4. python training/scripts/build_sft_dataset.py --include-synthesized data/think_synthesis/stage1.synthesized.jsonl |
| 5. python training/scripts/split_jsonl.py --input data/processed/stage1.ready.normalized.jsonl ... |
| |
| This script is stdlib-only (plus PyYAML) so it runs on any host. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import hashlib |
| import json |
| import random |
| import re |
| import sys |
| from pathlib import Path |
| from typing import Any, Iterable |
|
|
| import yaml |
|
|
| sys.path.insert(0, str(Path(__file__).resolve().parent)) |
| from sft_adapters import Example, apply_adapter, has_think |
|
|
| WS_RE = re.compile(r"\s+") |
|
|
| WRAP_PLACEHOLDER = ( |
| "<think>\n" |
| "Placeholder reasoning inserted for pipeline smoke testing only; replace " |
| "with a synthesized trace before the real Stage 1 run.\n" |
| "</think>\n\n" |
| ) |
|
|
|
|
| def read_yaml(path: str | Path) -> dict[str, Any]: |
| with Path(path).open("r", encoding="utf-8") as fh: |
| payload = yaml.safe_load(fh) or {} |
| if not isinstance(payload, dict): |
| raise TypeError(f"Expected a YAML mapping in {path}") |
| return payload |
|
|
|
|
| def read_jsonl(path: Path) -> Iterable[dict[str, Any]]: |
| with path.open("r", encoding="utf-8") as fh: |
| for line_no, line in enumerate(fh, start=1): |
| line = line.strip() |
| if not line: |
| continue |
| try: |
| row = json.loads(line) |
| except json.JSONDecodeError as exc: |
| raise ValueError(f"Invalid JSON in {path}:{line_no}: {exc}") from exc |
| if isinstance(row, dict): |
| yield row |
|
|
|
|
| def estimate_tokens(messages: list[dict[str, str]]) -> int: |
| chars = sum(len(m.get("content", "")) for m in messages) |
| return max(1, chars // 4) |
|
|
|
|
| def dedup_key(messages: list[dict[str, str]]) -> str: |
| text = " ".join( |
| m.get("content", "") for m in messages if m.get("role") in {"user", "assistant"} |
| ) |
| norm = WS_RE.sub(" ", text).strip().lower() |
| return hashlib.sha256(norm.encode("utf-8")).hexdigest() |
|
|
|
|
| def assistant_turn_count(messages: list[dict[str, str]]) -> int: |
| return sum(1 for m in messages if m.get("role") == "assistant") |
|
|
|
|
| def split_prompt_answer(messages: list[dict[str, str]]) -> tuple[list[dict[str, str]], str]: |
| """Return (messages_without_final_assistant, final_assistant_content).""" |
| for i in range(len(messages) - 1, -1, -1): |
| if messages[i].get("role") == "assistant": |
| return messages[:i], messages[i].get("content", "") |
| return messages, "" |
|
|
|
|
| def wrap_messages(messages: list[dict[str, str]]) -> list[dict[str, str]]: |
| out = [] |
| for m in messages: |
| if m.get("role") == "assistant" and not has_think(m.get("content", "")): |
| out.append({"role": "assistant", "content": WRAP_PLACEHOLDER + m.get("content", "")}) |
| else: |
| out.append(m) |
| return out |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| parser.add_argument("--manifest", default="training/configs/datasets.yaml") |
| parser.add_argument("--mix-name", default="stage1") |
| parser.add_argument("--profile", choices=["full", "pilot"], default="full") |
| parser.add_argument("--raw-dir", default=None, help="Override manifest defaults.raw_dir.") |
| parser.add_argument("--processed-dir", default="data/processed") |
| parser.add_argument("--synth-dir", default="data/think_synthesis") |
| parser.add_argument("--report", default=None, help="Data card path (default reports/data/<mix>_data_card.md).") |
| parser.add_argument( |
| "--missing-think-policy", |
| choices=["synthesize", "wrap", "drop"], |
| default="synthesize", |
| ) |
| parser.add_argument("--only", default=None, help="Comma-separated subset of source keys.") |
| parser.add_argument("--include-disabled", action="store_true") |
| parser.add_argument("--grounded-think", action="store_true", |
| help="Compose label-consistent <think> from metadata (no teacher pass needed).") |
| parser.add_argument("--balance-detection", action="store_true", |
| help="Downsample majority-class vuln-detection rows to 1:1 so training doesn't collapse to majority.") |
| parser.add_argument("--enforce-caps", action="store_true", help="Deterministically downsample over-cap sources.") |
| parser.add_argument( |
| "--include-synthesized", |
| default=None, |
| help="Fold an already-synthesized JSONL (from synthesize_think.py) into the ready mix.", |
| ) |
| return parser.parse_args() |
|
|
|
|
| def cap_for(source: dict[str, Any], defaults: dict[str, Any], profile: str) -> int | None: |
| if profile == "pilot": |
| return source.get("pilot_sample_cap", defaults.get("pilot_sample_cap")) |
| return source.get("sample_cap", defaults.get("sample_cap")) |
|
|
|
|
| def select_keys(sources: dict[str, Any], args: argparse.Namespace) -> list[str]: |
| if args.only: |
| wanted = [k.strip() for k in args.only.split(",") if k.strip()] |
| for k in wanted: |
| if k not in sources: |
| raise SystemExit(f"Unknown source key in --only: {k}") |
| return wanted |
| return [k for k, s in sources.items() if s.get("enabled", False) or args.include_disabled] |
|
|
|
|
| def build() -> int: |
| args = parse_args() |
| manifest = read_yaml(args.manifest) |
| defaults = manifest.get("defaults", {}) |
| raw_dir = Path(args.raw_dir or defaults.get("raw_dir", "data/download")) |
| sources = manifest.get("sources", {}) |
|
|
| processed_dir = Path(args.processed_dir) |
| synth_dir = Path(args.synth_dir) |
| processed_dir.mkdir(parents=True, exist_ok=True) |
| synth_dir.mkdir(parents=True, exist_ok=True) |
|
|
| ready_path = processed_dir / f"{args.mix_name}.ready.normalized.jsonl" |
| synth_path = synth_dir / f"{args.mix_name}.to_synthesize.jsonl" |
| report_path = Path(args.report or f"reports/data/{args.mix_name}_data_card.md") |
| report_path.parent.mkdir(parents=True, exist_ok=True) |
|
|
| keys = select_keys(sources, args) |
|
|
| seen: set[str] = set() |
| per_source: dict[str, dict[str, Any]] = {} |
| ready_rows: list[dict[str, Any]] = [] |
| synth_rows: list[dict[str, Any]] = [] |
| skipped: list[str] = [] |
|
|
| for key in keys: |
| source = sources[key] |
| raw_path = raw_dir / key / "raw.jsonl" |
| stats = per_source.setdefault( |
| key, |
| { |
| "hf_id": source.get("hf_id"), |
| "group": source.get("group"), |
| "license": source.get("license"), |
| "auth": source.get("auth"), |
| "adapter": source.get("adapter"), |
| "raw_rows": 0, |
| "examples": 0, |
| "ready": 0, |
| "to_synthesize": 0, |
| "wrapped": 0, |
| "dropped_no_think": 0, |
| "dropped_dup": 0, |
| "tokens": 0, |
| }, |
| ) |
| if not raw_path.is_file(): |
| skipped.append(f"{key}: missing {raw_path} (run hf_download.py --key {key})") |
| continue |
|
|
| cap = cap_for(source, defaults, args.profile) |
| adapter = source["adapter"] |
| params = dict(source.get("params", {}) or {}) |
| if args.grounded_think: |
| params["grounded_think"] = True |
| kept_from_source = 0 |
|
|
| for row in read_jsonl(raw_path): |
| stats["raw_rows"] += 1 |
| if cap is not None and kept_from_source >= cap: |
| break |
| try: |
| examples = apply_adapter(adapter, row, params) |
| except Exception as exc: |
| skipped.append(f"{key}: adapter error on row {stats['raw_rows']}: {exc!r}") |
| continue |
| for ex in examples: |
| stats["examples"] += 1 |
| key_hash = dedup_key(ex.messages) |
| if key_hash in seen: |
| stats["dropped_dup"] += 1 |
| continue |
| seen.add(key_hash) |
|
|
| row_id = f"{source['hf_id']}:{key_hash[:16]}" |
| tokens = estimate_tokens(ex.messages) |
|
|
| if ex.think_status == "present" or all( |
| has_think(m["content"]) for m in ex.messages if m["role"] == "assistant" |
| ): |
| ready_rows.append(_wrap_record(row_id, source, ex, ex.messages)) |
| stats["ready"] += 1 |
| stats["tokens"] += tokens |
| kept_from_source += 1 |
| continue |
|
|
| |
| if args.missing_think_policy == "drop": |
| stats["dropped_no_think"] += 1 |
| continue |
| if args.missing_think_policy == "wrap": |
| ready_rows.append(_wrap_record(row_id, source, ex, wrap_messages(ex.messages))) |
| stats["wrapped"] += 1 |
| stats["ready"] += 1 |
| stats["tokens"] += tokens |
| kept_from_source += 1 |
| continue |
|
|
| |
| if assistant_turn_count(ex.messages) != 1: |
| stats["dropped_no_think"] += 1 |
| continue |
| prompt_messages, answer = split_prompt_answer(ex.messages) |
| synth_rows.append( |
| { |
| "id": row_id, |
| "source": source["hf_id"], |
| "license": source.get("license", "missing"), |
| "group": source.get("group"), |
| "prompt_messages": prompt_messages, |
| "reference_answer": answer, |
| "verify": ex.verify or {"mode": "backfill", "answer": answer}, |
| "metadata": ex.metadata, |
| } |
| ) |
| stats["to_synthesize"] += 1 |
| kept_from_source += 1 |
|
|
| |
| synthesized_added = 0 |
| if args.include_synthesized: |
| synth_in = Path(args.include_synthesized) |
| if not synth_in.is_file(): |
| skipped.append(f"--include-synthesized: missing {synth_in}") |
| else: |
| for row in read_jsonl(synth_in): |
| messages = row.get("messages") |
| if not isinstance(messages, list): |
| continue |
| if not any(m.get("role") == "assistant" and has_think(m.get("content", "")) for m in messages): |
| continue |
| key_hash = dedup_key(messages) |
| if key_hash in seen: |
| continue |
| seen.add(key_hash) |
| ready_rows.append(row) |
| synthesized_added += 1 |
|
|
| bal_log: list[str] = [] |
| if args.balance_detection: |
| ready_rows, bal = _balance_detection(ready_rows) |
| bal_log.append( |
| f"detection balanced: vulnerable={bal['det_pos']} not_vulnerable={bal['det_neg']} " |
| f"-> kept {bal['kept_each']} each; {bal['other']} non-detection rows untouched" |
| ) |
|
|
| if args.enforce_caps: |
| ready_rows, cap_log = _enforce_source_caps(ready_rows, manifest, per_source) |
| else: |
| cap_log = [] |
| cap_log = bal_log + cap_log |
|
|
| _write_jsonl(ready_path, ready_rows) |
| _write_jsonl(synth_path, synth_rows) |
| _write_data_card( |
| report_path, args, manifest, per_source, ready_rows, synth_rows, skipped, cap_log, synthesized_added |
| ) |
|
|
| summary = { |
| "mix": args.mix_name, |
| "profile": args.profile, |
| "ready_rows": len(ready_rows), |
| "to_synthesize_rows": len(synth_rows), |
| "synthesized_added": synthesized_added, |
| "ready_path": str(ready_path), |
| "synth_path": str(synth_path), |
| "data_card": str(report_path), |
| "skipped": len(skipped), |
| } |
| print(json.dumps(summary, indent=2)) |
| return 0 |
|
|
|
|
| def _wrap_record(row_id: str, source: dict[str, Any], ex: Example, messages: list[dict[str, str]]) -> dict[str, Any]: |
| return { |
| "id": row_id, |
| "source": source["hf_id"], |
| "license": source.get("license", "missing"), |
| "group": source.get("group"), |
| "messages": messages, |
| "metadata": {**ex.metadata, "think_status": ex.think_status}, |
| } |
|
|
|
|
| def _write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None: |
| path.parent.mkdir(parents=True, exist_ok=True) |
| with path.open("w", encoding="utf-8") as out: |
| for row in rows: |
| out.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n") |
|
|
|
|
| def _enforce_source_caps( |
| ready_rows: list[dict[str, Any]], |
| manifest: dict[str, Any], |
| per_source: dict[str, Any], |
| ) -> tuple[list[dict[str, Any]], list[str]]: |
| """Deterministically downsample any source above the token-fraction cap.""" |
| data_mix = manifest.get("source_caps", {}) |
| max_frac = float(data_mix.get("max_single_source_token_fraction", 0.40)) |
| by_source: dict[str, list[dict[str, Any]]] = {} |
| for row in ready_rows: |
| by_source.setdefault(row.get("source", "?"), []).append(row) |
|
|
| total_tokens = sum(estimate_tokens(r["messages"]) for r in ready_rows) |
| cap_tokens = int(max_frac * total_tokens) if total_tokens else 0 |
| log: list[str] = [] |
| kept: list[dict[str, Any]] = [] |
| for src, rows in by_source.items(): |
| rows_sorted = sorted(rows, key=lambda r: r.get("id", "")) |
| running = 0 |
| src_kept = [] |
| for r in rows_sorted: |
| t = estimate_tokens(r["messages"]) |
| |
| |
| if cap_tokens and src_kept and running + t > cap_tokens: |
| continue |
| running += t |
| src_kept.append(r) |
| if len(src_kept) < len(rows): |
| log.append(f"{src}: capped {len(rows)} -> {len(src_kept)} rows (~{max_frac:.0%} token cap)") |
| kept.extend(src_kept) |
| return kept, log |
|
|
|
|
| def _balance_detection(rows: list[dict[str, Any]], seed: int = 1337) -> tuple[list[dict[str, Any]], dict[str, int]]: |
| """1:1 downsample vuln-detection rows by label; leave all other rows untouched.""" |
| det_pos, det_neg, other = [], [], [] |
| for r in rows: |
| m = r.get("metadata", {}) or {} |
| if m.get("task") == "vuln_detection" and m.get("label") in ("vulnerable", "not_vulnerable"): |
| (det_pos if m["label"] == "vulnerable" else det_neg).append(r) |
| else: |
| other.append(r) |
| rng = random.Random(seed) |
| rng.shuffle(det_pos) |
| rng.shuffle(det_neg) |
| n = min(len(det_pos), len(det_neg)) |
| combined = other + det_pos[:n] + det_neg[:n] |
| rng.shuffle(combined) |
| return combined, {"det_pos": len(det_pos), "det_neg": len(det_neg), "kept_each": n, "other": len(other)} |
|
|
|
|
| def _token_fractions(ready_rows: list[dict[str, Any]]) -> dict[str, float]: |
| by_source: dict[str, int] = {} |
| for row in ready_rows: |
| by_source[row.get("source", "?")] = by_source.get(row.get("source", "?"), 0) + estimate_tokens( |
| row["messages"] |
| ) |
| total = sum(by_source.values()) or 1 |
| return {k: v / total for k, v in sorted(by_source.items(), key=lambda kv: -kv[1])} |
|
|
|
|
| def _write_data_card( |
| path: Path, |
| args: argparse.Namespace, |
| manifest: dict[str, Any], |
| per_source: dict[str, Any], |
| ready_rows: list[dict[str, Any]], |
| synth_rows: list[dict[str, Any]], |
| skipped: list[str], |
| cap_log: list[str], |
| synthesized_added: int, |
| ) -> None: |
| fractions = _token_fractions(ready_rows) |
| lines: list[str] = [] |
| lines.append(f"# Data Card — {args.mix_name} ({args.profile})") |
| lines.append("") |
| lines.append(f"- Manifest: `{args.manifest}`") |
| lines.append(f"- Missing-think policy: `{args.missing_think_policy}`") |
| lines.append(f"- Ready rows (have `<think>`): **{len(ready_rows)}**") |
| lines.append(f"- Rows queued for reasoning synthesis: **{len(synth_rows)}**") |
| lines.append(f"- Synthesized rows folded in this build: **{synthesized_added}**") |
| lines.append("") |
| lines.append("## Per-source") |
| lines.append("") |
| lines.append("| key | hf_id | adapter | license | auth | raw | examples | ready | to_synth | dup | tokens≈ |") |
| lines.append("|---|---|---|---|---|---|---|---|---|---|---|") |
| for key, s in per_source.items(): |
| lines.append( |
| f"| {key} | {s['hf_id']} | {s['adapter']} | {s['license']} | {s.get('auth')} | " |
| f"{s['raw_rows']} | {s['examples']} | {s['ready']} | {s['to_synthesize']} | " |
| f"{s['dropped_dup']} | {s['tokens']} |" |
| ) |
| lines.append("") |
| lines.append("## Ready-mix token fraction by source") |
| lines.append("") |
| cap = manifest.get("source_caps", {}).get("max_single_source_token_fraction", 0.40) |
| for src, frac in fractions.items(): |
| flag = " ⚠️ over cap" if frac > float(cap) else "" |
| lines.append(f"- {src}: {frac:.1%}{flag}") |
| lines.append("") |
| lines.append(f"Single-source token cap: {float(cap):.0%}") |
| if cap_log: |
| lines.append("") |
| lines.append("## Cap enforcement") |
| for entry in cap_log: |
| lines.append(f"- {entry}") |
| |
| missing_lic = sorted({s["hf_id"] for s in per_source.values() if str(s["license"]).lower() == "missing"}) |
| if missing_lic: |
| lines.append("") |
| lines.append("## ⚠️ Sources with no stated license (record provenance / academic-use only)") |
| for hf_id in missing_lic: |
| lines.append(f"- {hf_id}") |
| if skipped: |
| lines.append("") |
| lines.append("## Skipped / warnings") |
| for entry in skipped[:200]: |
| lines.append(f"- {entry}") |
| if len(skipped) > 200: |
| lines.append(f"- ... and {len(skipped) - 200} more") |
| lines.append("") |
| lines.append("> Decontamination against eval splits is a separate required step before training.") |
| path.write_text("\n".join(lines) + "\n", encoding="utf-8") |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(build()) |
|
|