Files
cs249r_book/book/tools/audit/loop.py
Vijay Janapa Reddi 5b6138b697 pass 15 phase A: audit-fix-verify infrastructure
Adds book/tools/audit/ — a five-stage editorial audit pipeline
(SCAN -> PLAN -> FIX -> VERIFY -> REPORT) for the MIT Press round 1
style rules in book-prose-merged.md. Implements Phase A of the plan
at /Users/VJ/Desktop/MIT_Press_Feedback/15_audit_loop/PLAN.md.

Architecture. Five-stage cycle with hard stopping conditions and
verification gating. Five safety checks before any file is written:
no null bytes, no leftover sentinels, expected byte delta matches
actual, quarto structural delta is zero, no new issues introduced.
The byte-delta check alone would have caught the discarded bulk-edit
run that prompted Pass 15.

Files. protected_contexts.py is the load-bearing safety layer with
LineWalker and inline_protected_spans (14 adversarial tests covering
every failure mode from the discarded bulk run). ledger.py is the
JSON schema. scan.py, fix_script_lane.py, verify.py, loop.py are the
stage runners. Seven check modules cover vs_period, compound_prefix,
percent_symbol, lowercase_prose_references, acknowledgements_spelling,
binary_units, and h3_titlecase.

Validation. h3_titlecase finds 609 in vol1 (within 2 of plan's
expected 611). vs_period finds 0 in vol1 (matching pass 10b) and 16
in vol2 (real residuals). compound_prefix finds 19 in vol1 (real
misses pass 10b's regex did not catch) and 46 in vol2 (never swept).
2026-04-07 14:38:00 -04:00

435 lines
14 KiB
Python

#!/usr/bin/env python3
"""Pass 15 orchestrator — the five-stage cycle.
Runs SCAN -> PLAN -> FIX -> VERIFY -> REPORT. Stops when any of the
five conditions in Pass 15 plan section 2.5 is met:
1. Zero open issues remaining in active categories -> exit success
2. No progress in last iteration -> exit "stuck", print surviving
3. Verification failure -> exit "verification failed", do not retry
4. Time budget exceeded -> exit "budget exceeded"
5. Category-level abort (> 5 verification failures in one run) ->
disable that category, continue others
Usage:
# Scout mode - scan only, no fixes
python3 book/tools/audit/loop.py --scope vol2 --dry-run
# Fix + verify, one category at a time
python3 book/tools/audit/loop.py --scope vol2 \\
--categories vs-period --apply --verbose
# Fix + verify + commit, multiple categories
python3 book/tools/audit/loop.py --scope vol2 \\
--categories vs-period,compound-prefix-closeup \\
--apply --commit-each-iteration --verbose
Phase B (subagent dispatch) adds a separate lane but uses the same cycle.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
import time
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from audit.fix_script_lane import fix_all as script_lane_fix_all
from audit.ledger import (
Issue,
Ledger,
STATUS_FAILED_SCRIPT,
STATUS_FAILED_VERIFY,
STATUS_FIXED_SCRIPT,
STATUS_FIXED_SUBAGENT,
STATUS_OPEN,
STATUS_VERIFIED,
)
from audit.scan import scan, print_summary, REPO_ROOT
from audit.verify import verify
# ── Stage runners ───────────────────────────────────────────────────────────
def stage_scan(scope: str, categories: list[str] | None, verbose: bool) -> Ledger:
"""Run the SCAN stage."""
if verbose:
print(f"\n{'=' * 60}", file=sys.stderr)
print(f"[1/5] SCAN scope={scope}", file=sys.stderr)
print(f"{'=' * 60}", file=sys.stderr)
return scan(scope, categories, verbose=verbose)
def stage_plan(
ledger: Ledger, verbose: bool
) -> dict[str, list[Issue]]:
"""Run the PLAN stage.
Group open issues by lane. Returns a dict:
{
'script': [...open auto-fixable issues...],
'subagent': [...open needs-subagent issues...],
'accepted': [...open protected-context issues...],
}
"""
if verbose:
print(f"\n{'=' * 60}", file=sys.stderr)
print(f"[2/5] PLAN", file=sys.stderr)
print(f"{'=' * 60}", file=sys.stderr)
lanes: dict[str, list[Issue]] = {
"script": [],
"subagent": [],
"accepted": [],
}
for issue in ledger.issues:
if issue.status != STATUS_OPEN:
continue
if issue.protected_context:
lanes["accepted"].append(issue)
elif issue.auto_fixable:
lanes["script"].append(issue)
elif issue.needs_subagent:
lanes["subagent"].append(issue)
else:
# Detection-only, e.g. binary-units-in-prose
lanes["accepted"].append(issue)
if verbose:
for lane, issues in lanes.items():
print(f" {lane:10s} {len(issues):6d} issues", file=sys.stderr)
return lanes
def stage_fix(
ledger: Ledger,
lanes: dict[str, list[Issue]],
categories: list[str] | None,
dry_run: bool,
verbose: bool,
) -> tuple[int, list[Path]]:
"""Run the FIX stage (script lane only in Phase A).
Returns (applied_count, list_of_changed_files).
"""
if verbose:
print(f"\n{'=' * 60}", file=sys.stderr)
print(
f"[3/5] FIX {'(dry run)' if dry_run else '(LIVE)'}",
file=sys.stderr,
)
print(f"{'=' * 60}", file=sys.stderr)
# Script lane
script_results = script_lane_fix_all(
ledger,
categories=categories,
dry_run=dry_run,
verbose=verbose,
)
# Collect changed files (those with successful fixes)
changed: set[Path] = set()
applied = 0
for r in script_results:
if r.applied > 0:
changed.add(Path(r.file))
applied += r.applied
return applied, sorted(changed)
def stage_verify(
ledger: Ledger,
changed_files: list[Path],
dry_run: bool,
run_quarto: bool,
verbose: bool,
) -> bool:
"""Run the VERIFY stage. Returns True if all checks pass."""
if verbose:
print(f"\n{'=' * 60}", file=sys.stderr)
print(f"[4/5] VERIFY", file=sys.stderr)
print(f"{'=' * 60}", file=sys.stderr)
if dry_run:
if verbose:
print(" Skipping verification in dry-run mode", file=sys.stderr)
return True
if not changed_files:
if verbose:
print(" No changed files to verify", file=sys.stderr)
return True
ok, errors = verify(ledger, changed_files, run_quarto, verbose)
if not ok:
print(f"\nVerification failed with {len(errors)} error(s):",
file=sys.stderr)
for e in errors[:10]:
print(f" - {e}", file=sys.stderr)
# Roll back fixed issues in the ledger (mark failed-verification).
# We do NOT roll back file contents — the operator must do that
# manually with `git checkout --`. That is safer than automated
# rollback because it gives the human a chance to inspect.
for issue in ledger.issues:
if issue.status == STATUS_FIXED_SCRIPT:
issue.status = STATUS_FAILED_VERIFY
issue.error = "verification stage rejected"
else:
# Promote to verified
for issue in ledger.issues:
if issue.status in (STATUS_FIXED_SCRIPT, STATUS_FIXED_SUBAGENT):
issue.status = STATUS_VERIFIED
return ok
def stage_report(
ledger: Ledger,
applied: int,
iteration: int,
verbose: bool,
) -> None:
"""Run the REPORT stage."""
if verbose:
print(f"\n{'=' * 60}", file=sys.stderr)
print(f"[5/5] REPORT (iteration {iteration})", file=sys.stderr)
print(f"{'=' * 60}", file=sys.stderr)
print_summary(ledger)
print(f"\nIteration {iteration}: {applied} fixes applied and verified",
file=sys.stderr)
def stage_commit(
changed_files: list[Path],
message: str,
verbose: bool,
) -> bool:
"""Create a git commit for the changed files."""
if not changed_files:
return True
rel_files = []
for f in changed_files:
try:
rel_files.append(str(f.relative_to(REPO_ROOT)))
except ValueError:
rel_files.append(str(f))
try:
subprocess.run(
["git", "add", "--"] + rel_files,
cwd=REPO_ROOT,
check=True,
capture_output=True,
text=True,
)
result = subprocess.run(
["git", "commit", "-m", message],
cwd=REPO_ROOT,
capture_output=True,
text=True,
)
if result.returncode != 0:
print(
f"Commit failed: {result.stdout}\n{result.stderr}",
file=sys.stderr,
)
return False
if verbose:
print(f"Committed: {message}", file=sys.stderr)
return True
except subprocess.CalledProcessError as e:
print(f"git command failed: {e}", file=sys.stderr)
return False
# ── Orchestrator ────────────────────────────────────────────────────────────
def run_loop(args: argparse.Namespace) -> int:
"""Run the full five-stage cycle with stopping conditions."""
start_time = time.time()
max_wall_seconds = args.time_budget_min * 60
categories = (
[c.strip() for c in args.categories.split(",") if c.strip()]
if args.categories
else None
)
last_open_count = -1
iteration = 0
total_applied = 0
while iteration < args.max_iterations:
iteration += 1
# Check time budget
elapsed = time.time() - start_time
if elapsed > max_wall_seconds:
print(
f"\n[STOP] Time budget exceeded: {elapsed:.0f}s > "
f"{max_wall_seconds}s",
file=sys.stderr,
)
return 4
# Stage 1: SCAN
ledger = stage_scan(args.scope, categories, args.verbose)
ledger_path = args.ledger or Path("audit-ledger.json")
ledger.save(ledger_path)
# Stage 2: PLAN
lanes = stage_plan(ledger, args.verbose)
open_count = len(lanes["script"]) + len(lanes["subagent"])
# Stopping condition 1: zero open issues
if open_count == 0:
print(
f"\n[STOP] Zero open issues across active categories",
file=sys.stderr,
)
stage_report(ledger, total_applied, iteration, args.verbose)
return 0
# Stopping condition 2: no progress
if open_count == last_open_count:
print(
f"\n[STOP] No progress in iteration {iteration} "
f"(open count stuck at {open_count})",
file=sys.stderr,
)
stage_report(ledger, total_applied, iteration, args.verbose)
return 2
last_open_count = open_count
if args.dry_run:
stage_report(ledger, 0, iteration, args.verbose)
print("\n[DRY RUN] Exiting after first scan+plan", file=sys.stderr)
return 0
# Stage 3: FIX (script lane only in Phase A)
applied, changed = stage_fix(
ledger, lanes, categories, args.dry_run, args.verbose
)
total_applied += applied
ledger.save(ledger_path)
if applied == 0:
# Nothing to fix in script lane; if no subagents configured,
# we're done with what the orchestrator can do alone.
if not lanes["subagent"]:
print(
f"\n[STOP] No auto-fixable issues remaining "
f"(subagent lane has {len(lanes['subagent'])} pending)",
file=sys.stderr,
)
stage_report(ledger, total_applied, iteration, args.verbose)
return 0
# Stage 4: VERIFY
ok = stage_verify(
ledger, changed, args.dry_run, args.quarto_check, args.verbose
)
ledger.save(ledger_path)
if not ok:
print(
f"\n[STOP] Verification failed in iteration {iteration}. "
f"The orchestrator does NOT retry failed verification — "
f"inspect the changed files manually with `git diff` and "
f"either commit or roll back.",
file=sys.stderr,
)
return 3
# Stage 5: REPORT
stage_report(ledger, applied, iteration, args.verbose)
# Commit if requested
if args.commit_each_iteration and changed:
if categories:
cat_summary = ",".join(categories)
else:
cat_summary = "all"
commit_msg = (
f"pass 15 audit iter {iteration}: "
f"apply {applied} {cat_summary} fixes to {len(changed)} files"
)
if not stage_commit(changed, commit_msg, args.verbose):
print(
"\n[STOP] Commit failed. The working tree has staged changes.",
file=sys.stderr,
)
return 5
print(
f"\n[STOP] Max iterations ({args.max_iterations}) reached",
file=sys.stderr,
)
return 4
# ── CLI ─────────────────────────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(
description="Pass 15 audit loop orchestrator",
)
parser.add_argument(
"--scope", required=True,
help="vol1 | vol2 | both | path",
)
parser.add_argument(
"--categories", default="",
help="Comma-separated category names (default: all)",
)
parser.add_argument(
"--ledger", type=Path, default=None,
help="Path to the ledger JSON (default: audit-ledger.json)",
)
mutex = parser.add_mutually_exclusive_group(required=True)
mutex.add_argument(
"--dry-run", action="store_true",
help="Run scan + plan only; no fixes, no verify, no commit",
)
mutex.add_argument(
"--apply", action="store_true",
help="Actually apply the changes",
)
parser.add_argument(
"--commit-each-iteration", action="store_true",
help="Create a git commit after each successful iteration",
)
parser.add_argument(
"--quarto-check", action="store_true",
help="Also run `quarto check` in the verify stage (expensive)",
)
parser.add_argument(
"--max-iterations", type=int, default=10,
help="Maximum number of iterations (default: 10)",
)
parser.add_argument(
"--time-budget-min", type=int, default=30,
help="Wall-clock time budget in minutes (default: 30)",
)
parser.add_argument(
"--verbose", "-v", action="store_true",
help="Print per-stage progress",
)
args = parser.parse_args()
return run_loop(args)
if __name__ == "__main__":
sys.exit(main())