Files
opensquilla--opensquilla/scripts/experiments/exp_quarantine.py
T
2026-07-13 13:12:33 +08:00

262 lines
9.2 KiB
Python

#!/usr/bin/env python3
"""Register and query quarantined (contaminated) runs in the experiment ledger.
A contamination class tags artifact batches whose results are confounded by an
infra defect (e.g. a provider-compaction marker leak). Registration is
idempotent: names merge into ``contaminations.json`` at the ledger root,
matching ledger run dirs get a ``contamination.json`` stamp, and an event is
appended to ``experiments.jsonl``. Baseline tooling and ``exp_status`` read the
registry to warn when a quarantined artifact backs a baseline.
Usage:
exp_quarantine.py register --contamination-class NAME --names-file F \
--description TEXT [--evidence PATH] [--boundary-commit C ...]
exp_quarantine.py check ARTIFACT [ARTIFACT ...]
exp_quarantine.py list
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
from exp_common import (
LedgerError,
append_jsonl,
artifact_basename,
atomic_write_json,
contamination_class_for,
contaminations_path,
ledger_lock,
ledger_root_from_env,
load_contaminations,
now_iso,
read_json,
)
CLASS_NAME_RE_HELP = "lowercase snake_case, e.g. tool_result_compaction_defect"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
register = sub.add_parser("register", help="register/merge a contamination class")
register.add_argument(
"--contamination-class",
required=True,
help=f"class name ({CLASS_NAME_RE_HELP})",
)
register.add_argument(
"--names-file",
required=True,
type=Path,
help="JSON file containing a list of artifact batch names",
)
register.add_argument("--description", required=True)
register.add_argument(
"--evidence",
default="",
help="path or URL of the audit/report that established the contamination",
)
register.add_argument(
"--boundary-commit",
action="append",
default=[],
help="fix-boundary commit (repeatable); runs at or before these are affected",
)
check = sub.add_parser("check", help="check artifact names/paths against the registry")
check.add_argument("artifacts", nargs="+")
sub.add_parser("list", help="summarize registered contamination classes")
return parser
def _load_names(path: Path) -> list[str]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise LedgerError(f"cannot read names file {path}: {exc}") from exc
if not isinstance(data, list) or not all(isinstance(item, str) for item in data):
raise LedgerError(f"names file must be a JSON list of strings: {path}")
# Normalize before filtering: a raw value like "/" or "///" is non-empty
# pre-normalization but collapses to "" via artifact_basename, and an
# empty name would substring-match (or exact-match) every run.
names = sorted(
{
normalized
for item in data
if item.strip()
for normalized in [artifact_basename(item)]
if normalized
}
)
if not names:
raise LedgerError(f"names file is empty: {path}")
return names
def _candidate_names_in_payload(payload: Any) -> set[str]:
"""Recursively collect basename-normalized string values from a JSON payload."""
names: set[str] = set()
if isinstance(payload, str):
normalized = artifact_basename(payload)
if normalized:
names.add(normalized)
elif isinstance(payload, dict):
for value in payload.values():
names.update(_candidate_names_in_payload(value))
elif isinstance(payload, list):
for item in payload:
names.update(_candidate_names_in_payload(item))
return names
def _stamp_run_dirs(root: Path, class_name: str, names: list[str]) -> list[str]:
"""Stamp ledger run dirs whose artifacts reference a quarantined batch name.
Matching is by exact basename, consistent with ``contamination_class_for``.
A prior substring-based check over the raw JSON text could both false-
positive (a quarantined name that is a substring of an unrelated, longer
name) and, worse, treat an empty name as a substring of everything.
"""
stamped: list[str] = []
runs_dir = root / "runs"
if not runs_dir.is_dir():
return stamped
name_set = set(names)
for run_dir in sorted(runs_dir.iterdir()):
if not run_dir.is_dir():
continue
matched: set[str] = set()
for source in ("artifacts.json", "manifest.json"):
payload = read_json(run_dir / source)
if not payload:
continue
matched.update(name_set & _candidate_names_in_payload(payload))
if not matched:
continue
stamp_path = run_dir / "contamination.json"
existing = read_json(stamp_path)
classes = existing.get("classes") if isinstance(existing.get("classes"), dict) else {}
previous = classes.get(class_name, {})
previous_names = (
set(previous.get("matched_artifact_names", []))
if isinstance(previous, dict)
else set()
)
classes[class_name] = {
"matched_artifact_names": sorted(previous_names | matched),
"stamped_at": (
previous.get("stamped_at")
if isinstance(previous, dict) and previous.get("stamped_at")
else now_iso()
),
}
atomic_write_json(stamp_path, {"classes": classes})
stamped.append(run_dir.name)
return stamped
def cmd_register(args: argparse.Namespace) -> int:
root = ledger_root_from_env()
class_name = str(args.contamination_class).strip()
if not class_name:
raise LedgerError("--contamination-class must be non-empty")
names = _load_names(args.names_file)
with ledger_lock(root):
data = load_contaminations(root)
classes = data["classes"]
entry = classes.get(class_name)
if not isinstance(entry, dict):
entry = {"registered_at": now_iso()}
existing_names = set(entry.get("artifact_names", []))
merged = sorted(existing_names | set(names))
new_names = sorted(set(names) - existing_names)
entry.update(
{
"description": str(args.description),
"evidence": str(args.evidence),
"boundary_commits": sorted({str(c) for c in args.boundary_commit}),
"artifact_names": merged,
"updated_at": now_iso(),
}
)
classes[class_name] = entry
data["updated_at"] = now_iso()
atomic_write_json(contaminations_path(root), data)
stamped = _stamp_run_dirs(root, class_name, merged)
append_jsonl(
root / "experiments.jsonl",
{
"event": "contamination_registered",
"contamination_class": class_name,
"artifact_names_total": len(merged),
"artifact_names_new": len(new_names),
"stamped_run_dirs": stamped,
"evidence": str(args.evidence),
"boundary_commits": sorted({str(c) for c in args.boundary_commit}),
"time": now_iso(),
},
)
print(
f"registered {class_name}: {len(merged)} artifact names "
f"({len(new_names)} new), stamped {len(stamped)} ledger run dirs"
)
for run_name in stamped:
print(f" stamped: runs/{run_name}/contamination.json")
return 0
def cmd_check(args: argparse.Namespace) -> int:
root = ledger_root_from_env()
contaminations = load_contaminations(root)
dirty = 0
for artifact in args.artifacts:
contamination_class = contamination_class_for(root, artifact, contaminations)
if contamination_class:
dirty += 1
print(f"QUARANTINED\t{artifact_basename(artifact)}\t{contamination_class}")
else:
print(f"clean\t{artifact_basename(artifact)}")
return 1 if dirty else 0
def cmd_list(_: argparse.Namespace) -> int:
root = ledger_root_from_env()
classes = load_contaminations(root).get("classes", {})
if not classes:
print("no contamination classes registered")
return 0
for name, payload in sorted(classes.items()):
if not isinstance(payload, dict):
continue
count = len(payload.get("artifact_names", []))
boundary = ",".join(payload.get("boundary_commits", [])) or "-"
print(f"{name}\truns={count}\tboundary={boundary}")
description = payload.get("description")
if description:
print(f" {description}")
return 0
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.command == "register":
return cmd_register(args)
if args.command == "check":
return cmd_check(args)
return cmd_list(args)
except LedgerError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())