Setting Release Thresholds for Spatial Agents

Derive gate values from observed behaviour rather than round numbers, gate on a percentile rather than a mean, and make overrides visible instead of inevitable.

A release gate needs a number, and the number is usually chosen by someone saying “let’s require ninety per cent”. That figure has no relationship to the system’s actual behaviour, so it either blocks every release or none, and within two months it has been removed. This guide derives thresholds from measured history instead, as the decision layer over evaluation and benchmarking for spatial LLMs.

When to Use This Approach

Set thresholds once you have enough history to see the noise floor — five or more sweeps of a stable system. Before that, publish the numbers without gating on them; a gate calibrated against two data points measures the two data points.

Situation Gate on Why
Stable system, several sweeps of history Tenth percentile, just below baseline Catches regressions without blocking noise
New system, little history Nothing — publish only Any threshold is arbitrary
A metric that is bimodal A percentile, never the mean The mean sits between the two modes
A safety-relevant metric An absolute floor, plus the percentile Some behaviour is unacceptable at any baseline
A metric that has never failed Review whether it measures anything A gate that cannot fire is documentation

The fourth row is the exception to the whole approach. Most thresholds should be relative to observed behaviour, and a small number — an unfounded-answer rate, a refusal-path failure — deserve an absolute floor that does not move when the baseline drifts.

Setting a gate relative to the observed noise floorSeveral sweeps of a stable system vary within a band; a gate set just below that band catches a genuine regression while allowing normal variation through.Six sweeps of an unchanged system, then one regressiongateThe gate sits below the noise band and above anything that would be a real regression
The band, not the target, sets the gate. A threshold above the observed variation fires on every release; one far below it never fires at all. Both are removed within a quarter.

Implementation

The proposal function reads history, checks that the history is stable enough to derive from, and returns a threshold with the evidence behind it.

import logging
import statistics
from dataclasses import dataclass
from typing import Sequence

log = logging.getLogger("release_thresholds")

MIN_HISTORY = 5


@dataclass(frozen=True)
class Threshold:
    metric: str
    value: float
    baseline: float
    spread: float
    basis: str


def propose(metric: str, history: Sequence[float],
            allowed_regression: float = 0.02,
            absolute_floor: float | None = None) -> Threshold | None:
    """Derive a gate from observed behaviour. Returns None when history is too thin."""
    values = [v for v in history if v is not None]
    if len(values) < MIN_HISTORY:
        log.info("%s: only %d sweep(s) of history — publishing without a gate",
                 metric, len(values))
        return None

    baseline = statistics.median(values)
    spread = statistics.pstdev(values) if len(values) > 1 else 0.0
    # Sit below both the baseline-minus-allowance and the observed noise band.
    candidate = min(baseline - allowed_regression, baseline - 2 * spread)
    if absolute_floor is not None:
        candidate = max(candidate, absolute_floor)
    value = round(max(0.0, candidate), 4)

    basis = (f"median {baseline:.4f} over {len(values)} sweep(s), "
             f"spread {spread:.4f}, allowance {allowed_regression}")
    if absolute_floor is not None and value == absolute_floor:
        basis += f"; raised to the absolute floor {absolute_floor}"
    return Threshold(metric, value, baseline, spread, basis)

Taking the minimum of two candidates is what makes the gate robust. An allowance alone ignores how noisy the metric is, so a metric that varies by five points between identical sweeps will trip a two-point allowance constantly; two standard deviations alone can produce an absurdly low gate for a very stable metric. The minimum respects both.

The gate itself needs an override path, and the override needs to be recorded rather than merely possible.

@dataclass(frozen=True)
class GateResult:
    passed: bool
    failures: tuple[str, ...]
    overridden: bool
    reason: str


def evaluate(report: dict, thresholds: dict[str, Threshold],
             override_reason: str | None = None) -> GateResult:
    """Apply thresholds to a report. An override is allowed and always recorded."""
    failures = tuple(
        f"{name}: {report[name]['p10']:.4f} below {t.value:.4f}"
        for name, t in thresholds.items()
        if name in report and report[name]["p10"] < t.value
    )
    if not failures:
        return GateResult(True, (), False, "")
    if override_reason:
        log.warning("release gate overridden (%s): %s", override_reason, "; ".join(failures))
        return GateResult(True, failures, True, override_reason)
    log.error("release blocked: %s", "; ".join(failures))
    return GateResult(False, failures, False, "")

Allowing the override is not a weakness. A gate with no override will be deleted the first time it blocks an urgent security fix, and its absence afterwards is permanent; an override that requires a reason and appears in the release record keeps the gate alive and turns each bypass into a decision somebody signed.

