diff --git a/src/pipeline/gates.py b/src/pipeline/gates.py index ddd894e3..a7bd8062 100644 --- a/src/pipeline/gates.py +++ b/src/pipeline/gates.py @@ -225,11 +225,44 @@ def check(self, recipe: RecipeSpec, ctx: GateContext) -> GateResult: return GateResult("regression", True, "at parity with registry") + + +class BudgetGate(BaseGate): + """C3: declared performance budgets vs actual index values. + + The index entry may declare perf budgets (size_mb today; latency + budgets ride the fixture decode in CI). A declared budget that the + actual artifact violates blocks the gate — regressions are release + failures, not notes.""" + + name = "budgets" + + def check(self, recipe: RecipeSpec, ctx: GateContext) -> GateResult: + entry = (ctx.extra or {}).get("index_entry") or {} + perf = entry.get("perf") or {} + size_mb = perf.get("size_mb") + if size_mb is None: + return GateResult(self.name, True, "no perf budgets declared") + size = entry.get("size") + if size is None: + return GateResult(self.name, False, + "perf.size_mb declared but index has no size") + actual_mb = size / 1_000_000 + if actual_mb > float(size_mb): + return GateResult(self.name, False, + f"artifact {actual_mb:.1f} MB exceeds the " + f"declared {float(size_mb):.1f} MB budget") + return GateResult(self.name, True, + f"{actual_mb:.1f} MB within the " + f"{float(size_mb):.1f} MB budget") + + register_gate(QualityGate()) register_gate(ParityGate()) register_gate(LicenseGate()) register_gate(ConformanceGate()) register_gate(RegressionGate()) +register_gate(BudgetGate()) def evaluate( diff --git a/tests/test_pipeline_plan_gate.py b/tests/test_pipeline_plan_gate.py index a4c702aa..13163f5a 100644 --- a/tests/test_pipeline_plan_gate.py +++ b/tests/test_pipeline_plan_gate.py @@ -157,6 +157,7 @@ def test_gate_results_files_end_to_end(tmp_path: Path) -> None: "license": True, "conformance": False, "regression": True, + "budgets": True, } diff --git a/tests/test_pipeline_registry.py b/tests/test_pipeline_registry.py index 8c0ff945..5ca84445 100644 --- a/tests/test_pipeline_registry.py +++ b/tests/test_pipeline_registry.py @@ -158,3 +158,20 @@ def test_license_policy_is_encapsulated() -> None: }) strict = type(DEFAULT_LICENSE_POLICY)(allow_waivers=False) assert strict.blocked(restricted), "policies vary without core edits" + + +def test_budget_gate_enforces_declared_size() -> None: + from pipeline.gates import BudgetGate, GateContext + + gate = BudgetGate() + over = gate.check(None, GateContext(extra={"index_entry": { + "size": 100_000_000, "perf": {"size_mb": 50}}})) + assert not over.passed + + ok = gate.check(None, GateContext(extra={"index_entry": { + "size": 40_000_000, "perf": {"size_mb": 50}}})) + assert ok.passed + + no_budget = gate.check(None, GateContext(extra={"index_entry": { + "size": 1}})) + assert no_budget.passed # nothing declared -> nothing to enforce