openlatch-client 0.5.2

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
#!/usr/bin/env python3
"""Fail the build when the zone evaluator's per-event p99 leaves its budget.

`cargo bench` prints numbers nobody fails on. This is the mechanism that turns
one of them red.

The engine has a documented envelope — p50 66 µs / p99 183 µs at 2,000 atoms —
and two recorded ways to lose it that **no conformance row can see**, because
neither changes a verdict:

  * `(?-u)` flipping off the `regex_lite` set: a Unicode `\\b` regresses the p99
    fifteenfold (`benches/zone_eval.rs::regex_unicode_boundary`).
  * `keyword` folding losing its ASCII fast path, or moving from bundle load into
    the per-event path: +14.5% on an all-non-ASCII surface, measured
    (`src/zone_eval/tier1.rs::fold_keyword`).

Correctness is unchanged in both cases. Only cost moves — so the corpus stays
489/489 and this script is the only thing looking.

Three things it has to get right, each because the obvious implementation is
wrong:

  1. **It reads `sample.json`, never `estimates.json`.** criterion's
     `estimates.json` holds mean / median / slope / std-dev and **no percentiles
     at all**; a gate pointed at it cannot compute a p99 and would either crash
     or silently pass. `sample.json` carries `iters[]` and `times[]` — the
     per-iteration time of sample *i* is `times[i] / iters[i]`, and the p99 is
     the 99th percentile of that list.

  2. **Its paths match the bench invocation.** `--save-baseline ci` writes to
     `target/criterion/<group>/<id>/ci/`, not `<id>/new/`. `--baseline` here must
     be the name the workflow passes to cargo.

  3. **A report directory holding ZERO gated benchmarks exits NON-ZERO.** A gate
     that passes because it found nothing is worse than no gate, and this repo
     has been bitten by exactly that — see the `cargo test <filter>` guard
     comment in `.github/workflows/pr-checks.yml` and the same paragraph in
     `ci/check-engine-purity.py`. "I measured nothing" is the loudest failure
     here.

## Which groups are gated, and why not all of them

`benches/zone_eval.rs` separates the layers on purpose, because one threshold
cannot mean the same thing for all of them:

  * `zone_eval_load` — bundle load, paid once per bundle refresh. A 2,000-atom
    load is milliseconds of automaton construction and is *supposed* to be.
  * `zone_eval_scan` — the shared automata pass, once per event. **Gated.** This
    is the layer `fold_keyword`'s measured table was taken at and the layer a
    `(?-u)` flip lands on first.
  * `zone_eval_evaluate` — the whole per-event path over 2,000 mixed atoms.
    **Gated.** This is the number the PRD's 183 µs is comparable to.
  * `zone_eval_evaluate_wide` — the whole path over a 40-value surface. **Gated,
    at its own looser ceiling in a second invocation.** It costs ~7 ms, ~40x the
    per-event budget, spent in `tier1::leaf_attribute` calling `field_values`
    once per leaf; that bench's doc comment carries the probe that proves it is
    the value *count* and not the byte total. It is a reachable payload shape
    (`MultiEdit`, `TodoWrite`, any MCP tool taking a list), so leaving it as an
    info line would be an escape hatch. A ratchet cannot stop a cost that is
    already too high, but it stops it getting worse unnoticed.

So one invocation per budget: `--gate-group` selects the groups a given
`--p99-ms` applies to. Renaming a group in the bench without naming it in some
invocation trips the empty-gate guard below rather than passing quietly — which
is the point.

Falsify it by hand — do this after any edit below:

    cargo bench --bench zone_eval -- --save-baseline ci
    python3 ci/check-bench-gate.py --report target/criterion --p99-ms 1
    #   exit 0 — every gated benchmark inside budget
    python3 ci/check-bench-gate.py --report target/criterion --p99-ms 0.001
    #   exit 1 — a 1 µs budget fails everything
    mkdir -p /tmp/empty && python3 ci/check-bench-gate.py --report /tmp/empty --p99-ms 1
    #   exit 1 — no gated benchmark found, which is a failure and not a pass

Exit codes: 0 clean, 1 the gate failed, 2 bad invocation.
"""

from __future__ import annotations

import argparse
import json
import sys
from dataclasses import dataclass
from pathlib import Path

# The per-event groups in `benches/zone_eval.rs`. `zone_eval_load` is deliberately
# absent: see the module docstring.
DEFAULT_GATE_GROUPS = ("zone_eval_scan", "zone_eval_evaluate")

NS_PER_MS = 1_000_000.0
NS_PER_US = 1_000.0