Why the gate reads a percentile rather than a meanA bimodal score distribution moves its failing tail while its mean barely changes, so a mean-based gate misses a regression that a tenth-percentile gate catches immediately.before10%90% near 1.0after22%78% near 1.0The mean moved by 0.11; the tenth percentile moved from 0.9 to 0.0
The failing tail doubled and the mean barely noticed. Spatial scores are bimodal — answers are close or badly wrong — so the statistic that tracks the failing population is the one a gate should read.

Validation & Testing

def test_thin_history_yields_no_gate():
    assert propose("placement", [0.9, 0.91, 0.89]) is None


def test_noisy_metric_gets_a_lower_gate_than_a_stable_one():
    stable = propose("a", [0.90, 0.90, 0.91, 0.90, 0.90])
    noisy = propose("b", [0.90, 0.82, 0.95, 0.86, 0.93])
    assert noisy.value < stable.value


def test_absolute_floor_wins_when_it_is_higher():
    t = propose("unfounded_answers", [0.2] * 6, absolute_floor=0.5)
    assert t.value == 0.5 and "absolute floor" in t.basis


def test_override_is_recorded_not_silent(caplog):
    result = evaluate(FAILING_REPORT, THRESHOLDS, override_reason="hotfix for outage")
    assert result.passed and result.overridden and result.failures
    assert any("overridden" in r.message for r in caplog.records)

The fourth test is the one that keeps the override honest. An override implementation that returns passed=True and discards the failures is indistinguishable, from the caller’s perspective, from a genuine pass — and a release record that cannot show which releases bypassed the gate cannot answer the only question anyone asks about gates afterwards.

Store the derived thresholds as data rather than recomputing them on every run. A gate whose value is recalculated from recent history at evaluation time will drift downward automatically as behaviour degrades, which is the opposite of what a gate is for.

Gotchas & Edge Cases

A threshold derived from a period that included a regression. The baseline absorbs the bad sweeps and the gate is set below the degraded behaviour, permanently. Exclude known-bad periods explicitly, and record which sweeps contributed.

Gating on a metric with no history because it is new. New metrics should publish for several sweeps before gating; a gate on a metric nobody has watched will fire on its own noise and be blamed on the release.

One allowance applied to every metric. Two points of regression is generous for a stable metric and meaningless for a noisy one. Derive per metric, from that metric’s own spread.

A gate that only ever moves downwardEach recalibration after an override lowers the threshold slightly, so over a year the gate falls below anything that could realistically fail and stops protecting anything.Q1Q2Q3Q4Q5Nothing raised it, so by the fifth quarter it cannot fire
A ratchet in one direction is not a gate. Every individual lowering was justified by a specific incident; the cumulative effect was never decided by anyone, which is what makes it worth reviewing on a schedule.

Thresholds that only ever go down. Each override or recalibration lowers the bar slightly and nothing raises it, so after a year the gate is below anything that could fail. Recalibrate upward too when the baseline improves, and record when you last did.

A gate on an aggregate rather than a family. A blended score can pass while one family regresses badly. Gate per family and per region, which is also what makes a failure actionable.

Overrides that become routine. A gate overridden every release is not protecting anything; it is generating a ritual. Review the override log periodically and either fix the underlying regression or retire the gate deliberately.

Frequently Asked Questions

How much history is enough?

Five sweeps is the practical minimum for a spread estimate that means anything, and ten is comfortable. What matters more than the count is that the sweeps come from a period when nothing changed — five sweeps spanning two model upgrades measure the upgrades, not the noise. If the system changes faster than history accumulates, that is itself worth knowing, and the honest response is to publish without gating until it settles.

Should the gate block or warn?

Block, with the override available. A warning gate is read for two weeks and ignored thereafter, which is worse than no gate because it creates the impression of protection. The override is what makes blocking tolerable, and requiring a written reason is what keeps the override from becoming reflexive.

What about metrics where higher is worse?

Invert them at the source rather than special-casing the threshold logic. A rate of unfounded answers is naturally "lower is better", and carrying two conventions through a gate implementation is exactly the kind of thing that produces a gate wired backwards — which passes every release and is discovered months later.

Should thresholds differ per region?

Yes, where the system genuinely performs differently across regions and both are in scope. A single threshold set from a mixed report is dominated by the region with the most cases, and it will let a regression in a smaller region through. Per-region thresholds are more work to maintain and are the only way the smaller region gets protected at all.

Who should own the thresholds?

Whoever owns the consequence of the metric being wrong, which is rarely the person who wrote the harness. A placement threshold belongs to whoever answers for a wrong footprint; a refusal threshold belongs to whoever answers for an unfounded claim. Naming an owner per threshold sounds bureaucratic and is the thing that keeps a gate from being quietly lowered by whoever is closest to the release.

How should a threshold change be recorded?

As a change to a versioned file, with the evidence in the commit: the sweeps it was derived from, the spread, and why the previous value no longer applies. A threshold that lives in a dashboard configuration can be edited by anyone in seconds and leaves no trace, which is how gates drift downward without anybody deciding to lower them.