diff --git a/README.md b/README.md index 9f8d3ac1..5fbe0d26 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-21) — Service-Level Objectives (SLO)](#whats-new-2026-06-21--service-level-objectives-slo) - [What's new (2026-06-21) — Chaos Experiments](#whats-new-2026-06-21--chaos-experiments) - [What's new (2026-06-21) — JSON Contract & Snapshot Matching](#whats-new-2026-06-21--json-contract--snapshot-matching) - [What's new (2026-06-21) — SLSA Build Provenance](#whats-new-2026-06-21--slsa-build-provenance) @@ -124,6 +125,12 @@ --- +## What's new (2026-06-21) — Service-Level Objectives (SLO) + +SLI, error budget and burn-rate alerts. Full reference: [`docs/source/Eng/doc/new_features/v72_features_doc.rst`](docs/source/Eng/doc/new_features/v72_features_doc.rst). + +- **`evaluate_slo` / `burn_rate` / `burn_alerts` / `default_burn_rules`** (`AC_evaluate_slo`, `AC_burn_alerts`): the framework emitted raw signals but had no SLO layer. This computes the SLI over outcome records (`[{timestamp, ok}]`), the error budget against a target, and the **multi-window multi-burn-rate** alerts from the Google SRE workbook (page 14.4×@1h, 6×@6h; ticket 1×@3d — firing only when both windows exceed the threshold). Records are plain data, clock injectable, fully deterministic. Pure-stdlib. + ## What's new (2026-06-21) — Chaos Experiments Inject faults, verify the system holds. Full reference: [`docs/source/Eng/doc/new_features/v71_features_doc.rst`](docs/source/Eng/doc/new_features/v71_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 719c86c8..22ab5fc1 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-21) — 服务等级目标(SLO)](#本次更新-2026-06-21--服务等级目标slo) - [本次更新 (2026-06-21) — 混沌实验](#本次更新-2026-06-21--混沌实验) - [本次更新 (2026-06-21) — JSON 合约与快照比对](#本次更新-2026-06-21--json-合约与快照比对) - [本次更新 (2026-06-21) — SLSA 构建来源证明](#本次更新-2026-06-21--slsa-构建来源证明) @@ -123,6 +124,12 @@ --- +## 本次更新 (2026-06-21) — 服务等级目标(SLO) + +SLI、错误预算与燃烧率告警。完整参考:[`docs/source/Zh/doc/new_features/v72_features_doc.rst`](../docs/source/Zh/doc/new_features/v72_features_doc.rst)。 + +- **`evaluate_slo` / `burn_rate` / `burn_alerts` / `default_burn_rules`**(`AC_evaluate_slo`、`AC_burn_alerts`):框架会发出原始信号却没有 SLO 层。本功能在结果记录(`[{timestamp, ok}]`)上计算 SLI、对目标计算错误预算,以及 Google SRE workbook 的**多窗口多燃烧率**告警(1h 达 14.4×、6h 达 6× 呼叫;3d 达 1× 开单 —— 只有当长短窗口双双超过阈值才触发)。记录为纯数据、时钟可注入、完全确定。纯标准库。 + ## 本次更新 (2026-06-21) — 混沌实验 注入故障、验证系统仍成立。完整参考:[`docs/source/Zh/doc/new_features/v71_features_doc.rst`](../docs/source/Zh/doc/new_features/v71_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 61925780..df579cda 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-21) — 服務等級目標(SLO)](#本次更新-2026-06-21--服務等級目標slo) - [本次更新 (2026-06-21) — 混沌實驗](#本次更新-2026-06-21--混沌實驗) - [本次更新 (2026-06-21) — JSON 合約與快照比對](#本次更新-2026-06-21--json-合約與快照比對) - [本次更新 (2026-06-21) — SLSA 建置來源證明](#本次更新-2026-06-21--slsa-建置來源證明) @@ -123,6 +124,12 @@ --- +## 本次更新 (2026-06-21) — 服務等級目標(SLO) + +SLI、錯誤預算與燃燒率警示。完整參考:[`docs/source/Zh/doc/new_features/v72_features_doc.rst`](../docs/source/Zh/doc/new_features/v72_features_doc.rst)。 + +- **`evaluate_slo` / `burn_rate` / `burn_alerts` / `default_burn_rules`**(`AC_evaluate_slo`、`AC_burn_alerts`):框架會發出原始訊號卻沒有 SLO 層。本功能在結果紀錄(`[{timestamp, ok}]`)上計算 SLI、對目標計算錯誤預算,以及 Google SRE workbook 的**多視窗多燃燒率**警示(1h 達 14.4×、6h 達 6× 呼叫;3d 達 1× 開票 —— 只有當長短視窗雙雙超過門檻才觸發)。紀錄為純資料、時鐘可注入、完全具決定性。純標準函式庫。 + ## 本次更新 (2026-06-21) — 混沌實驗 注入故障、驗證系統仍成立。完整參考:[`docs/source/Zh/doc/new_features/v71_features_doc.rst`](../docs/source/Zh/doc/new_features/v71_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v72_features_doc.rst b/docs/source/Eng/doc/new_features/v72_features_doc.rst new file mode 100644 index 00000000..7131d9fe --- /dev/null +++ b/docs/source/Eng/doc/new_features/v72_features_doc.rst @@ -0,0 +1,46 @@ +Service-Level Objectives (SLO) +============================== + +The framework emits raw signals (``observability`` metrics, ``run_history`` +durations) but had no operational layer turning them into an SLO, an error +budget, or burn-rate alerts. This adds that: compute the SLI over a window of +outcome records, the error budget against a target, and the **multi-window +multi-burn-rate** alerts from the Google SRE workbook. + +Records are plain data (``[{"timestamp": float, "ok": bool}, ...]``) so the +whole thing is offline and deterministic; the clock is injectable. Pure +standard library; imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import evaluate_slo, burn_alerts + + report = evaluate_slo(records, target=0.99) + # {"sli": 0.995, "good": 995, "total": 1000, "target": 0.99, + # "budget_total": 10.0, "budget_remaining": 5.0, + # "budget_remaining_fraction": 0.5, "burn_rate": 0.5} + + for alert in burn_alerts(records, target=0.99): + page_oncall(alert) # severity, threshold, long/short burn rates + +``evaluate_slo`` computes the SLI (good / total), the error budget +(``(1 - target) * total`` events) and the burn rate (``bad_rate / (1 - +target)`` — 1.0 means spending budget exactly on pace, > 1 means too fast). +``burn_rate`` is the bare number over a window. ``burn_alerts`` evaluates the +canonical Google SRE tiers from :func:`default_burn_rules` — page at 14.4× over +1h (and 5m), page at 6× over 6h (and 30m), ticket at 1× over 3d (and 6h) — and +fires a tier only when **both** its long and short windows exceed the +threshold, which gives fast reset and few false positives. Supply your own +``rules`` (``BurnRule`` list) to customise. + +Executor commands +----------------- + +``AC_evaluate_slo`` takes ``records`` (a list or JSON string), a ``target`` and +optional ``window_s``, and returns the SLI/budget report. ``AC_burn_alerts`` +returns ``{alerts, firing}``. Both are exposed as MCP tools +(``ac_evaluate_slo`` / ``ac_burn_alerts``) and as Script Builder commands under +**Report**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 83be642f..f7b6570b 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -94,6 +94,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v69_features_doc doc/new_features/v70_features_doc doc/new_features/v71_features_doc + doc/new_features/v72_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v72_features_doc.rst b/docs/source/Zh/doc/new_features/v72_features_doc.rst new file mode 100644 index 00000000..96cec95e --- /dev/null +++ b/docs/source/Zh/doc/new_features/v72_features_doc.rst @@ -0,0 +1,37 @@ +服務等級目標(SLO) +================== + +框架會發出原始訊號(``observability`` 指標、``run_history`` 時長),但沒有把它們轉成 SLO、錯誤 +預算或燃燒率警示的運維層。本功能補上:在一段視窗的結果紀錄上計算 SLI、對目標計算錯誤預算,以及 +Google SRE workbook 的**多視窗多燃燒率**警示。 + +紀錄是純資料(``[{"timestamp": float, "ok": bool}, ...]``),因此整體離線且具決定性;時鐘可注入。 +純標準函式庫;不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import evaluate_slo, burn_alerts + + report = evaluate_slo(records, target=0.99) + # {"sli": 0.995, "good": 995, "total": 1000, "target": 0.99, + # "budget_total": 10.0, "budget_remaining": 5.0, + # "budget_remaining_fraction": 0.5, "burn_rate": 0.5} + + for alert in burn_alerts(records, target=0.99): + page_oncall(alert) # severity、threshold、long/short 燃燒率 + +``evaluate_slo`` 計算 SLI(good / total)、錯誤預算(``(1 - target) * total`` 個事件)與燃燒率 +(``bad_rate / (1 - target)`` —— 1.0 代表剛好按進度消耗預算,> 1 代表太快)。``burn_rate`` 是某 +視窗的純數字。``burn_alerts`` 評估 :func:`default_burn_rules` 的標準 Google SRE 分層 —— 1h(與 +5m)達 14.4× 呼叫、6h(與 30m)達 6× 呼叫、3d(與 6h)達 1× 開票 —— 且只有當某層的長視窗與短視窗 +**雙雙**超過門檻時才觸發,以取得快速重置與少量誤報。可傳入自訂的 ``rules``(``BurnRule`` 清單)。 + +執行器命令 +---------- + +``AC_evaluate_slo`` 接受 ``records``(清單或 JSON 字串)、``target`` 與選用的 ``window_s``,回傳 +SLI/預算報告。``AC_burn_alerts`` 回傳 ``{alerts, firing}``。兩者皆以 MCP 工具(``ac_evaluate_slo`` +/ ``ac_burn_alerts``)以及 Script Builder 中 **Report** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 2e05d813..cdb28c38 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -94,6 +94,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v69_features_doc doc/new_features/v70_features_doc doc/new_features/v71_features_doc + doc/new_features/v72_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index d7c97e67..ebccb24f 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -365,6 +365,10 @@ ChaosExperiment, Fault, Probe, exception_fault, latency_fault, run_experiment, ) +# SLO: SLI, error budget and multi-window burn-rate alerts +from je_auto_control.utils.slo import ( + BurnRule, burn_alerts, burn_rate, default_burn_rules, evaluate_slo, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -860,6 +864,7 @@ def start_autocontrol_gui(*args, **kwargs): "MatchReport", "diff_json", "match_json", "normalize_json", "snapshot_json", "ChaosExperiment", "Fault", "Probe", "exception_fault", "latency_fault", "run_experiment", + "BurnRule", "burn_alerts", "burn_rate", "default_burn_rules", "evaluate_slo", # MCP server "AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt", "MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 010ba04b..a25cb289 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1388,6 +1388,25 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: ), description="Verify a JWT (alg allowlist + exp/nbf/aud); returns {ok, claims}.", )) + specs.append(CommandSpec( + "AC_evaluate_slo", "Report", "SLO: Evaluate (SLI + Error Budget)", + fields=( + FieldSpec("records", FieldType.STRING, + placeholder='[{"timestamp": 1700000000, "ok": true}]'), + FieldSpec("target", FieldType.FLOAT, placeholder="0.99"), + FieldSpec("window_s", FieldType.FLOAT, optional=True), + ), + description="SLI + error budget for outcome records vs a target.", + )) + specs.append(CommandSpec( + "AC_burn_alerts", "Report", "SLO: Burn-Rate Alerts", + fields=( + FieldSpec("records", FieldType.STRING, + placeholder='[{"timestamp": 1700000000, "ok": false}]'), + FieldSpec("target", FieldType.FLOAT, placeholder="0.99"), + ), + description="Multi-window burn-rate alerts (Google SRE tiers).", + )) specs.append(CommandSpec( "AC_run_chaos", "Flow", "Run Chaos Experiment", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 6df58aa5..9106cb73 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2928,6 +2928,26 @@ def _rate_limit(name: str, rate: float = 1.0, capacity: float = 1.0, "wait": round(bucket.time_until_available(float(n)), 4)} +def _evaluate_slo(records: Any, target: float, + window_s: Optional[float] = None) -> Dict[str, Any]: + """Adapter: SLI + error budget for outcome records (list or JSON string).""" + import json + from je_auto_control.utils.slo import evaluate_slo + if isinstance(records, str): + records = json.loads(records) + return evaluate_slo(records, float(target), window_s=window_s) + + +def _burn_alerts(records: Any, target: float) -> Dict[str, Any]: + """Adapter: multi-window burn-rate alerts for outcome records.""" + import json + from je_auto_control.utils.slo import burn_alerts + if isinstance(records, str): + records = json.loads(records) + alerts = burn_alerts(records, float(target)) + return {"alerts": alerts, "firing": bool(alerts)} + + def _chaos_probe_call(actions: List[Any]) -> Any: def call() -> bool: executor.execute_action(list(actions), raise_on_error=True) @@ -3965,6 +3985,8 @@ def __init__(self): "AC_match_json": _match_json, "AC_diff_json": _diff_json, "AC_run_chaos": _run_chaos, + "AC_evaluate_slo": _evaluate_slo, + "AC_burn_alerts": _burn_alerts, "AC_unified_diff": _unified_diff, "AC_apply_unified": _apply_unified, "AC_three_way_merge": _three_way_merge, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 765519f7..91e1e3d0 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3343,6 +3343,34 @@ def rate_limit_tools() -> List[MCPTool]: ] +def slo_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_evaluate_slo", + description=("Compute the SLI and error budget for outcome " + "'records' [{timestamp, ok}] against 'target' " + "(0-1). Returns {sli, budget_remaining, burn_rate, ...}."), + input_schema=schema( + {"records": {"type": "array"}, "target": {"type": "number"}, + "window_s": {"type": "number"}}, + ["records", "target"]), + handler=h.evaluate_slo, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_burn_alerts", + description=("Multi-window burn-rate alerts (Google SRE tiers) for " + "outcome 'records' against 'target'. Returns " + "{alerts, firing}."), + input_schema=schema( + {"records": {"type": "array"}, "target": {"type": "number"}}, + ["records", "target"]), + handler=h.burn_alerts, + annotations=READ_ONLY, + ), + ] + + def chaos_tools() -> List[MCPTool]: return [ MCPTool( @@ -4789,6 +4817,7 @@ def media_assert_tools() -> List[MCPTool]: license_policy_tools, jwt_tools, rate_limit_tools, json_patch_tools, search_index_tools, stats_tools, recurrence_tools, text_diff_tools, feature_flag_tools, provenance_tools, json_contract_tools, chaos_tools, + slo_tools, saga_tools, decision_table_tools, locator_repair_tools, pii_text_tools, sarif_tools, screen_record_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 3a29363d..75c7cc54 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1680,6 +1680,17 @@ def run_chaos(spec): return _run_chaos(spec) +def evaluate_slo(records, target, window_s=None): + from je_auto_control.utils.slo import evaluate_slo as _slo + return _slo(records, float(target), window_s=window_s) + + +def burn_alerts(records, target): + from je_auto_control.utils.slo import burn_alerts as _alerts + alerts = _alerts(records, float(target)) + return {"alerts": alerts, "firing": bool(alerts)} + + def build_provenance(paths, builder_id="je_auto_control"): from je_auto_control.utils.provenance import build_provenance, subject_for subjects = [subject_for(path) for path in paths] diff --git a/je_auto_control/utils/slo/__init__.py b/je_auto_control/utils/slo/__init__.py new file mode 100644 index 00000000..cb412956 --- /dev/null +++ b/je_auto_control/utils/slo/__init__.py @@ -0,0 +1,9 @@ +"""SLO evaluation: SLI, error budget and multi-window burn-rate alerts.""" +from je_auto_control.utils.slo.slo import ( + BurnRule, burn_alerts, burn_rate, default_burn_rules, evaluate_slo, +) + +__all__ = [ + "BurnRule", "burn_alerts", "burn_rate", "default_burn_rules", + "evaluate_slo", +] diff --git a/je_auto_control/utils/slo/slo.py b/je_auto_control/utils/slo/slo.py new file mode 100644 index 00000000..81036330 --- /dev/null +++ b/je_auto_control/utils/slo/slo.py @@ -0,0 +1,103 @@ +"""Service-level objectives: SLI, error budget and multi-window burn-rate alerts. + +The framework emits raw signals (``observability`` metrics, ``run_history`` +durations) but had no operational layer turning them into an SLO, an error +budget, or burn-rate alerts. This adds that: compute the SLI over a window of +outcome records, the error budget against a target, and the multi-window +multi-burn-rate alerts from the Google SRE workbook. + +Records are plain data (``[{"timestamp": float, "ok": bool}, ...]``) so the +whole thing is offline and deterministic; the clock is injectable. Pure +standard library; imports no ``PySide6``. +""" +import time +from dataclasses import dataclass +from typing import Any, Dict, List, Mapping, Optional, Sequence + +from je_auto_control.utils.exception.exceptions import AutoControlException + + +@dataclass(frozen=True) +class BurnRule: + """One multi-window burn-rate alert tier.""" + + severity: str + long_window_s: float + short_window_s: float + threshold: float + + +def default_burn_rules() -> List[BurnRule]: + """Return the canonical Google SRE burn-rate tiers (for a 30-day SLO).""" + return [ + BurnRule("page", 3600.0, 300.0, 14.4), + BurnRule("page", 21600.0, 1800.0, 6.0), + BurnRule("ticket", 259200.0, 21600.0, 1.0), + ] + + +def _window(records: Sequence[Mapping[str, Any]], window_s: Optional[float], + now: float) -> List[Mapping[str, Any]]: + if window_s is None: + return list(records) + cutoff = now - window_s + return [r for r in records if float(r.get("timestamp", now)) >= cutoff] + + +def _counts(records: Sequence[Mapping[str, Any]]) -> tuple: + total = len(records) + good = sum(1 for r in records if r.get("ok")) + return good, total + + +def evaluate_slo(records: Sequence[Mapping[str, Any]], target: float, *, + window_s: Optional[float] = None, + now: Optional[float] = None) -> Dict[str, Any]: + """Return the SLI and error budget for ``records`` against ``target``.""" + if not 0.0 < target < 1.0: + raise AutoControlException("target must be between 0 and 1") + when = time.time() if now is None else now + good, total = _counts(_window(records, window_s, when)) + if total == 0: + return {"sli": 1.0, "good": 0, "total": 0, "target": target, + "budget_total": 0.0, "budget_remaining": 0.0, + "budget_remaining_fraction": 1.0, "burn_rate": 0.0} + bad = total - good + allowed = (1.0 - target) * total + bad_rate = bad / total + return { + "sli": good / total, "good": good, "total": total, "target": target, + "budget_total": allowed, + "budget_remaining": allowed - bad, + "budget_remaining_fraction": (1.0 - bad / allowed) if allowed else 0.0, + "burn_rate": bad_rate / (1.0 - target), + } + + +def burn_rate(records: Sequence[Mapping[str, Any]], target: float, *, + window_s: Optional[float] = None, + now: Optional[float] = None) -> float: + """Return the error-budget burn rate over the window (1.0 = on-budget).""" + return evaluate_slo(records, target, window_s=window_s, now=now)["burn_rate"] + + +def burn_alerts(records: Sequence[Mapping[str, Any]], target: float, *, + rules: Optional[Sequence[BurnRule]] = None, + now: Optional[float] = None) -> List[Dict[str, Any]]: + """Return fired multi-window burn-rate alerts (both windows over threshold).""" + when = time.time() if now is None else now + active = rules if rules is not None else default_burn_rules() + alerts: List[Dict[str, Any]] = [] + for rule in active: + long_rate = burn_rate(records, target, window_s=rule.long_window_s, + now=when) + short_rate = burn_rate(records, target, window_s=rule.short_window_s, + now=when) + if long_rate > rule.threshold and short_rate > rule.threshold: + alerts.append({ + "severity": rule.severity, "threshold": rule.threshold, + "long_burn_rate": long_rate, "short_burn_rate": short_rate, + "long_window_s": rule.long_window_s, + "short_window_s": rule.short_window_s, + }) + return alerts diff --git a/test/unit_test/headless/test_slo_batch.py b/test/unit_test/headless/test_slo_batch.py new file mode 100644 index 00000000..6f50f2fa --- /dev/null +++ b/test/unit_test/headless/test_slo_batch.py @@ -0,0 +1,98 @@ +"""Headless tests for SLO / error budget / burn-rate. Pure stdlib, no Qt.""" +import json + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.exception.exceptions import AutoControlException +from je_auto_control.utils.slo import ( + BurnRule, burn_alerts, burn_rate, default_burn_rules, evaluate_slo) + + +def _records(good, bad, ts=1000): + return ([{"timestamp": ts, "ok": True}] * good + + [{"timestamp": ts, "ok": False}] * bad) + + +def test_evaluate_slo_budget(): + report = evaluate_slo(_records(995, 5), 0.99, now=1000) + assert report["sli"] == pytest.approx(0.995) + assert report["budget_total"] == pytest.approx(10.0) + assert report["budget_remaining"] == pytest.approx(5.0) + assert report["burn_rate"] == pytest.approx(0.5) + + +def test_all_good_zero_burn(): + assert burn_rate(_records(10, 0), 0.99, now=1000) == pytest.approx(0.0) + + +def test_empty_is_full_budget(): + report = evaluate_slo([], 0.99, now=1) + assert report["sli"] == pytest.approx(1.0) + assert report["budget_remaining_fraction"] == pytest.approx(1.0) + + +def test_bad_target_raises(): + with pytest.raises(AutoControlException): + evaluate_slo(_records(1, 0), 1.5, now=1) + + +def test_window_excludes_old_records(): + now = 10000 + old = [{"timestamp": 0, "ok": False}] * 100 + assert burn_rate(old, 0.99, window_s=3600, now=now) == pytest.approx(0.0) + + +def test_burn_alerts_fire_on_high_error_rate(): + now = 10000 + records = _records(50, 50, ts=now - 10) # 50% errors -> burn ~50 + alerts = burn_alerts(records, 0.99, now=now) + assert {a["severity"] for a in alerts} == {"page", "ticket"} + assert len(alerts) == 3 + + +def test_burn_alerts_quiet_when_healthy(): + now = 10000 + records = _records(1000, 0, ts=now - 10) + assert burn_alerts(records, 0.99, now=now) == [] + + +def test_default_burn_rules_shape(): + rules = default_burn_rules() + assert all(isinstance(r, BurnRule) for r in rules) + assert rules[0].threshold == pytest.approx(14.4) + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + rec = ac.execute_action([[ + "AC_evaluate_slo", + {"records": json.dumps(_records(99, 1)), "target": 0.9}, + ]]) + report = next(v for v in rec.values() if isinstance(v, dict)) + assert report["sli"] == pytest.approx(0.99) + + rec2 = ac.execute_action([[ + "AC_burn_alerts", + {"records": json.dumps(_records(99, 1)), "target": 0.99}, + ]]) + payload = next(v for v in rec2.values() if isinstance(v, dict)) + assert "firing" in payload and "alerts" in payload + + +def test_wiring(): + assert {"AC_evaluate_slo", "AC_burn_alerts"} <= ac.executor.known_commands() + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_evaluate_slo", "ac_burn_alerts"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert {"AC_evaluate_slo", "AC_burn_alerts"} <= cmds + + +def test_facade_exports(): + for attr in ("evaluate_slo", "burn_rate", "burn_alerts", "BurnRule", + "default_burn_rules"): + assert hasattr(ac, attr) + assert attr in ac.__all__