Self-Forcing / scripts /evaluate_layer17_chunk_impact.py
Cccccz's picture
Upload Python scripts
bc29ee3 verified
Raw
History Blame Contribute Delete
30.4 kB
#!/usr/bin/env python3
"""Measure local and downstream impact of isolated Layer-17 Predictor calls.
For every prompt, chunk 1..6 is independently evaluated with FPFF, FFPF, and
FPPF while every other chunk remains FFFF. A shadow Full forward is executed
at each selected Predictor step to measure hidden/flow/x0 error on the exact
rollout state. The shadow output is never used by the generated trajectory.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import os
import sys
import time
from pathlib import Path
from typing import Any
def _preparse_gpu() -> str:
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--gpu", default="4")
args, _ = parser.parse_known_args()
os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
return str(args.gpu)
PHYSICAL_GPU = _preparse_gpu()
import lpips
import torch
from omegaconf import OmegaConf
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from scripts import evaluate_single_block_fppf as base
from utils.misc import set_seed
from utils.wan_wrapper import WanVAEWrapper
SCHEDULES = ("FPFF", "FFPF", "FPPF")
EPS = 1e-8
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--gpu", default=PHYSICAL_GPU)
parser.add_argument(
"--config_path", type=Path, default=Path("configs/self_forcing_sid.yaml")
)
parser.add_argument(
"--checkpoint_path",
type=Path,
default=Path("checkpoints/self_forcing_dmd.pt"),
)
parser.add_argument(
"--dataset_root",
type=Path,
default=Path("outputs/predictor_offline_100_all_blocks"),
)
parser.add_argument(
"--sweep_dir",
type=Path,
default=Path("outputs/single_block_init_sweep"),
)
parser.add_argument(
"--reference_root",
type=Path,
default=Path("outputs/single_block_fppf_eval"),
)
parser.add_argument(
"--output_dir",
type=Path,
default=Path("outputs/layer17_chunk_impact_pilot"),
)
parser.add_argument(
"--prompt_ids", type=int, nargs="*", default=list(range(80, 90))
)
parser.add_argument("--schedules", nargs="*", choices=SCHEDULES, default=list(SCHEDULES))
parser.add_argument(
"--chunks", type=int, nargs="*", default=list(range(1, base.NUM_CHUNKS))
)
parser.add_argument("--max_prompts", type=int, default=None)
parser.add_argument("--metric_batch_size", type=int, default=4)
parser.add_argument("--generation_seed", type=int, default=0)
parser.add_argument(
"--skip_lpips", action=argparse.BooleanOptionalAction, default=False
)
parser.add_argument("--overwrite", action="store_true")
args = parser.parse_args()
if not args.prompt_ids:
parser.error("At least one prompt ID is required")
if any(value < 0 or value >= 100 for value in args.prompt_ids):
parser.error("Prompt IDs must be in [0, 99]")
if args.metric_batch_size < 1:
parser.error("--metric_batch_size must be positive")
if not args.schedules:
parser.error("At least one schedule is required")
if not args.chunks or any(chunk < 1 or chunk >= base.NUM_CHUNKS for chunk in args.chunks):
parser.error("--chunks must contain values in [1, 6]")
return args
def resolve(path: Path) -> Path:
return path.resolve() if path.is_absolute() else (REPO_ROOT / path).resolve()
def rms(value: torch.Tensor) -> torch.Tensor:
return value.float().square().mean().sqrt()
def nrmse(prediction: torch.Tensor, target: torch.Tensor) -> float:
return float(rms(prediction.float() - target.float()) / rms(target).clamp_min(EPS))
def chunk_frame_slice(chunk: int) -> slice:
if chunk == 0:
return slice(0, base.PIXEL_FRAMES_FIRST_CHUNK)
start = base.PIXEL_FRAMES_FIRST_CHUNK + 12 * (chunk - 1)
return slice(start, start + 12)
def summarize_frame_range(metrics: dict[str, Any], selected: slice) -> dict[str, float]:
mse_values = metrics["mse_per_frame"][selected]
ssim_values = metrics["ssim_per_frame"][selected]
lpips_values = metrics["lpips_per_frame"][selected]
mean_mse = sum(mse_values) / len(mse_values)
return {
"pixel_mse": mean_mse,
"psnr": -10.0 * math.log10(max(mean_mse, 1e-12)),
"ssim": sum(ssim_values) / len(ssim_values),
"lpips": (
sum(lpips_values) / len(lpips_values) if lpips_values else float("nan")
),
}
@torch.inference_mode()
def generate_intervention(
*,
pipeline: Any,
dataset_root: Path,
prompt_id: int,
generation_seed: int,
device: torch.device,
predictor: Any,
source_layer: int,
intervention_chunk: int,
intervention_schedule: str,
) -> tuple[torch.Tensor, dict[str, Any]]:
if intervention_schedule not in SCHEDULES:
raise ValueError(intervention_schedule)
if intervention_chunk < 1 or intervention_chunk >= base.NUM_CHUNKS:
raise ValueError("Predictor intervention chunk must be 1..6")
base.reset_kv_and_load_cross_cache(pipeline, dataset_root, prompt_id, device)
set_seed(generation_seed)
noise = torch.randn(
1,
base.NUM_CHUNKS * base.FRAMES_PER_CHUNK,
base.LATENT_CHANNELS,
base.LATENT_HEIGHT,
base.LATENT_WIDTH,
dtype=torch.bfloat16,
device=device,
)
timesteps = pipeline.denoising_step_list.to(device=device)
output_chunks: list[torch.Tensor] = []
previous_chunk_hidden: list[torch.Tensor | None] | None = None
teacher = pipeline.generator.model
capture = base.FinalHiddenCapture(teacher)
full_calls = 0
predictor_calls = 0
shadow_full_calls = 0
local_errors: list[dict[str, float | int]] = []
started = time.perf_counter()
try:
for chunk in range(base.NUM_CHUNKS):
noisy_input = noise[
:,
chunk * base.FRAMES_PER_CHUNK : (chunk + 1) * base.FRAMES_PER_CHUNK,
]
current_hidden: list[torch.Tensor | None] = [None] * base.NUM_DENOISING_STEPS
denoised_pred: torch.Tensor | None = None
timestep: torch.Tensor | None = None
for step, current_timestep in enumerate(timesteps):
timestep = torch.ones(
[1, base.FRAMES_PER_CHUNK], dtype=torch.int64, device=device
) * current_timestep
selected = (
chunk == intervention_chunk
and intervention_schedule[step] == "P"
)
if selected:
anchor_hidden = current_hidden[step - 1]
if anchor_hidden is None or previous_chunk_hidden is None:
raise RuntimeError("Predictor inputs are unavailable")
previous_hidden = previous_chunk_hidden[step]
if previous_hidden is None:
raise RuntimeError("Previous-chunk hidden is unavailable")
history = pipeline.kv_cache1[source_layer]
cross = pipeline.crossattn_cache[source_layer]
pred_hidden, pred_flow, _ = base.predictor_step(
predictor=predictor,
teacher=teacher,
noisy_input=noisy_input,
timestep=timestep,
anchor_hidden=anchor_hidden,
previous_hidden=previous_hidden,
history_cache=history,
cross_cache=cross,
current_start=chunk * base.TOKENS_PER_CHUNK,
)
pred_x0 = pipeline.generator._convert_flow_pred_to_x0(
flow_pred=pred_flow.flatten(0, 1),
xt=noisy_input.flatten(0, 1),
timestep=timestep.flatten(0, 1),
).unflatten(0, pred_flow.shape[:2])
# Shadow Full measures the exact counterfactual target on
# this rollout state. Its x0/hidden are never accepted.
capture.start()
full_flow, full_x0 = pipeline.generator(
noisy_image_or_video=noisy_input,
conditional_dict={
"prompt_embeds": torch.zeros(
1,
1,
int(teacher.text_embedding[0].in_features),
dtype=torch.bfloat16,
device=device,
)
},
timestep=timestep,
kv_cache=pipeline.kv_cache1,
crossattn_cache=pipeline.crossattn_cache,
current_start=chunk * base.TOKENS_PER_CHUNK,
)
full_hidden = capture.finish()
local_errors.append(
{
"step": step,
"timestep": float(current_timestep),
"hidden_nrmse": nrmse(pred_hidden, full_hidden),
"flow_nrmse": nrmse(pred_flow, full_flow),
"x0_nrmse": nrmse(pred_x0, full_x0),
}
)
current_hidden[step] = pred_hidden
denoised_pred = pred_x0
predictor_calls += 1
shadow_full_calls += 1
del full_flow, full_x0, full_hidden
else:
capture.start()
_, denoised_pred = pipeline.generator(
noisy_image_or_video=noisy_input,
conditional_dict={
"prompt_embeds": torch.zeros(
1,
1,
int(teacher.text_embedding[0].in_features),
dtype=torch.bfloat16,
device=device,
)
},
timestep=timestep,
kv_cache=pipeline.kv_cache1,
crossattn_cache=pipeline.crossattn_cache,
current_start=chunk * base.TOKENS_PER_CHUNK,
)
current_hidden[step] = capture.finish()
full_calls += 1
if step < base.NUM_DENOISING_STEPS - 1:
if denoised_pred is None:
raise RuntimeError("Denoising step produced no x0")
next_timestep = timesteps[step + 1]
flat = denoised_pred.flatten(0, 1)
noisy_input = pipeline.scheduler.add_noise(
flat,
torch.randn_like(flat),
next_timestep
* torch.ones(
[base.FRAMES_PER_CHUNK], dtype=torch.long, device=device
),
).unflatten(0, denoised_pred.shape[:2])
if denoised_pred is None or timestep is None:
raise RuntimeError("Chunk produced no clean latent")
output_chunks.append(denoised_pred)
context_timestep = torch.ones_like(timestep) * pipeline.args.context_noise
pipeline.generator(
noisy_image_or_video=denoised_pred,
conditional_dict={
"prompt_embeds": torch.zeros(
1,
1,
int(teacher.text_embedding[0].in_features),
dtype=torch.bfloat16,
device=device,
)
},
timestep=context_timestep,
kv_cache=pipeline.kv_cache1,
crossattn_cache=pipeline.crossattn_cache,
current_start=chunk * base.TOKENS_PER_CHUNK,
)
previous_chunk_hidden = current_hidden
finally:
capture.close()
torch.cuda.synchronize()
return torch.cat(output_chunks, dim=1), {
"generation_time_s": time.perf_counter() - started,
"full_calls": full_calls,
"predictor_calls": predictor_calls,
"shadow_full_calls": shadow_full_calls,
"local_errors": local_errors,
}
def average(values: list[float]) -> float:
return sum(values) / len(values)
def finite_average(values: list[Any]) -> float:
numeric = [
float(value)
for value in values
if value is not None and math.isfinite(float(value))
]
return average(numeric) if numeric else float("nan")
def rankdata(values: list[float]) -> list[float]:
order = sorted(range(len(values)), key=values.__getitem__)
ranks = [0.0] * len(values)
start = 0
while start < len(order):
end = start + 1
while end < len(order) and values[order[end]] == values[order[start]]:
end += 1
rank = 0.5 * (start + end - 1)
for position in range(start, end):
ranks[order[position]] = rank
start = end
return ranks
def pearson(left: list[float], right: list[float]) -> float:
left_mean, right_mean = average(left), average(right)
left_centered = [value - left_mean for value in left]
right_centered = [value - right_mean for value in right]
numerator = sum(a * b for a, b in zip(left_centered, right_centered))
denominator = math.sqrt(
sum(value * value for value in left_centered)
* sum(value * value for value in right_centered)
)
return numerator / denominator if denominator > 0 else float("nan")
def spearman(left: list[float], right: list[float]) -> float:
return pearson(rankdata(left), rankdata(right))
def write_csv(path: Path, rows: list[dict[str, Any]], fields: list[str]) -> None:
temporary = path.with_suffix(path.suffix + ".tmp")
with temporary.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fields)
writer.writeheader()
writer.writerows(rows)
os.replace(temporary, path)
def group_centered_values(
records: list[dict[str, Any]], field: str
) -> list[float]:
"""Remove schedule-by-chunk means to isolate prompt/state variation."""
groups: dict[tuple[str, int], list[float]] = {}
for row in records:
key = (str(row["schedule"]), int(row["chunk"]))
groups.setdefault(key, []).append(float(row[field]))
means = {key: average(values) for key, values in groups.items()}
return [
float(row[field]) - means[(str(row["schedule"]), int(row["chunk"]))]
for row in records
]
def aggregate(records: list[dict[str, Any]], output_dir: Path) -> None:
numeric_fields = [
"hidden_nrmse_mean",
"flow_nrmse_mean",
"x0_nrmse_mean",
"latent_all_nrmse",
"latent_current_nrmse",
"latent_tail_nrmse",
"psnr",
"ssim",
"lpips",
"current_psnr",
"current_ssim",
"current_lpips",
"tail_psnr",
"tail_ssim",
"tail_lpips",
"generation_time_s",
]
summary_rows: list[dict[str, Any]] = []
available_schedules = [
schedule for schedule in SCHEDULES if any(row["schedule"] == schedule for row in records)
]
available_chunks = sorted({int(row["chunk"]) for row in records})
for schedule in available_schedules:
for chunk in available_chunks:
selected = [
row
for row in records
if row["schedule"] == schedule and row["chunk"] == chunk
]
row: dict[str, Any] = {
"schedule": schedule,
"chunk": chunk,
"num_prompts": len(selected),
"alpha_eligible_linear": (base.NUM_CHUNKS - 1 - chunk)
/ (base.NUM_CHUNKS - 2),
}
for field in numeric_fields:
row[field] = finite_average([item[field] for item in selected])
summary_rows.append(row)
summary_fields = [
"schedule",
"chunk",
"num_prompts",
"alpha_eligible_linear",
*numeric_fields,
]
write_csv(output_dir / "summary_by_schedule_chunk.csv", summary_rows, summary_fields)
correlation_rows: list[dict[str, Any]] = []
for schedule in (*available_schedules, "ALL"):
selected = (
records if schedule == "ALL" else [r for r in records if r["schedule"] == schedule]
)
for local in ("hidden_nrmse_mean", "flow_nrmse_mean", "x0_nrmse_mean"):
for downstream in ("tail_lpips", "latent_tail_nrmse", "tail_pixel_mse"):
pairs = [
(float(row[local]), float(row[downstream]))
for row in selected
if row[local] is not None
and row[downstream] is not None
and math.isfinite(float(row[local]))
and math.isfinite(float(row[downstream]))
]
correlation_rows.append(
{
"schedule": schedule,
"local_metric": local,
"downstream_metric": downstream,
"num_observations": len(pairs),
"spearman": (
spearman(
[pair[0] for pair in pairs],
[pair[1] for pair in pairs],
)
if len(pairs) >= 2
else float("nan")
),
}
)
correlation_fields = [
"schedule",
"local_metric",
"downstream_metric",
"num_observations",
"spearman",
]
write_csv(output_dir / "local_downstream_correlations.csv", correlation_rows, correlation_fields)
controlled_rows: list[dict[str, Any]] = []
for local in ("hidden_nrmse_mean", "flow_nrmse_mean", "x0_nrmse_mean"):
local_residual = group_centered_values(records, local)
downstream_residual = group_centered_values(records, "tail_lpips")
within_cell = []
for schedule in available_schedules:
for chunk in available_chunks:
selected = [
row
for row in records
if row["schedule"] == schedule and row["chunk"] == chunk
]
if len(selected) >= 2:
within_cell.append(
spearman(
[float(row[local]) for row in selected],
[float(row["tail_lpips"]) for row in selected],
)
)
controlled_rows.append(
{
"local_metric": local,
"downstream_metric": "tail_lpips",
"controls": "schedule+chunk",
"residual_spearman": spearman(local_residual, downstream_residual),
"mean_within_cell_spearman": finite_average(within_cell),
"positive_cells": sum(value > 0 for value in within_cell),
"num_cells": len(within_cell),
}
)
controlled_fields = [
"local_metric",
"downstream_metric",
"controls",
"residual_spearman",
"mean_within_cell_spearman",
"positive_cells",
"num_cells",
]
write_csv(
output_dir / "controlled_local_downstream_correlations.csv",
controlled_rows,
controlled_fields,
)
report = [
"# Layer-17 Predictor chunk-impact pilot",
"",
f"Prompts: {len(set(row['prompt_id'] for row in records))}; seed 0; "
"all non-intervened chunks use FFFF.",
"",
"A shadow Full call measures local error at each Predictor decision, but the "
"generated trajectory always consumes the Predictor output.",
"",
]
for schedule in available_schedules:
report.extend(
[
f"## {schedule}",
"",
"| Chunk | x0 nRMSE | Tail PSNR | Tail LPIPS | Tail latent nRMSE |",
"|---:|---:|---:|---:|---:|",
]
)
for row in summary_rows:
if row["schedule"] != schedule:
continue
report.append(
f"| {row['chunk']} | {row['x0_nrmse_mean']:.6f} | "
f"{row['tail_psnr']:.4f} | {row['tail_lpips']:.6f} | "
f"{row['latent_tail_nrmse']:.6f} |"
)
report.append("")
report.extend(
[
"## Local-to-downstream Spearman correlations",
"",
"| Schedule | Local metric | Downstream metric | Spearman |",
"|---|---|---|---:|",
]
)
for row in correlation_rows:
if row["downstream_metric"] == "tail_lpips":
report.append(
f"| {row['schedule']} | {row['local_metric']} | tail LPIPS | "
f"{row['spearman']:.4f} |"
)
report.extend(
[
"",
"## Correlations after controlling schedule and chunk",
"",
"Residual correlations remove each schedule-by-chunk mean, so they test "
"whether local error explains prompt/state risk beyond the position prior.",
"",
"| Local metric | Residual Spearman | Mean within-cell Spearman | Positive cells |",
"|---|---:|---:|---:|",
]
)
for row in controlled_rows:
report.append(
f"| {row['local_metric']} | {row['residual_spearman']:.4f} | "
f"{row['mean_within_cell_spearman']:.4f} | "
f"{row['positive_cells']}/{row['num_cells']} |"
)
(output_dir / "REPORT.md").write_text("\n".join(report) + "\n", encoding="utf-8")
def main() -> None:
args = parse_args()
for name in (
"config_path",
"checkpoint_path",
"dataset_root",
"sweep_dir",
"reference_root",
"output_dir",
):
setattr(args, name, resolve(getattr(args, name)))
args.output_dir.mkdir(parents=True, exist_ok=True)
prompt_ids = sorted(set(args.prompt_ids))
if args.max_prompts is not None:
prompt_ids = prompt_ids[: args.max_prompts]
config = OmegaConf.merge(
OmegaConf.load(REPO_ROOT / "configs/default_config.yaml"),
OmegaConf.load(args.config_path),
)
device = torch.device("cuda")
torch.set_grad_enabled(False)
set_seed(args.generation_seed)
manifest = {
"status": "running",
"gpu": str(args.gpu),
"prompt_ids": prompt_ids,
"generation_seed": args.generation_seed,
"checkpoint_path": str(args.checkpoint_path),
"predictor_weights": str(
args.sweep_dir / "teacher_layer_17" / "predictor_final.safetensors"
),
"dataset_root": str(args.dataset_root),
"schedules": list(args.schedules),
"chunks": list(args.chunks),
"shadow_full_target": True,
}
base.atomic_json(args.output_dir / "manifest.json", manifest)
print("[setup] loading VAE", flush=True)
vae = WanVAEWrapper().to(device=device, dtype=torch.bfloat16).eval()
missing_references = [
prompt_id
for prompt_id in prompt_ids
if not (
args.reference_root
/ "ffff_reference_frames"
/ f"prompt_{prompt_id:04d}.safetensors"
).exists()
]
if missing_references:
base.prepare_reference_frames(
vae=vae,
dataset_root=args.dataset_root,
output_dir=args.reference_root,
prompt_ids=missing_references,
device=device,
rebuild=False,
)
print("[setup] loading frozen generator and Layer-17 Predictor", flush=True)
pipeline = base.build_pipeline(config, args.checkpoint_path, vae, device)
experiment = base.discover_experiments(
args.sweep_dir, ["teacher_layer_17"], None
)[0]
predictor = base.load_predictor(pipeline.generator.model, experiment, device)
lpips_model = None
if not args.skip_lpips:
lpips_model = lpips.LPIPS(net="alex", verbose=False).to(device).eval()
lpips_model.requires_grad_(False)
records: list[dict[str, Any]] = []
total = len(prompt_ids) * len(args.schedules) * len(args.chunks)
completed = 0
for prompt_id in prompt_ids:
reference_latent = base.load_ffff_latent(args.dataset_root, prompt_id).to(
device=device, dtype=torch.bfloat16
)
reference_u8 = base.load_reference_frames(args.reference_root, prompt_id)
for schedule in args.schedules:
for chunk in args.chunks:
destination = (
args.output_dir
/ "per_intervention"
/ f"prompt_{prompt_id:04d}_{schedule}_chunk_{chunk:02d}.json"
)
if destination.exists() and not args.overwrite:
record = json.loads(destination.read_text(encoding="utf-8"))
records.append(record)
completed += 1
print(f"[cached] {completed}/{total} {destination.stem}", flush=True)
continue
started = time.perf_counter()
latent, diagnostics = generate_intervention(
pipeline=pipeline,
dataset_root=args.dataset_root,
prompt_id=prompt_id,
generation_seed=args.generation_seed,
device=device,
predictor=predictor,
source_layer=17,
intervention_chunk=chunk,
intervention_schedule=schedule,
)
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
pixels = vae.decode_to_pixel(latent, use_cache=False)
prediction_u8 = base.pixels_to_u8(pixels)
frame = base.frame_metrics(
reference_u8=reference_u8,
prediction_u8=prediction_u8,
lpips_model=lpips_model,
batch_size=args.metric_batch_size,
device=device,
)
current_slice = chunk_frame_slice(chunk)
current = summarize_frame_range(frame, current_slice)
tail = summarize_frame_range(frame, slice(current_slice.start, None))
latent_chunk_start = chunk * base.FRAMES_PER_CHUNK
latent_chunk_end = latent_chunk_start + base.FRAMES_PER_CHUNK
errors = diagnostics.pop("local_errors")
record = {
"prompt_id": prompt_id,
"schedule": schedule,
"chunk": chunk,
"predictor_steps": [int(item["step"]) for item in errors],
"hidden_nrmse_mean": average(
[float(item["hidden_nrmse"]) for item in errors]
),
"flow_nrmse_mean": average(
[float(item["flow_nrmse"]) for item in errors]
),
"x0_nrmse_mean": average(
[float(item["x0_nrmse"]) for item in errors]
),
"local_errors": errors,
"latent_all_nrmse": nrmse(latent, reference_latent),
"latent_current_nrmse": nrmse(
latent[:, latent_chunk_start:latent_chunk_end],
reference_latent[:, latent_chunk_start:latent_chunk_end],
),
"latent_tail_nrmse": nrmse(
latent[:, latent_chunk_start:],
reference_latent[:, latent_chunk_start:],
),
"psnr": frame["psnr"],
"ssim": frame["ssim"],
"lpips": frame["lpips"],
"current_pixel_mse": current["pixel_mse"],
"current_psnr": current["psnr"],
"current_ssim": current["ssim"],
"current_lpips": current["lpips"],
"tail_pixel_mse": tail["pixel_mse"],
"tail_psnr": tail["psnr"],
"tail_ssim": tail["ssim"],
"tail_lpips": tail["lpips"],
**diagnostics,
"total_time_s": time.perf_counter() - started,
}
base.atomic_json(destination, record)
records.append(record)
completed += 1
print(
f"[run] {completed}/{total} p={prompt_id} {schedule} c={chunk} "
f"x0={record['x0_nrmse_mean']:.5f} "
f"tail_lpips={record['tail_lpips']:.5f} "
f"time={record['total_time_s']:.1f}s",
flush=True,
)
if hasattr(vae.model, "clear_cache"):
vae.model.clear_cache()
del latent, pixels, prediction_u8, frame
torch.cuda.empty_cache()
del reference_latent, reference_u8
fields = sorted({key for record in records for key in record if key != "local_errors"})
flattened = [{key: row.get(key) for key in fields} for row in records]
write_csv(args.output_dir / "interventions.csv", flattened, fields)
aggregate(records, args.output_dir)
manifest["status"] = "complete"
base.atomic_json(args.output_dir / "manifest.json", manifest)
print(f"[complete] results -> {args.output_dir}", flush=True)
if __name__ == "__main__":
main()