Add decision core: autojev/sft_pipeline.py
Browse files- autojev/sft_pipeline.py +532 -0
autojev/sft_pipeline.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Freeze row order once; publish each audited training tranche atomically."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import gzip
|
| 7 |
+
import hashlib
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
import random
|
| 11 |
+
from collections import Counter, defaultdict
|
| 12 |
+
from collections.abc import Iterator, Sequence
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import TypedDict, cast
|
| 15 |
+
|
| 16 |
+
from autojev.events import record
|
| 17 |
+
from autojev.sft_synthetic import COUNTS, Slot, load_plan
|
| 18 |
+
from autojev.types import Example, JSONValue
|
| 19 |
+
|
| 20 |
+
SEED = 20260921
|
| 21 |
+
TRANCHES = 10
|
| 22 |
+
NEW_ROWS = 5000
|
| 23 |
+
REPLAY_ROWS = 1920
|
| 24 |
+
TOTAL_ROWS = 69200
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class Tranche(TypedDict):
|
| 28 |
+
index: int
|
| 29 |
+
synthetic_ids: list[str]
|
| 30 |
+
synthetic_families: list[str]
|
| 31 |
+
replay_ids: list[str]
|
| 32 |
+
ordered_ids: list[str]
|
| 33 |
+
data_path: str
|
| 34 |
+
synthetic_path: str
|
| 35 |
+
manifest_path: str
|
| 36 |
+
counts: dict[str, JSONValue]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class Schedule(TypedDict):
|
| 40 |
+
schema_version: int
|
| 41 |
+
seed: int
|
| 42 |
+
total_rows: int
|
| 43 |
+
ordered_ids: list[str]
|
| 44 |
+
plan_path: str
|
| 45 |
+
plan_sha256: str
|
| 46 |
+
original_train_path: str
|
| 47 |
+
original_train_sha256: str
|
| 48 |
+
replay_ids: list[str]
|
| 49 |
+
selected_replay_sha256: str
|
| 50 |
+
selected_replay_ids_sha256: str
|
| 51 |
+
public_calibration_path: str
|
| 52 |
+
public_calibration_sha256: str
|
| 53 |
+
calibration_path: str
|
| 54 |
+
calibration_ids: list[str]
|
| 55 |
+
controls_audit_path: str
|
| 56 |
+
synthetic_calibration_path: str
|
| 57 |
+
synthetic_confirmation_path: str
|
| 58 |
+
control_ids: dict[str, list[str]]
|
| 59 |
+
audit_order: list[str]
|
| 60 |
+
assignments: list[dict[str, JSONValue]]
|
| 61 |
+
code_sha256: dict[str, str]
|
| 62 |
+
tranches: list[Tranche]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class Receipt(TypedDict):
|
| 66 |
+
schema_version: int
|
| 67 |
+
complete: bool
|
| 68 |
+
schedule_sha256: str
|
| 69 |
+
tranche: int
|
| 70 |
+
rows: int
|
| 71 |
+
data_sha256: str
|
| 72 |
+
synthetic_sha256: str
|
| 73 |
+
ordered_ids_sha256: str
|
| 74 |
+
audit_path: str
|
| 75 |
+
audit_sha256: str
|
| 76 |
+
calibration_sha256: str
|
| 77 |
+
controls_audit_sha256: str
|
| 78 |
+
previous_receipts_sha256: list[str]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def digest(path: Path) -> str:
|
| 82 |
+
with path.open("rb") as stream:
|
| 83 |
+
return hashlib.file_digest(stream, "sha256").hexdigest()
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def ids_sha256(ids: Sequence[str]) -> str:
|
| 87 |
+
return hashlib.sha256(json.dumps(ids, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def path_at(schedule_path: Path, value: str) -> Path:
|
| 91 |
+
path = Path(value)
|
| 92 |
+
return path if path.is_absolute() else schedule_path.parent / path
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def relative(path: Path, schedule_path: Path) -> str:
|
| 96 |
+
# Keep repository symlink paths portable; do not resolve their storage targets.
|
| 97 |
+
return os.path.relpath(path.absolute(), schedule_path.parent.absolute())
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def rows(path: Path) -> Iterator[Example]:
|
| 101 |
+
with path.open() as stream:
|
| 102 |
+
for line in stream:
|
| 103 |
+
yield cast(Example, json.loads(line))
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def document(path: Path) -> dict[str, JSONValue]:
|
| 107 |
+
return cast(dict[str, JSONValue], json.loads(path.read_bytes()))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def load_schedule(path: Path) -> Schedule:
|
| 111 |
+
"""Validate structure without requiring future dataset files to exist."""
|
| 112 |
+
raw = gzip.decompress(path.read_bytes()) if path.suffix == ".gz" else path.read_bytes()
|
| 113 |
+
schedule = cast(Schedule, json.loads(raw))
|
| 114 |
+
tranches = schedule["tranches"]
|
| 115 |
+
if schedule["schema_version"] != 1 or [item["index"] for item in tranches] != list(range(TRANCHES)):
|
| 116 |
+
raise ValueError(f"Schedule must contain exactly {TRANCHES} ordered tranches")
|
| 117 |
+
concatenated: list[str] = []
|
| 118 |
+
for item in tranches:
|
| 119 |
+
new, replay, ordered = item["synthetic_ids"], item["replay_ids"], item["ordered_ids"]
|
| 120 |
+
if (len(new) != NEW_ROWS or len(replay) != REPLAY_ROWS or len(ordered) != NEW_ROWS + REPLAY_ROWS
|
| 121 |
+
or len(set(new + replay)) != len(ordered) or set(new + replay) != set(ordered)):
|
| 122 |
+
raise ValueError(f"Tranche {item['index']} has invalid coverage or repeated IDs")
|
| 123 |
+
concatenated.extend(ordered)
|
| 124 |
+
if (concatenated != schedule["ordered_ids"] or len(concatenated) != schedule["total_rows"]
|
| 125 |
+
or len(concatenated) != TOTAL_ROWS or len(set(concatenated)) != TOTAL_ROWS):
|
| 126 |
+
raise ValueError("Global schedule order, count or uniqueness differs")
|
| 127 |
+
return schedule
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def immutable(path: Path, payload: bytes) -> None:
|
| 131 |
+
if path.exists():
|
| 132 |
+
if path.read_bytes() != payload:
|
| 133 |
+
raise ValueError(f"Refusing to replace immutable artifact: {path}")
|
| 134 |
+
return
|
| 135 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 136 |
+
temporary = path.with_name(path.name + ".tmp")
|
| 137 |
+
with temporary.open("xb") as stream:
|
| 138 |
+
stream.write(payload)
|
| 139 |
+
stream.flush()
|
| 140 |
+
os.fsync(stream.fileno())
|
| 141 |
+
temporary.chmod(0o444)
|
| 142 |
+
temporary.replace(path)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def encoded(example: Example) -> bytes:
|
| 146 |
+
return (json.dumps(example, ensure_ascii=False) + "\n").encode()
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def rank(value: str) -> str:
|
| 150 |
+
return hashlib.sha256(f"{SEED}:{value}".encode()).hexdigest()
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def make_schedule(plan_path: Path, original: Path, public_calibration: Path,
|
| 154 |
+
controls_audit: Path, calibration: Path, output: Path) -> Schedule:
|
| 155 |
+
plan = load_plan(plan_path)
|
| 156 |
+
groups: dict[tuple[str, str], dict[str, list[Slot]]] = defaultdict(lambda: defaultdict(list))
|
| 157 |
+
for slot in plan["slots"]:
|
| 158 |
+
if slot["split"] == "train":
|
| 159 |
+
groups[slot["task"], slot["language"]][slot["family"]].append(slot)
|
| 160 |
+
allocated: list[list[Slot]] = [[] for _ in range(TRANCHES)]
|
| 161 |
+
for key, families in sorted(groups.items()):
|
| 162 |
+
if len(families) % TRANCHES or any(len(pair) != 2 for pair in families.values()):
|
| 163 |
+
raise ValueError(f"Cannot assign exact paired task/language quotas: {key}")
|
| 164 |
+
quota = len(families) // TRANCHES
|
| 165 |
+
pilot = sorted((family for family, pair in families.items() if pair[0]["pilot"]), key=rank)
|
| 166 |
+
pilot_set = set(pilot)
|
| 167 |
+
remaining = sorted((family for family in families if family not in pilot_set), key=rank)
|
| 168 |
+
if len(pilot) > quota:
|
| 169 |
+
raise ValueError("Pilot exceeds the first tranche quota")
|
| 170 |
+
cursor = 0
|
| 171 |
+
# Split each original 10,000-row allocation in two; keep every pilot family first.
|
| 172 |
+
for index in range(TRANCHES // 2):
|
| 173 |
+
chosen = pilot[:] if index == 0 else []
|
| 174 |
+
take = 2 * quota - len(chosen)
|
| 175 |
+
chosen += remaining[cursor:cursor + take]
|
| 176 |
+
cursor += take
|
| 177 |
+
for half in range(2):
|
| 178 |
+
allocated[2 * index + half].extend(slot for family in chosen[half * quota:(half + 1) * quota] for slot in families[family])
|
| 179 |
+
|
| 180 |
+
old: dict[str, dict[str, JSONValue]] = {}
|
| 181 |
+
by_suite: dict[str, dict[str, list[str]]] = defaultdict(lambda: defaultdict(list))
|
| 182 |
+
for row in rows(original):
|
| 183 |
+
if row["id"] in old:
|
| 184 |
+
raise ValueError("Original training IDs repeat")
|
| 185 |
+
old[row["id"]] = {"id": row["id"], "family": row["family"], "suite": row["suite"],
|
| 186 |
+
"kind": "replay", "label": row["label"], "image_count": len(row.get("images", []))}
|
| 187 |
+
by_suite[row["suite"]][row["family"]].append(row["id"])
|
| 188 |
+
if len(old) != 96000:
|
| 189 |
+
raise ValueError("Expected the frozen original 96,000 training rows")
|
| 190 |
+
replay: list[list[str]] = [[] for _ in range(TRANCHES)]
|
| 191 |
+
for suite, replay_families in sorted(by_suite.items()):
|
| 192 |
+
size = sum(map(len, replay_families.values()))
|
| 193 |
+
if size % (5 * TRANCHES):
|
| 194 |
+
raise ValueError(f"Source quota is not divisible across {TRANCHES} tranches: {suite}")
|
| 195 |
+
quota = size // 5
|
| 196 |
+
selected: list[str] = []
|
| 197 |
+
for family in sorted(replay_families, key=lambda value: rank(f"replay:{suite}:{value}")):
|
| 198 |
+
if len(selected) + len(replay_families[family]) <= quota:
|
| 199 |
+
selected.extend(sorted(replay_families[family], key=rank))
|
| 200 |
+
if len(selected) != quota:
|
| 201 |
+
raise ValueError(f"Whole-family replay sampling did not fill {suite}: {len(selected)}/{quota}")
|
| 202 |
+
for index in range(TRANCHES):
|
| 203 |
+
replay[index].extend(selected[index * (quota // TRANCHES):(index + 1) * (quota // TRANCHES)])
|
| 204 |
+
|
| 205 |
+
assignments: list[dict[str, JSONValue]] = []
|
| 206 |
+
tranches: list[Tranche] = []
|
| 207 |
+
for index, slots in enumerate(allocated):
|
| 208 |
+
slots.sort(key=lambda slot: (rank(f"tranche:{index}:{slot['family']}"), slot["variant"]))
|
| 209 |
+
task_counts = Counter(slot["task"] for slot in slots)
|
| 210 |
+
if dict(task_counts) != {task: count // TRANCHES for task, count in COUNTS.items()} or len(replay[index]) != REPLAY_ROWS:
|
| 211 |
+
raise ValueError("Tranche task or replay quotas differ")
|
| 212 |
+
for task, count in task_counts.items():
|
| 213 |
+
if Counter(slot["language"] for slot in slots if slot["task"] == task) != {"EN": count * 7 // 10, "DE": count // 10, "ES": count // 10, "FR": count // 10}:
|
| 214 |
+
raise ValueError("Tranche task/language margins differ")
|
| 215 |
+
new_ids = [slot["id"] for slot in slots]
|
| 216 |
+
ordered = new_ids + replay[index]
|
| 217 |
+
random.Random(int(rank(f"training-order:{index}"), 16)).shuffle(ordered)
|
| 218 |
+
folder = Path("data/continuation/tranches") / f"{index:03d}"
|
| 219 |
+
tranches.append({"index": index, "synthetic_ids": new_ids,
|
| 220 |
+
"synthetic_families": list(dict.fromkeys(slot["family"] for slot in slots)),
|
| 221 |
+
"replay_ids": replay[index], "ordered_ids": ordered,
|
| 222 |
+
"data_path": relative(folder / "train.jsonl", output),
|
| 223 |
+
"synthetic_path": relative(folder / "synthetic.jsonl", output),
|
| 224 |
+
"manifest_path": relative(Path("progress/continuation/tranches") / f"{index:03d}.json", output),
|
| 225 |
+
"counts": {"synthetic_tasks": dict(task_counts),
|
| 226 |
+
"replay_suites": dict(Counter(str(old[id_]["suite"]) for id_ in replay[index])),
|
| 227 |
+
"replay_labels": dict(Counter(f"{old[id_]['suite']}:{old[id_]['label']}" for id_ in replay[index])),
|
| 228 |
+
"replay_image_rows": sum(bool(old[id_]["image_count"]) for id_ in replay[index])}})
|
| 229 |
+
for slot in slots:
|
| 230 |
+
assignments.append({"id": slot["id"], "family": slot["family"], "kind": "synthetic", "tranche": index,
|
| 231 |
+
"task": slot["task"], "language": slot["language"], "domain": slot["domain"],
|
| 232 |
+
"phenomenon": slot["phenomenon"], "style": slot["style"], "length": slot["length"],
|
| 233 |
+
"ontology": slot["ontology"], "pilot": slot["pilot"], "menu_size": len(slot["option_labels"]),
|
| 234 |
+
"label": slot["desired_label"]})
|
| 235 |
+
assignments.extend({**old[id_], "tranche": index} for id_ in replay[index])
|
| 236 |
+
global_ids = [id_ for tranche in tranches for id_ in tranche["ordered_ids"]]
|
| 237 |
+
if len(global_ids) != TOTAL_ROWS or len(set(global_ids)) != TOTAL_ROWS:
|
| 238 |
+
raise ValueError("Schedule IDs repeat or total differs")
|
| 239 |
+
if sum(slot["pilot"] for slot in allocated[0]) != 2000 or any(slot["pilot"] for group in allocated[1:] for slot in group):
|
| 240 |
+
raise ValueError("All 2,000 pilot examples must be in tranche zero")
|
| 241 |
+
public_ids = [row["id"] for row in rows(public_calibration)]
|
| 242 |
+
controls = {split: [slot["id"] for slot in plan["slots"] if slot["split"] == split]
|
| 243 |
+
for split in ("calibration", "confirmation")}
|
| 244 |
+
if len(set(public_ids)) != 2500 or len(public_ids) != 2500 or any(len(ids) != 1000 for ids in controls.values()):
|
| 245 |
+
raise ValueError("Expected 2,500 public calibration and 1,000 examples per synthetic control")
|
| 246 |
+
replay_ids = [id_ for group in replay for id_ in group]
|
| 247 |
+
selected_set = set(replay_ids)
|
| 248 |
+
replay_hash = hashlib.sha256()
|
| 249 |
+
for row in rows(original):
|
| 250 |
+
if row["id"] in selected_set:
|
| 251 |
+
replay_hash.update(encoded(row))
|
| 252 |
+
return {"schema_version": 1, "seed": SEED, "total_rows": TOTAL_ROWS, "ordered_ids": global_ids,
|
| 253 |
+
"plan_path": relative(plan_path, output), "plan_sha256": digest(plan_path),
|
| 254 |
+
"original_train_path": relative(original, output), "original_train_sha256": digest(original),
|
| 255 |
+
"replay_ids": replay_ids, "selected_replay_sha256": replay_hash.hexdigest(),
|
| 256 |
+
"selected_replay_ids_sha256": ids_sha256(replay_ids),
|
| 257 |
+
"public_calibration_path": relative(public_calibration, output), "public_calibration_sha256": digest(public_calibration),
|
| 258 |
+
"calibration_path": relative(calibration, output), "calibration_ids": public_ids + controls["calibration"],
|
| 259 |
+
"controls_audit_path": relative(controls_audit, output),
|
| 260 |
+
"synthetic_calibration_path": relative(controls_audit.parent / "calibration.jsonl", output),
|
| 261 |
+
"synthetic_confirmation_path": relative(controls_audit.parent / "confirmation.jsonl", output),
|
| 262 |
+
"control_ids": controls, "audit_order": controls["calibration"] + controls["confirmation"] + [id_ for tranche in tranches for id_ in tranche["synthetic_ids"]],
|
| 263 |
+
"assignments": assignments, "code_sha256": {"src/autojev/sft_pipeline.py": digest(Path(__file__))}, "tranches": tranches}
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def verify_output(report: dict[str, JSONValue], split: str, path: Path, expected: list[str]) -> list[Example]:
|
| 267 |
+
outputs = cast(dict[str, dict[str, JSONValue]], report["outputs"])
|
| 268 |
+
result = list(rows(path))
|
| 269 |
+
if outputs[split]["sha256"] != digest(path) or len(result) != len(expected) or {row["id"] for row in result} != set(expected):
|
| 270 |
+
raise ValueError(f"Audited {split} data differs from its receipt or scheduled IDs")
|
| 271 |
+
return result
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def freeze_tranche(schedule_path: Path, index: int, synthetic: Path, audit_path: Path) -> Receipt:
|
| 275 |
+
schedule = load_schedule(schedule_path)
|
| 276 |
+
schedule_hash = digest(schedule_path)
|
| 277 |
+
if not 0 <= index < TRANCHES:
|
| 278 |
+
raise ValueError(f"Tranche index must be 0..{TRANCHES - 1}")
|
| 279 |
+
if schedule["code_sha256"]["src/autojev/sft_pipeline.py"] != digest(Path(__file__)):
|
| 280 |
+
raise ValueError("Packaging code changed after the schedule freeze")
|
| 281 |
+
if digest(path_at(schedule_path, schedule["plan_path"])) != schedule["plan_sha256"]:
|
| 282 |
+
raise ValueError("Generation plan changed after the schedule freeze")
|
| 283 |
+
original = path_at(schedule_path, schedule["original_train_path"])
|
| 284 |
+
if digest(original) != schedule["original_train_sha256"]:
|
| 285 |
+
raise ValueError("Original training dataset changed")
|
| 286 |
+
controls_path = path_at(schedule_path, schedule["controls_audit_path"])
|
| 287 |
+
controls = document(controls_path)
|
| 288 |
+
if (controls.get("complete") is not True or controls.get("phase") != "controls" or controls.get("accepted_rows") != 2000
|
| 289 |
+
or cast(dict[str, JSONValue], controls["plan"])["sha256"] != schedule["plan_sha256"]):
|
| 290 |
+
raise ValueError("Complete frozen controls audit is required before any training tranche")
|
| 291 |
+
control_paths = {"calibration": schedule["synthetic_calibration_path"], "confirmation": schedule["synthetic_confirmation_path"]}
|
| 292 |
+
control_rows = {split: verify_output(controls, split, path_at(schedule_path, value), schedule["control_ids"][split])
|
| 293 |
+
for split, value in control_paths.items()}
|
| 294 |
+
public = path_at(schedule_path, schedule["public_calibration_path"])
|
| 295 |
+
if digest(public) != schedule["public_calibration_sha256"]:
|
| 296 |
+
raise ValueError("Public calibration data changed")
|
| 297 |
+
temperature = {row["id"]: row for row in [*rows(public), *control_rows["calibration"]]}
|
| 298 |
+
if len(temperature) != 3500 or set(temperature) != set(schedule["calibration_ids"]):
|
| 299 |
+
raise ValueError("Combined calibration data must contain the exact 3,500 scheduled IDs")
|
| 300 |
+
temperature_path = path_at(schedule_path, schedule["calibration_path"])
|
| 301 |
+
immutable(temperature_path, b"".join(encoded(temperature[id_]) for id_ in schedule["calibration_ids"]))
|
| 302 |
+
temperature_hash = digest(temperature_path)
|
| 303 |
+
previous: list[str] = []
|
| 304 |
+
prior_hashes = {digest(path_at(schedule_path, value)) for value in control_paths.values()}
|
| 305 |
+
for prior in schedule["tranches"][:index]:
|
| 306 |
+
path = path_at(schedule_path, prior["manifest_path"])
|
| 307 |
+
receipt = cast(Receipt, document(path))
|
| 308 |
+
if (receipt["complete"] is not True or receipt["schedule_sha256"] != schedule_hash or receipt["tranche"] != prior["index"]
|
| 309 |
+
or receipt["previous_receipts_sha256"] != previous or receipt["calibration_sha256"] != temperature_hash
|
| 310 |
+
or receipt["controls_audit_sha256"] != digest(controls_path)
|
| 311 |
+
or receipt["data_sha256"] != digest(path_at(schedule_path, prior["data_path"]))
|
| 312 |
+
or receipt["synthetic_sha256"] != digest(path_at(schedule_path, prior["synthetic_path"]))
|
| 313 |
+
or receipt["audit_sha256"] != digest(path_at(schedule_path, receipt["audit_path"]))):
|
| 314 |
+
raise ValueError("An earlier immutable tranche receipt or its inputs changed")
|
| 315 |
+
previous.append(digest(path))
|
| 316 |
+
prior_hashes.add(receipt["synthetic_sha256"])
|
| 317 |
+
audit = document(audit_path)
|
| 318 |
+
actual_prior = {str(item["sha256"]) for item in cast(list[dict[str, JSONValue]], audit["prior_inputs"])}
|
| 319 |
+
if (audit.get("complete") is not True or audit.get("phase") != "tranche" or audit.get("accepted_rows") != NEW_ROWS
|
| 320 |
+
or audit.get("schedule_sha256") != schedule_hash or audit.get("tranche") != index
|
| 321 |
+
or cast(dict[str, JSONValue], audit["plan"])["sha256"] != schedule["plan_sha256"] or not prior_hashes <= actual_prior):
|
| 322 |
+
raise ValueError("Tranche audit is incomplete or does not bind the schedule and all previous accepted data")
|
| 323 |
+
tranche = schedule["tranches"][index]
|
| 324 |
+
new_rows = verify_output(audit, "train", synthetic, tranche["synthetic_ids"])
|
| 325 |
+
replay_set = set(tranche["replay_ids"])
|
| 326 |
+
selected = {row["id"]: row for row in rows(original) if row["id"] in replay_set}
|
| 327 |
+
selected.update({row["id"]: row for row in new_rows})
|
| 328 |
+
if set(selected) != set(tranche["ordered_ids"]):
|
| 329 |
+
raise ValueError("Packaged training IDs differ from the schedule")
|
| 330 |
+
new_path, data_path = path_at(schedule_path, tranche["synthetic_path"]), path_at(schedule_path, tranche["data_path"])
|
| 331 |
+
immutable(new_path, synthetic.read_bytes())
|
| 332 |
+
immutable(data_path, b"".join(encoded(selected[id_]) for id_ in tranche["ordered_ids"]))
|
| 333 |
+
result: Receipt = {"schema_version": 1, "complete": True, "schedule_sha256": schedule_hash, "tranche": index,
|
| 334 |
+
"rows": len(selected), "data_sha256": digest(data_path), "synthetic_sha256": digest(new_path),
|
| 335 |
+
"ordered_ids_sha256": ids_sha256(tranche["ordered_ids"]), "audit_path": relative(audit_path, schedule_path),
|
| 336 |
+
"audit_sha256": digest(audit_path), "calibration_sha256": temperature_hash,
|
| 337 |
+
"controls_audit_sha256": digest(controls_path), "previous_receipts_sha256": previous}
|
| 338 |
+
receipt_path = path_at(schedule_path, tranche["manifest_path"])
|
| 339 |
+
exists = receipt_path.exists()
|
| 340 |
+
immutable(receipt_path, (json.dumps(result, indent=2, ensure_ascii=False) + "\n").encode())
|
| 341 |
+
if not exists:
|
| 342 |
+
record("training_tranche_ready", tranche=index, rows=len(selected), schedule_sha256=schedule_hash,
|
| 343 |
+
receipt=str(receipt_path), receipt_sha256=digest(receipt_path), data_sha256=result["data_sha256"])
|
| 344 |
+
return result
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def quick_key(identifier: str) -> str:
|
| 348 |
+
return rank(f"quick-evaluation:{identifier}")
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def quick_public(path: Path, per_suite: int) -> list[Example]:
|
| 352 |
+
groups: dict[str, list[Example]] = defaultdict(list)
|
| 353 |
+
for row in rows(path):
|
| 354 |
+
groups[row["suite"]].append(row)
|
| 355 |
+
if len(groups) != 5:
|
| 356 |
+
raise ValueError("Quick public panels require five source suites")
|
| 357 |
+
selected = [row for _, group in sorted(groups.items())
|
| 358 |
+
for row in sorted(group, key=lambda row: quick_key(row["id"]))[:per_suite]]
|
| 359 |
+
if len(selected) != 5 * per_suite or len({row["family"] for row in selected}) != len(selected):
|
| 360 |
+
raise ValueError("Expected enough distinct public source families")
|
| 361 |
+
return selected
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def make_quick_plan(output: Path, generation_plan: Path) -> dict[str, JSONValue]:
|
| 365 |
+
"""Choose diagnostic IDs from construction metadata, never model outcomes."""
|
| 366 |
+
if output.exists():
|
| 367 |
+
raise ValueError("Quick evaluation IDs are already frozen")
|
| 368 |
+
paths = {"public": Path("data/transfer/public.jsonl"), "guard": Path("data/transfer/synthetic.jsonl"),
|
| 369 |
+
"public_temperature": Path("data/continuation/public-temperature.jsonl")}
|
| 370 |
+
public = quick_public(paths["public"], 200)
|
| 371 |
+
public_temperature = quick_public(paths["public_temperature"], 100)
|
| 372 |
+
cells: dict[tuple[str, str, str], list[Example]] = defaultdict(list)
|
| 373 |
+
for row in rows(paths["guard"]):
|
| 374 |
+
source = row["source"]
|
| 375 |
+
cells[str(source["task"]), str(source["condition"]), str(source["language"])].append(row)
|
| 376 |
+
if len(cells) != 200:
|
| 377 |
+
raise ValueError("Guard needs ten tasks × five conditions × four languages")
|
| 378 |
+
guard: list[Example] = []
|
| 379 |
+
tasks = sorted({cell[0] for cell in cells})
|
| 380 |
+
for task in tasks:
|
| 381 |
+
conditions = sorted({cell[1] for cell in cells if cell[0] == task}, key=lambda value: quick_key(f"{task}:{value}"))
|
| 382 |
+
other_languages = sorted(("DE", "ES", "FR"), key=lambda value: quick_key(f"{task}:{value}"))
|
| 383 |
+
for index, condition in enumerate(conditions):
|
| 384 |
+
quotas = {"EN": 2 if index < 3 else 1, **{language: int(index >= 3 or language != other_languages[index]) for language in other_languages}}
|
| 385 |
+
for language, count in sorted(quotas.items()):
|
| 386 |
+
guard_chosen = sorted(cells[task, condition, language], key=lambda row: quick_key(row["id"]))[:count]
|
| 387 |
+
if len(guard_chosen) != count:
|
| 388 |
+
raise ValueError("Insufficient guard rows for the fixed task/condition/language quotas")
|
| 389 |
+
guard.extend(guard_chosen)
|
| 390 |
+
if len({row["family"] for row in guard}) != 200:
|
| 391 |
+
raise ValueError("Quick guard families must be distinct")
|
| 392 |
+
|
| 393 |
+
plan = load_plan(generation_plan)
|
| 394 |
+
by_cell: dict[tuple[str, str], dict[str, list[Slot]]] = defaultdict(lambda: defaultdict(list))
|
| 395 |
+
for slot in plan["slots"]:
|
| 396 |
+
if slot["split"] == "calibration":
|
| 397 |
+
by_cell[slot["task"], slot["language"]][slot["family"]].append(slot)
|
| 398 |
+
family_targets = {task: count // 500 for task, count in COUNTS.items()}
|
| 399 |
+
weights = {"EN": 7, "DE": 1, "ES": 1, "FR": 1}
|
| 400 |
+
# Paired families make some task/language ideals fractional. Preserve both
|
| 401 |
+
# integer margins, with at least one family in every task/language cell.
|
| 402 |
+
allocation = {(task, language): max(1, total * weight // 10)
|
| 403 |
+
for task, total in family_targets.items() for language, weight in weights.items()}
|
| 404 |
+
remaining_task = {task: total - sum(allocation[task, language] for language in weights)
|
| 405 |
+
for task, total in family_targets.items()}
|
| 406 |
+
remaining_language = {language: weight * 10 - sum(allocation[task, language] for task in family_targets)
|
| 407 |
+
for language, weight in weights.items()}
|
| 408 |
+
while sum(remaining_task.values()):
|
| 409 |
+
choices = [(task, language) for task in family_targets for language in weights
|
| 410 |
+
if remaining_task[task] > 0 and remaining_language[language] > 0]
|
| 411 |
+
if not choices:
|
| 412 |
+
raise ValueError("Cannot fill declared paired temperature margins")
|
| 413 |
+
task, language = min(choices, key=lambda cell: (
|
| 414 |
+
-(family_targets[cell[0]] * weights[cell[1]] - 10 * allocation[cell]), quick_key(":".join(cell))))
|
| 415 |
+
allocation[task, language] += 1
|
| 416 |
+
remaining_task[task] -= 1
|
| 417 |
+
remaining_language[language] -= 1
|
| 418 |
+
synthetic_ids: list[str] = []
|
| 419 |
+
for cell, count in sorted(allocation.items()):
|
| 420 |
+
families = by_cell[cell]
|
| 421 |
+
chosen = sorted(families, key=quick_key)[:count]
|
| 422 |
+
if len(chosen) != count or any(len(families[family]) != 2 for family in chosen):
|
| 423 |
+
raise ValueError("Insufficient complete calibration families")
|
| 424 |
+
synthetic_ids.extend(slot["id"] for family in chosen
|
| 425 |
+
for slot in sorted(families[family], key=lambda slot: slot["variant"]))
|
| 426 |
+
if len(synthetic_ids) != 200 or any(remaining_language.values()):
|
| 427 |
+
raise ValueError("Synthetic temperature row or language totals differ")
|
| 428 |
+
destination = Path("data/continuation")
|
| 429 |
+
public_path, guard_path = destination / "quick-public.jsonl", destination / "quick-guard.jsonl"
|
| 430 |
+
immutable(public_path, b"".join(map(encoded, public)))
|
| 431 |
+
immutable(guard_path, b"".join(map(encoded, guard)))
|
| 432 |
+
result: dict[str, JSONValue] = {
|
| 433 |
+
"schema_version": 1, "seed": SEED, "selection_namespace": "quick-evaluation",
|
| 434 |
+
"purpose": "Diagnostic only; selection uses the complete public and guard cohorts every50 updates and at the final update.",
|
| 435 |
+
"quick_every": 20, "full_every": 50, "generation_plan": {"path": str(generation_plan), "sha256": digest(generation_plan)},
|
| 436 |
+
"source_files": {key: {"path": str(path), "sha256": digest(path)} for key, path in paths.items()},
|
| 437 |
+
"code_sha256": digest(Path(__file__)),
|
| 438 |
+
"public": {"path": str(public_path), "sha256": digest(public_path), "rows": 1000,
|
| 439 |
+
"ids": [row["id"] for row in public], "rule": "200 SHA-ranked IDs per source; distinct source families."},
|
| 440 |
+
"guard": {"path": str(guard_path), "sha256": digest(guard_path), "rows": 200,
|
| 441 |
+
"ids": [row["id"] for row in guard], "rule": "Four SHA-ranked IDs per task/condition; three SHA-ranked conditions use twoEN and omit a distinct other language, remaining two use one per language.",
|
| 442 |
+
"language_weighting": "Preserve the full guard40/20/20/20 mixture: eightEN and fourDE/ES/FR per task.",
|
| 443 |
+
"task_condition_language_rows": {task: {condition: {language: sum(row["source"]["task"] == task and row["source"]["condition"] == condition and row["source"]["language"] == language for row in guard) for language in ("EN", "DE", "ES", "FR")} for condition in sorted({cell[1] for cell in cells if cell[0] == task})} for task in tasks}},
|
| 444 |
+
"temperature": {"path": str(destination / "quick-temperature.jsonl"), "rows": 700,
|
| 445 |
+
"public_ids": [row["id"] for row in public_temperature], "synthetic_ids": cast(JSONValue, synthetic_ids),
|
| 446 |
+
"ids": cast(JSONValue, [row["id"] for row in public_temperature] + synthetic_ids),
|
| 447 |
+
"synthetic_task_language_rows": {task: {language: 2 * allocation[task, language] for language in weights} for task in family_targets},
|
| 448 |
+
"rule": "100 SHA-ranked public IDs per source plus100 whole synthetic families. Exact task and aggregate70/10/10/10 language margins; floor/minimum-one families then largest deficit with SHA tie-breaking.",
|
| 449 |
+
"materialization": "After the complete synthetic controls audit; no synthetic text or fitted temperature is chosen here."},
|
| 450 |
+
"limitations": ["Small-panel sampling variation is diagnostic, not a checkpoint-selection signal.",
|
| 451 |
+
"All labels/states/options are copied unchanged from their full parent folds; no model scores or confirmation records enter selection.",
|
| 452 |
+
"Synthetic task/language cells use whole-family integer rounding; the exact table above replaces infeasible fractional per-cell quotas."],
|
| 453 |
+
}
|
| 454 |
+
immutable(output, (json.dumps(result, indent=2, ensure_ascii=False) + "\n").encode())
|
| 455 |
+
record("quick_evaluation_ids_frozen", path=str(output), sha256=digest(output), public_rows=1000, guard_rows=200, temperature_rows=700)
|
| 456 |
+
return result
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
def materialize_quick_temperature(plan_path: Path, controls_audit: Path) -> Path:
|
| 460 |
+
plan = document(plan_path)
|
| 461 |
+
sources = cast(dict[str, dict[str, JSONValue]], plan["source_files"])
|
| 462 |
+
public_source = sources["public_temperature"]
|
| 463 |
+
public = Path(str(public_source["path"]))
|
| 464 |
+
if digest(public) != public_source["sha256"]:
|
| 465 |
+
raise ValueError("Public temperature source changed after quick-ID selection")
|
| 466 |
+
controls = document(controls_audit)
|
| 467 |
+
generation_plan = cast(dict[str, JSONValue], plan["generation_plan"])
|
| 468 |
+
if (controls.get("complete") is not True or controls.get("phase") != "controls" or controls.get("accepted_rows") != 2000
|
| 469 |
+
or cast(dict[str, JSONValue], controls["plan"])["sha256"] != generation_plan["sha256"]):
|
| 470 |
+
raise ValueError("Complete matching controls audit is required")
|
| 471 |
+
synthetic = controls_audit.parent / "calibration.jsonl"
|
| 472 |
+
outputs = cast(dict[str, dict[str, JSONValue]], controls["outputs"])
|
| 473 |
+
if digest(synthetic) != outputs["calibration"]["sha256"]:
|
| 474 |
+
raise ValueError("Synthetic calibration differs from its audit")
|
| 475 |
+
temperature = cast(dict[str, JSONValue], plan["temperature"])
|
| 476 |
+
ids = cast(list[str], temperature["ids"])
|
| 477 |
+
full_rows = [*rows(public), *rows(synthetic)]
|
| 478 |
+
source = {row["id"]: row for row in full_rows}
|
| 479 |
+
if len(full_rows) != 3500 or len(source) != 3500 or len(ids) != 700 or not set(ids) <= source.keys():
|
| 480 |
+
raise ValueError("Quick temperature IDs do not match the complete parent fold")
|
| 481 |
+
destination = Path(str(temperature["path"]))
|
| 482 |
+
existed = destination.exists()
|
| 483 |
+
immutable(destination, b"".join(encoded(source[id_]) for id_ in ids))
|
| 484 |
+
receipt = {"quick_plan_sha256": digest(plan_path), "controls_audit_sha256": digest(controls_audit),
|
| 485 |
+
"path": str(destination), "sha256": digest(destination), "rows": 700}
|
| 486 |
+
immutable(plan_path.with_name("quick-temperature-receipt.json"), (json.dumps(receipt, indent=2) + "\n").encode())
|
| 487 |
+
if not existed:
|
| 488 |
+
record("quick_evaluation_temperature_ready", **receipt)
|
| 489 |
+
return destination
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def main() -> None:
|
| 493 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 494 |
+
parser.add_argument("command", choices=("plan", "freeze", "quick-plan", "quick-temperature"))
|
| 495 |
+
parser.add_argument("--schedule", type=Path)
|
| 496 |
+
parser.add_argument("--quick-plan", type=Path, default=Path("progress/continuation/quick-eval-plan.json"))
|
| 497 |
+
parser.add_argument("--generation-plan", type=Path, default=Path("data/continuation/generation-plan-v2.json.gz"))
|
| 498 |
+
parser.add_argument("--original-train", type=Path, default=Path("data/train.jsonl"))
|
| 499 |
+
parser.add_argument("--public-calibration", type=Path, default=Path("data/continuation/public-temperature.jsonl"))
|
| 500 |
+
parser.add_argument("--controls-audit", type=Path, default=Path("data/continuation/controls-audit/audit.json"))
|
| 501 |
+
parser.add_argument("--calibration", type=Path, default=Path("data/continuation/temperature.jsonl"))
|
| 502 |
+
parser.add_argument("--tranche", type=int)
|
| 503 |
+
parser.add_argument("--synthetic", type=Path)
|
| 504 |
+
parser.add_argument("--audit", type=Path)
|
| 505 |
+
args = parser.parse_args()
|
| 506 |
+
if not os.getenv("AUTOJEV_EVENTS"):
|
| 507 |
+
parser.error("Set AUTOJEV_EVENTS explicitly to the continuation event log")
|
| 508 |
+
if args.command == "quick-plan":
|
| 509 |
+
make_quick_plan(args.quick_plan, args.generation_plan)
|
| 510 |
+
print(json.dumps({"path": str(args.quick_plan), "sha256": digest(args.quick_plan)}))
|
| 511 |
+
elif args.command == "quick-temperature":
|
| 512 |
+
print(materialize_quick_temperature(args.quick_plan, args.controls_audit))
|
| 513 |
+
elif args.command == "plan":
|
| 514 |
+
if args.schedule is None:
|
| 515 |
+
parser.error("plan requires --schedule")
|
| 516 |
+
if args.schedule.exists():
|
| 517 |
+
parser.error("Schedule already exists; refusing to replace it")
|
| 518 |
+
schedule = make_schedule(args.generation_plan, args.original_train, args.public_calibration, args.controls_audit, args.calibration, args.schedule)
|
| 519 |
+
payload = json.dumps(schedule, ensure_ascii=False, separators=(",", ":")).encode()
|
| 520 |
+
immutable(args.schedule, gzip.compress(payload, mtime=0) if args.schedule.suffix == ".gz" else payload)
|
| 521 |
+
load_schedule(args.schedule)
|
| 522 |
+
record("training_schedule_frozen", path=str(args.schedule), sha256=digest(args.schedule), rows=TOTAL_ROWS,
|
| 523 |
+
synthetic_rows=50000, replay_rows=19200, tranches=TRANCHES, global_batch=256, updates=271)
|
| 524 |
+
print(json.dumps({"path": str(args.schedule), "sha256": digest(args.schedule), "rows": TOTAL_ROWS, "updates": 271}))
|
| 525 |
+
else:
|
| 526 |
+
if args.schedule is None or args.tranche is None or args.synthetic is None or args.audit is None:
|
| 527 |
+
parser.error("freeze requires --schedule, --tranche, --synthetic and --audit")
|
| 528 |
+
print(json.dumps(freeze_tranche(args.schedule, args.tranche, args.synthetic, args.audit), indent=2))
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
if __name__ == "__main__":
|
| 532 |
+
main()
|