@dataclass(frozen=True)
class Sample:
    """One benchmark's measured per-iteration times, in nanoseconds."""

    group: str
    bench_id: str
    times_ns: list[float]

    @property
    def name(self) -> str:
        return f"{self.group}/{self.bench_id}"

    def percentile(self, pct: float) -> float:
        """The `pct`th percentile, nearest-rank.

        Nearest-rank rather than an interpolating estimator on purpose: a gate
        must be reproducible from the raw file by hand, and with criterion's
        default 100 samples an interpolated p99 differs from the ranked one by
        less than the run-to-run noise it would be hiding.
        """
        ordered = sorted(self.times_ns)
        rank = max(1, min(len(ordered), -(-int(pct * len(ordered)) // 100)))
        return ordered[rank - 1]


def read_samples(report: Path, baseline: str) -> list[Sample]:
    """Every `<group>/<id>/<baseline>/sample.json` under `report`.

    criterion also writes a `report/` tree and a `*/base/` directory for
    comparisons; globbing on the baseline name is what keeps those out without a
    denylist that would rot.
    """
    found: list[Sample] = []
    for path in sorted(report.glob(f"*/*/{baseline}/sample.json")):
        group = path.parent.parent.parent.name
        bench_id = path.parent.parent.name
        try:
            doc = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError) as exc:
            print(f"error: {path} is unreadable: {exc}", file=sys.stderr)
            raise SystemExit(2) from exc

        iters = doc.get("iters")
        times = doc.get("times")
        if not isinstance(iters, list) or not isinstance(times, list) or not iters:
            # An `estimates.json` shape reaching here means the glob was pointed
            # at the wrong file; say so rather than computing a p99 of nothing.
            print(
                f"error: {path} carries no iters[]/times[] arrays — "
                "this gate reads sample.json, not estimates.json",
                file=sys.stderr,
            )
            raise SystemExit(2)
        if len(iters) != len(times):
            print(
                f"error: {path} has {len(iters)} iters and {len(times)} times",
                file=sys.stderr,
            )
            raise SystemExit(2)

        # Per-iteration time. criterion times a BATCH of `iters[i]` iterations
        # per sample, so `times[i]` alone is not a per-event number.
        per_iter = [t / i for t, i in zip(times, iters) if i]
        if not per_iter:
            print(f"error: {path} holds no usable samples", file=sys.stderr)
            raise SystemExit(2)
        found.append(Sample(group=group, bench_id=bench_id, times_ns=per_iter))
    return found


def main() -> int:
    # Windows gives stdout the ANSI code page (cp1252) whenever it is a pipe —
    # CI, `| head`. Every timing line below is printed in µs, so without this
    # the script raises UnicodeEncodeError instead of reporting the benchmark.
    for stream in (sys.stdout, sys.stderr):
        reconfigure = getattr(stream, "reconfigure", None)
        if reconfigure is not None:
            reconfigure(encoding="utf-8", errors="replace")

    parser = argparse.ArgumentParser(
        description="Fail the build when a gated benchmark's p99 leaves its budget.",
    )
    parser.add_argument(
        "--report",
        type=Path,
        required=True,
        help="criterion's report directory (target/criterion)",
    )
    parser.add_argument(
        "--p99-ms",
        type=float,
        required=True,
        help="the p99 budget, in milliseconds; a benchmark at or above it fails",
    )
    parser.add_argument(
        "--baseline",
        default="ci",
        help="the baseline name passed to `cargo bench -- --save-baseline` (default: ci)",
    )
    parser.add_argument(
        "--gate-group",
        action="append",
        default=None,
        metavar="GROUP",
        help=(
            "a criterion group to hold to the budget; repeatable. "
            f"Default: {', '.join(DEFAULT_GATE_GROUPS)}"
        ),
    )
    args = parser.parse_args()

    if args.p99_ms <= 0:
        print("error: --p99-ms must be positive", file=sys.stderr)
        return 2
    if not args.report.is_dir():
        print(f"error: --report {args.report} is not a directory", file=sys.stderr)
        return 2

    gated_groups = tuple(args.gate_group) if args.gate_group else DEFAULT_GATE_GROUPS
    budget_ns = args.p99_ms * NS_PER_MS

    samples = read_samples(args.report, args.baseline)
    gated = [s for s in samples if s.group in gated_groups]
    ungated = [s for s in samples if s.group not in gated_groups]

    print(f"Bench gate — p99 budget {args.p99_ms} ms, baseline '{args.baseline}'")
    print(f"Report: {args.report}")
    print()

    failures: list[tuple[Sample, float]] = []
    if gated:
        print("Gated (per-event):")
        for sample in sorted(gated, key=lambda s: s.name):
            p99 = sample.percentile(99)
            p50 = sample.percentile(50)
            over = p99 >= budget_ns
            mark = "FAIL" if over else "ok"
            print(
                f"  [{mark:>4}] {sample.name}: "
                f"p50 {p50 / NS_PER_US:.2f} µs, p99 {p99 / NS_PER_US:.2f} µs "
                f"({len(sample.times_ns)} samples)"
            )
            if over:
                failures.append((sample, p99))
        print()

    if ungated:
        print("Reported only (not gated — see the module docstring):")
        for sample in sorted(ungated, key=lambda s: s.name):
            p99 = sample.percentile(99)
            p50 = sample.percentile(50)
            print(
                f"  [info] {sample.name}: "
                f"p50 {p50 / NS_PER_MS:.3f} ms, p99 {p99 / NS_PER_MS:.3f} ms"
            )
        print()

    # THE GUARD. A gate that passes because it matched nothing is worse than no
    # gate: it reports green on a PR that never ran a benchmark, which is exactly
    # how a filter that named no files reached main twice in this repo.
    if not gated:
        print(
            "error: no gated benchmark found under "
            f"{args.report}/*/*/{args.baseline}/sample.json",
            file=sys.stderr,
        )
        print(
            "       Looked for group(s): " + ", ".join(gated_groups),
            file=sys.stderr,
        )
        if samples:
            seen = ", ".join(sorted({s.group for s in samples}))
            print(f"       Groups present: {seen}", file=sys.stderr)
        else:
            print("       No sample.json at all — did `cargo bench` run?", file=sys.stderr)
        print(
            "       A gate that measures nothing is a failure, never a pass.",
            file=sys.stderr,
        )
        return 1

    if failures:
        print(
            f"error: {len(failures)} benchmark(s) at or above the "
            f"{args.p99_ms} ms p99 budget:",
            file=sys.stderr,
        )
        for sample, p99 in failures:
            print(
                f"       {sample.name}: p99 {p99 / NS_PER_US:.2f} µs "
                f">= {budget_ns / NS_PER_US:.2f} µs",
                file=sys.stderr,
            )
        return 1

    print(f"All {len(gated)} gated benchmark(s) inside the {args.p99_ms} ms p99 budget.")
    return 0


if __name__ == "__main__":
    sys.exit(main())