4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
781 lines
28 KiB
Python
781 lines
28 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from config.config import has_credentials_for_active_llm_provider
|
|
from tests.synthetic.rds_postgres.run_suite import run_scenario
|
|
from tests.synthetic.rds_postgres.scenario_loader import (
|
|
SUITE_DIR,
|
|
GoldenTrajectoryConfig,
|
|
load_all_scenarios,
|
|
load_scenario,
|
|
)
|
|
from tests.synthetic.rds_postgres.scoring import score_result
|
|
from tests.synthetic.schemas import VALID_EVIDENCE_SOURCES
|
|
|
|
|
|
def test_load_all_scenarios_reads_benchmark_cases() -> None:
|
|
fixtures = load_all_scenarios()
|
|
|
|
scenario_ids = [fixture.scenario_id for fixture in fixtures]
|
|
assert "000-healthy" in scenario_ids
|
|
assert "001-replication-lag" in scenario_ids
|
|
assert "002-connection-exhaustion" in scenario_ids
|
|
|
|
|
|
def test_scenario_metadata_is_valid() -> None:
|
|
fixtures = load_all_scenarios()
|
|
|
|
for fixture in fixtures:
|
|
meta = fixture.metadata
|
|
assert meta.schema_version, f"{fixture.scenario_id}: schema_version must be set"
|
|
assert meta.engine, f"{fixture.scenario_id}: engine must be set"
|
|
assert meta.failure_mode, f"{fixture.scenario_id}: failure_mode must be set"
|
|
assert meta.region, f"{fixture.scenario_id}: region must be set"
|
|
assert meta.available_evidence, (
|
|
f"{fixture.scenario_id}: available_evidence must not be empty"
|
|
)
|
|
unknown = set(meta.available_evidence) - VALID_EVIDENCE_SOURCES
|
|
assert not unknown, f"{fixture.scenario_id}: unknown evidence sources {unknown}"
|
|
|
|
|
|
def test_scenario_evidence_matches_available_evidence() -> None:
|
|
fixtures = load_all_scenarios()
|
|
|
|
for fixture in fixtures:
|
|
evidence_dict = fixture.evidence.as_dict()
|
|
assert set(evidence_dict.keys()) == set(fixture.metadata.available_evidence), (
|
|
f"{fixture.scenario_id}: evidence keys {set(evidence_dict.keys())} "
|
|
f"do not match available_evidence {fixture.metadata.available_evidence}"
|
|
)
|
|
|
|
|
|
def test_score_result_does_not_apply_failover_wording_to_storage_scenario() -> None:
|
|
fixture = load_scenario(SUITE_DIR / "008-storage-full-missing-metric")
|
|
|
|
final_state = {
|
|
"root_cause": (
|
|
"The RDS instance ran out of storage space, and storage space exhaustion is "
|
|
"confirmed by the RDS event plus collapsing WriteIOPS."
|
|
),
|
|
"root_cause_category": "storage_exhaustion",
|
|
"validated_claims": [
|
|
{"claim": 'RDS event states "DB instance ran out of storage space".'},
|
|
],
|
|
"non_validated_claims": [],
|
|
"causal_chain": ["Storage filled up, blocked writes, and caused the alert."],
|
|
"evidence": {
|
|
"grafana_logs": [
|
|
{"message": "DB instance ran out of storage space."},
|
|
]
|
|
},
|
|
"executed_hypotheses": [
|
|
{
|
|
"actions": [
|
|
"query_grafana_logs",
|
|
"query_grafana_metrics",
|
|
"query_grafana_alert_rules",
|
|
]
|
|
}
|
|
],
|
|
}
|
|
|
|
score = score_result(fixture, final_state)
|
|
|
|
assert score.passed is True
|
|
|
|
|
|
def test_score_result_accepts_equivalent_cpu_saturation_category() -> None:
|
|
"""Scenario 004 allows either generic CPU saturation or the specific bad-query label."""
|
|
|
|
fixture = load_scenario(SUITE_DIR / "004-cpu-saturation-bad-query")
|
|
|
|
final_state = {
|
|
"root_cause": (
|
|
"CPU saturation from a heavy query shown in Performance Insights "
|
|
"top SQL with AAS and avg load on the catalog database."
|
|
),
|
|
"root_cause_category": "cpu_saturation",
|
|
"validated_claims": [],
|
|
"non_validated_claims": [],
|
|
"causal_chain": [],
|
|
"evidence": {
|
|
"grafana_metrics": {"placeholder": True},
|
|
},
|
|
"executed_hypotheses": [
|
|
{
|
|
"actions": [
|
|
"query_grafana_metrics",
|
|
"query_grafana_logs",
|
|
"query_grafana_alert_rules",
|
|
]
|
|
}
|
|
],
|
|
"investigation_loop_count": 1,
|
|
}
|
|
|
|
score = score_result(fixture, final_state)
|
|
|
|
assert score.passed is True
|
|
assert score.actual_category == "cpu_saturation"
|
|
assert score.accepted_categories == ("cpu_saturation", "cpu_saturation_bad_query")
|
|
|
|
|
|
def test_score_result_accepts_replication_lag_wal_volume_equivalent() -> None:
|
|
"""Scenario 006 allows generic replication_lag or WAL-specific subcategory."""
|
|
|
|
fixture = load_scenario(SUITE_DIR / "006-replication-lag-cpu-redherring")
|
|
|
|
final_state = {
|
|
"root_cause": (
|
|
"Replication lag on the replica from WAL volume; the SELECT workload is a "
|
|
"red herring with avg load and AAS in Performance Insights. "
|
|
"UPDATE on primary drives WAL; SELECT analytics is unrelated to the lag."
|
|
),
|
|
"root_cause_category": "replication_lag_wal_volume",
|
|
"validated_claims": [],
|
|
"non_validated_claims": [],
|
|
"causal_chain": [],
|
|
"evidence": {
|
|
"grafana_metrics": {"placeholder": True},
|
|
},
|
|
"executed_hypotheses": [
|
|
{
|
|
"actions": [
|
|
"query_grafana_metrics",
|
|
"query_grafana_logs",
|
|
"query_grafana_alert_rules",
|
|
]
|
|
}
|
|
],
|
|
"investigation_loop_count": 1,
|
|
}
|
|
|
|
score = score_result(fixture, final_state)
|
|
|
|
assert score.passed is True
|
|
assert score.actual_category == "replication_lag_wal_volume"
|
|
assert score.accepted_categories == ("replication_lag", "replication_lag_wal_volume")
|
|
|
|
|
|
def test_score_result_keeps_failover_event_reasoning_requirement() -> None:
|
|
fixture = load_scenario(SUITE_DIR / "005-failover")
|
|
|
|
final_state = {
|
|
"root_cause": (
|
|
"A Multi-AZ automatic failover occurred after a health check failure on the "
|
|
"primary host, and workload resumed normally after failover completed. The "
|
|
"timeline shows failover initiated, failover in progress, failover completed, "
|
|
"and instance available as the primary evidence source."
|
|
),
|
|
"root_cause_category": "infrastructure",
|
|
"validated_claims": [
|
|
{"claim": "The timeline confirms the instance became available again."},
|
|
],
|
|
"non_validated_claims": [],
|
|
"causal_chain": [
|
|
"Health check failure triggered standby promotion and client reconnection."
|
|
],
|
|
"evidence": {
|
|
"grafana_logs": [
|
|
{"message": "Failover initiated."},
|
|
{"message": "Failover in progress."},
|
|
{"message": "Failover completed."},
|
|
{"message": "Instance available."},
|
|
]
|
|
},
|
|
"executed_hypotheses": [
|
|
{
|
|
"actions": [
|
|
"query_grafana_logs",
|
|
"query_grafana_metrics",
|
|
"query_grafana_alert_rules",
|
|
]
|
|
}
|
|
],
|
|
}
|
|
|
|
score = score_result(fixture, final_state)
|
|
|
|
assert score.failure_reason == "RDS events gathered but not used as primary reasoning signal"
|
|
|
|
|
|
def test_score_result_accepts_failover_event_reasoning() -> None:
|
|
fixture = load_scenario(SUITE_DIR / "005-failover")
|
|
|
|
final_state = {
|
|
"root_cause": (
|
|
"Based on the RDS event timeline (primary evidence source), a Multi-AZ "
|
|
"automatic failover occurred after a health check failure on the primary "
|
|
"host, and workload resumed normally after failover completed."
|
|
),
|
|
"root_cause_category": "infrastructure",
|
|
"validated_claims": [
|
|
{
|
|
"claim": (
|
|
"RDS events show the full sequence: failover initiated -> "
|
|
"failover in progress -> failover completed -> instance available."
|
|
)
|
|
},
|
|
],
|
|
"non_validated_claims": [],
|
|
"causal_chain": [
|
|
"Health check failure triggered failover, standby promotion, DNS update, "
|
|
"brief outage, and recovery."
|
|
],
|
|
"evidence": {
|
|
"grafana_logs": [
|
|
{"message": "Failover initiated."},
|
|
{"message": "Failover in progress."},
|
|
{"message": "Failover completed."},
|
|
{"message": "Instance available."},
|
|
]
|
|
},
|
|
"executed_hypotheses": [
|
|
{
|
|
"actions": [
|
|
"query_grafana_logs",
|
|
"query_grafana_metrics",
|
|
"query_grafana_alert_rules",
|
|
]
|
|
}
|
|
],
|
|
}
|
|
|
|
score = score_result(fixture, final_state)
|
|
|
|
assert score.passed is True
|
|
|
|
|
|
def test_score_result_uses_semantic_keyword_matching_for_write_heavy_workload() -> None:
|
|
fixture = load_scenario(SUITE_DIR / "001-replication-lag")
|
|
|
|
final_state = {
|
|
"root_cause": (
|
|
"Replication lag is driven by a write-heavy UPDATE on the orders table, "
|
|
"which increases WAL generation faster than the replica can replay; "
|
|
"Top SQL Activity and Avg Load confirm replay pressure."
|
|
),
|
|
"root_cause_category": "replication_lag",
|
|
"validated_claims": [
|
|
{"claim": "Replica lag and WAL replay pressure are both elevated."},
|
|
],
|
|
"non_validated_claims": [],
|
|
"causal_chain": [],
|
|
"evidence": {
|
|
"grafana_metrics": [{"metric_name": "ReplicaLag"}],
|
|
"grafana_logs": [{"message": "replica lag spike observed"}],
|
|
},
|
|
"executed_hypotheses": [
|
|
{
|
|
"actions": [
|
|
"query_grafana_metrics",
|
|
"query_grafana_logs",
|
|
"query_grafana_alert_rules",
|
|
]
|
|
}
|
|
],
|
|
}
|
|
|
|
score = score_result(fixture, final_state)
|
|
|
|
assert score.semantic_keyword_match is True
|
|
assert score.exact_keyword_match is False
|
|
assert score.gates["required_keyword_match"].status == "pass"
|
|
assert "write-heavy workload" in score.semantic_matched_keywords
|
|
assert score.passed is True
|
|
|
|
|
|
_ALL_SCENARIOS = load_all_scenarios()
|
|
_LLM_ATTEMPTS = 2
|
|
_SYNTHETIC_SKIP_LLM = (
|
|
"SKIPPED: missing API key for LLM_PROVIDER "
|
|
"(suite uses mock Grafana/RDS backends; configure the key for your selected provider)"
|
|
)
|
|
|
|
|
|
def _by_difficulty(level: int) -> list:
|
|
return [f for f in _ALL_SCENARIOS if f.metadata.scenario_difficulty == level]
|
|
|
|
|
|
def _should_assert_trajectory(fixture, actual_category: str) -> bool:
|
|
"""Keep trajectory assertions for lower-difficulty non-healthy scenarios."""
|
|
|
|
return fixture.metadata.scenario_difficulty <= 2 and actual_category != "healthy"
|
|
|
|
|
|
def _run_scenario_test(fixture) -> None:
|
|
"""Run scenario with real LLM and mock Grafana backend, then assert scoring."""
|
|
if not has_credentials_for_active_llm_provider():
|
|
pytest.skip(_SYNTHETIC_SKIP_LLM)
|
|
|
|
failures: list[str] = []
|
|
for attempt in range(1, _LLM_ATTEMPTS + 1):
|
|
final_state, score = run_scenario(fixture, use_mock_grafana=True)
|
|
|
|
try:
|
|
assert final_state["root_cause"]
|
|
assert score.passed is True, (
|
|
f"{fixture.scenario_id} FAILED: {score.failure_reason}\n"
|
|
f" actual_category={score.actual_category!r} "
|
|
f" missing_keywords={score.missing_keywords}"
|
|
)
|
|
|
|
if (
|
|
_should_assert_trajectory(fixture, score.actual_category)
|
|
and score.trajectory is not None
|
|
):
|
|
assert score.trajectory.sequencing_ok, (
|
|
f"{fixture.scenario_id} TRAJECTORY FAIL: "
|
|
f"sequencing={score.trajectory.sequencing_ok} "
|
|
f"calibration={score.trajectory.calibration_ok}\n"
|
|
f" expected={score.trajectory.expected_sequence}\n"
|
|
f" actual={score.trajectory.actual_sequence}"
|
|
)
|
|
return
|
|
except AssertionError as exc:
|
|
failures.append(f"attempt {attempt}/{_LLM_ATTEMPTS}: {exc}")
|
|
|
|
raise AssertionError("\n\n".join(failures))
|
|
|
|
|
|
@pytest.mark.synthetic
|
|
@pytest.mark.parametrize("fixture", _by_difficulty(1), ids=lambda f: f.scenario_id)
|
|
def test_level1_scenario(fixture) -> None:
|
|
"""Level 1 — single dominant signal, all evidence consistent."""
|
|
_run_scenario_test(fixture)
|
|
|
|
|
|
@pytest.mark.synthetic
|
|
@pytest.mark.parametrize("fixture", _by_difficulty(2), ids=lambda f: f.scenario_id)
|
|
def test_level2_scenario(fixture) -> None:
|
|
"""Level 2 — one confounder present, second evidence source needed to rule it out."""
|
|
_run_scenario_test(fixture)
|
|
|
|
|
|
@pytest.mark.synthetic
|
|
@pytest.mark.parametrize("fixture", _by_difficulty(3), ids=lambda f: f.scenario_id)
|
|
def test_level3_scenario(fixture) -> None:
|
|
"""Level 3 — absent or indirect evidence, key metric missing."""
|
|
_run_scenario_test(fixture)
|
|
|
|
|
|
@pytest.mark.synthetic
|
|
@pytest.mark.parametrize("fixture", _by_difficulty(4), ids=lambda f: f.scenario_id)
|
|
def test_level4_scenario(fixture) -> None:
|
|
"""Level 4 — compositional fault, two failure modes causally linked."""
|
|
_run_scenario_test(fixture)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scenario inheritance unit tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _write_minimal_answer_yml(scenario_dir: Path) -> None:
|
|
(scenario_dir / "answer.yml").write_text(
|
|
textwrap.dedent("""\
|
|
root_cause_category: test_category
|
|
required_keywords:
|
|
- test_keyword
|
|
model_response: "Test model response."
|
|
""")
|
|
)
|
|
|
|
|
|
class TestScenarioInheritance:
|
|
"""Verify base-inheritance and evidence-file fallback in scenario_loader."""
|
|
|
|
def test_metadata_inherited_from_base(self, tmp_path: Path) -> None:
|
|
"""Scenario with base: 000-healthy inherits metadata fields it omits."""
|
|
scenario_dir = tmp_path / "999-test-inherit"
|
|
scenario_dir.mkdir()
|
|
|
|
(scenario_dir / "scenario.yml").write_text(
|
|
textwrap.dedent("""\
|
|
base: 000-healthy
|
|
scenario_id: 999-test-inherit
|
|
failure_mode: cpu_saturation
|
|
severity: critical
|
|
""")
|
|
)
|
|
_write_minimal_answer_yml(scenario_dir)
|
|
|
|
# Symlink the suite directory so _resolve_base_dir can find 000-healthy.
|
|
# We place our scenario inside the real suite dir temporarily.
|
|
real_dir = SUITE_DIR / "999-test-inherit"
|
|
real_dir.mkdir(exist_ok=True)
|
|
try:
|
|
for f in scenario_dir.iterdir():
|
|
(real_dir / f.name).write_bytes(f.read_bytes())
|
|
|
|
fixture = load_scenario(real_dir)
|
|
|
|
assert fixture.metadata.scenario_id == "999-test-inherit"
|
|
assert fixture.metadata.failure_mode == "cpu_saturation"
|
|
assert fixture.metadata.severity == "critical"
|
|
# These should be inherited from 000-healthy:
|
|
assert fixture.metadata.engine == "postgres"
|
|
assert fixture.metadata.engine_version == "15"
|
|
assert fixture.metadata.instance_class == "db.r6g.2xlarge"
|
|
assert fixture.metadata.region == "us-east-1"
|
|
assert fixture.metadata.db_instance_identifier == "payments-prod"
|
|
assert fixture.metadata.db_cluster == "payments-cluster"
|
|
assert fixture.metadata.schema_version == "1.0"
|
|
assert "aws_cloudwatch_metrics" in fixture.metadata.available_evidence
|
|
finally:
|
|
for f in real_dir.iterdir():
|
|
f.unlink()
|
|
real_dir.rmdir()
|
|
|
|
def test_evidence_falls_back_to_base(self) -> None:
|
|
"""Scenario without evidence files loads them from the base."""
|
|
real_dir = SUITE_DIR / "999-test-fallback"
|
|
real_dir.mkdir(exist_ok=True)
|
|
try:
|
|
(real_dir / "scenario.yml").write_text(
|
|
textwrap.dedent("""\
|
|
base: 000-healthy
|
|
scenario_id: 999-test-fallback
|
|
failure_mode: healthy
|
|
severity: info
|
|
""")
|
|
)
|
|
_write_minimal_answer_yml(real_dir)
|
|
|
|
fixture = load_scenario(real_dir)
|
|
|
|
# Evidence should come from 000-healthy (non-None since base has all three)
|
|
assert fixture.evidence.aws_cloudwatch_metrics is not None
|
|
assert fixture.evidence.aws_rds_events is not None
|
|
assert fixture.evidence.aws_performance_insights is not None
|
|
|
|
# Alert should also fall back to 000-healthy's
|
|
assert fixture.alert["state"] == "normal"
|
|
assert "payments-prod" in fixture.alert["title"]
|
|
finally:
|
|
for f in real_dir.iterdir():
|
|
f.unlink()
|
|
real_dir.rmdir()
|
|
|
|
def test_local_evidence_overrides_base(self) -> None:
|
|
"""Scenario with its own evidence file uses it instead of the base's."""
|
|
real_dir = SUITE_DIR / "999-test-override"
|
|
real_dir.mkdir(exist_ok=True)
|
|
try:
|
|
(real_dir / "scenario.yml").write_text(
|
|
textwrap.dedent("""\
|
|
base: 000-healthy
|
|
scenario_id: 999-test-override
|
|
failure_mode: healthy
|
|
severity: info
|
|
""")
|
|
)
|
|
_write_minimal_answer_yml(real_dir)
|
|
|
|
custom_events = {
|
|
"events": [
|
|
{
|
|
"date": "2026-04-01T00:00:00Z",
|
|
"message": "Custom test event",
|
|
"source_identifier": "payments-prod",
|
|
"source_type": "db-instance",
|
|
"event_categories": ["notification"],
|
|
}
|
|
]
|
|
}
|
|
(real_dir / "aws_rds_events.json").write_text(json.dumps(custom_events))
|
|
|
|
fixture = load_scenario(real_dir)
|
|
|
|
assert fixture.evidence.aws_rds_events is not None
|
|
assert len(fixture.evidence.aws_rds_events) == 1
|
|
assert fixture.evidence.aws_rds_events[0]["message"] == "Custom test event"
|
|
finally:
|
|
for f in real_dir.iterdir():
|
|
f.unlink()
|
|
real_dir.rmdir()
|
|
|
|
def test_chained_inheritance_rejected(self) -> None:
|
|
"""Declaring base on a scenario that itself has a base raises ValueError."""
|
|
real_dir = SUITE_DIR / "999-test-chain-a"
|
|
real_dir_b = SUITE_DIR / "999-test-chain-b"
|
|
real_dir.mkdir(exist_ok=True)
|
|
real_dir_b.mkdir(exist_ok=True)
|
|
try:
|
|
(real_dir.joinpath("scenario.yml")).write_text(
|
|
textwrap.dedent("""\
|
|
base: 000-healthy
|
|
scenario_id: 999-test-chain-a
|
|
failure_mode: healthy
|
|
severity: info
|
|
""")
|
|
)
|
|
(real_dir_b / "scenario.yml").write_text(
|
|
textwrap.dedent("""\
|
|
base: 999-test-chain-a
|
|
scenario_id: 999-test-chain-b
|
|
failure_mode: healthy
|
|
severity: info
|
|
""")
|
|
)
|
|
_write_minimal_answer_yml(real_dir_b)
|
|
|
|
with pytest.raises(ValueError, match="Chained inheritance is not supported"):
|
|
load_scenario(real_dir_b)
|
|
finally:
|
|
for d in (real_dir, real_dir_b):
|
|
for f in d.iterdir():
|
|
f.unlink()
|
|
d.rmdir()
|
|
|
|
def test_missing_base_raises(self) -> None:
|
|
"""Referencing a non-existent base scenario raises ValueError."""
|
|
real_dir = SUITE_DIR / "999-test-missing-base"
|
|
real_dir.mkdir(exist_ok=True)
|
|
try:
|
|
(real_dir / "scenario.yml").write_text(
|
|
textwrap.dedent("""\
|
|
base: 999-nonexistent
|
|
scenario_id: 999-test-missing-base
|
|
failure_mode: healthy
|
|
severity: info
|
|
""")
|
|
)
|
|
_write_minimal_answer_yml(real_dir)
|
|
|
|
with pytest.raises(ValueError, match="Base scenario '999-nonexistent' not found"):
|
|
load_scenario(real_dir)
|
|
finally:
|
|
for f in real_dir.iterdir():
|
|
f.unlink()
|
|
real_dir.rmdir()
|
|
|
|
def test_no_base_works_unchanged(self) -> None:
|
|
"""Scenarios without a base field still load normally."""
|
|
fixture = load_scenario(SUITE_DIR / "000-healthy")
|
|
assert fixture.metadata.scenario_id == "000-healthy"
|
|
assert fixture.metadata.failure_mode == "healthy"
|
|
assert fixture.evidence.aws_cloudwatch_metrics is not None
|
|
|
|
def test_golden_trajectory_is_loaded_as_typed_config(self) -> None:
|
|
"""golden_trajectory is normalized into a typed config object."""
|
|
real_dir = SUITE_DIR / "999-test-golden-trajectory"
|
|
real_dir.mkdir(exist_ok=True)
|
|
try:
|
|
(real_dir / "scenario.yml").write_text(
|
|
textwrap.dedent("""\
|
|
base: 000-healthy
|
|
scenario_id: 999-test-golden-trajectory
|
|
failure_mode: replication_lag
|
|
severity: critical
|
|
""")
|
|
)
|
|
(real_dir / "answer.yml").write_text(
|
|
textwrap.dedent("""\
|
|
root_cause_category: resource_exhaustion
|
|
required_keywords:
|
|
- replication lag
|
|
model_response: "Replication lag from write pressure."
|
|
golden_trajectory:
|
|
ordered_actions:
|
|
- query_grafana_metrics
|
|
- query_grafana_logs
|
|
matching: strict
|
|
max_edit_distance: 1
|
|
max_extra_actions: 0
|
|
max_redundancy: 0
|
|
max_loops: 2
|
|
""")
|
|
)
|
|
|
|
fixture = load_scenario(real_dir)
|
|
expected = GoldenTrajectoryConfig(
|
|
ordered_actions=["query_grafana_metrics", "query_grafana_logs"],
|
|
matching="strict",
|
|
max_edit_distance=1,
|
|
max_extra_actions=0,
|
|
max_redundancy=0,
|
|
max_loops=2,
|
|
)
|
|
|
|
assert fixture.answer_key.golden_trajectory == expected
|
|
finally:
|
|
for f in real_dir.iterdir():
|
|
f.unlink()
|
|
real_dir.rmdir()
|
|
|
|
def test_golden_trajectory_requires_ordered_actions(self) -> None:
|
|
"""golden_trajectory block must include a non-empty ordered_actions list."""
|
|
real_dir = SUITE_DIR / "999-test-golden-trajectory-missing-actions"
|
|
real_dir.mkdir(exist_ok=True)
|
|
try:
|
|
(real_dir / "scenario.yml").write_text(
|
|
textwrap.dedent("""\
|
|
base: 000-healthy
|
|
scenario_id: 999-test-golden-trajectory-missing-actions
|
|
failure_mode: replication_lag
|
|
severity: critical
|
|
""")
|
|
)
|
|
(real_dir / "answer.yml").write_text(
|
|
textwrap.dedent("""\
|
|
root_cause_category: resource_exhaustion
|
|
required_keywords:
|
|
- replication lag
|
|
model_response: "Replication lag from write pressure."
|
|
golden_trajectory:
|
|
matching: strict
|
|
""")
|
|
)
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="golden_trajectory.ordered_actions",
|
|
):
|
|
load_scenario(real_dir)
|
|
finally:
|
|
for f in real_dir.iterdir():
|
|
f.unlink()
|
|
real_dir.rmdir()
|
|
|
|
def test_golden_trajectory_rejects_boolean_numeric_fields(self) -> None:
|
|
"""Boolean values are rejected for numeric golden_trajectory limits."""
|
|
real_dir = SUITE_DIR / "999-test-golden-trajectory-bool-limit"
|
|
real_dir.mkdir(exist_ok=True)
|
|
try:
|
|
(real_dir / "scenario.yml").write_text(
|
|
textwrap.dedent("""\
|
|
base: 000-healthy
|
|
scenario_id: 999-test-golden-trajectory-bool-limit
|
|
failure_mode: replication_lag
|
|
severity: critical
|
|
""")
|
|
)
|
|
(real_dir / "answer.yml").write_text(
|
|
textwrap.dedent("""\
|
|
root_cause_category: resource_exhaustion
|
|
required_keywords:
|
|
- replication lag
|
|
model_response: "Replication lag from write pressure."
|
|
golden_trajectory:
|
|
ordered_actions:
|
|
- query_grafana_metrics
|
|
- query_grafana_logs
|
|
max_loops: true
|
|
""")
|
|
)
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="golden_trajectory.max_loops",
|
|
):
|
|
load_scenario(real_dir)
|
|
finally:
|
|
for f in real_dir.iterdir():
|
|
f.unlink()
|
|
real_dir.rmdir()
|
|
|
|
def test_schema_v3_supports_k8s_semantic_evidence_sources(self) -> None:
|
|
"""schema_v3 scenarios can declare and load Kubernetes semantic evidence IDs."""
|
|
real_dir = SUITE_DIR / "999-test-schema-v3-k8s-sources"
|
|
real_dir.mkdir(exist_ok=True)
|
|
try:
|
|
(real_dir / "scenario.yml").write_text(
|
|
textwrap.dedent("""\
|
|
base: 000-healthy
|
|
schema_version: schema_v3
|
|
scenario_id: 999-test-schema-v3-k8s-sources
|
|
failure_mode: cpu_saturation
|
|
severity: critical
|
|
scenario_difficulty: 3
|
|
available_evidence:
|
|
- k8s_events
|
|
- k8s_rollout
|
|
""")
|
|
)
|
|
(real_dir / "answer.yml").write_text(
|
|
textwrap.dedent("""\
|
|
root_cause_category: cpu_saturation
|
|
required_keywords:
|
|
- cpu saturation
|
|
model_response: "CPU saturation."
|
|
required_evidence_sources:
|
|
- k8s_events
|
|
""")
|
|
)
|
|
(real_dir / "k8s_events.json").write_text(json.dumps({"events": []}))
|
|
(real_dir / "k8s_rollout.json").write_text(json.dumps({"status": "degraded"}))
|
|
|
|
fixture = load_scenario(real_dir)
|
|
|
|
assert fixture.metadata.schema_version == "schema_v3"
|
|
assert fixture.metadata.available_evidence == ["k8s_events", "k8s_rollout"]
|
|
assert fixture.evidence.k8s_events is not None
|
|
assert fixture.evidence.k8s_rollout is not None
|
|
assert fixture.answer_key.required_evidence_sources == ["k8s_events"]
|
|
finally:
|
|
for f in real_dir.iterdir():
|
|
f.unlink()
|
|
real_dir.rmdir()
|
|
|
|
def test_schema_v3_complex_scenario_requires_required_evidence_sources(self) -> None:
|
|
"""schema_v3 complex scenarios must declare non-empty required_evidence_sources."""
|
|
real_dir = SUITE_DIR / "999-test-schema-v3-complex-requires-sources"
|
|
real_dir.mkdir(exist_ok=True)
|
|
try:
|
|
(real_dir / "scenario.yml").write_text(
|
|
textwrap.dedent("""\
|
|
base: 000-healthy
|
|
schema_version: schema_v3
|
|
scenario_id: 999-test-schema-v3-complex-requires-sources
|
|
failure_mode: cpu_saturation
|
|
severity: critical
|
|
scenario_difficulty: 3
|
|
available_evidence:
|
|
- aws_cloudwatch_metrics
|
|
""")
|
|
)
|
|
_write_minimal_answer_yml(real_dir)
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="required_evidence_sources",
|
|
):
|
|
load_scenario(real_dir)
|
|
finally:
|
|
for f in real_dir.iterdir():
|
|
f.unlink()
|
|
real_dir.rmdir()
|
|
|
|
def test_schema_v1_complex_scenario_keeps_backward_compatibility(self) -> None:
|
|
"""Legacy schema versions keep loading without required_evidence_sources."""
|
|
real_dir = SUITE_DIR / "999-test-schema-v1-complex-backcompat"
|
|
real_dir.mkdir(exist_ok=True)
|
|
try:
|
|
(real_dir / "scenario.yml").write_text(
|
|
textwrap.dedent("""\
|
|
base: 000-healthy
|
|
schema_version: "1.0"
|
|
scenario_id: 999-test-schema-v1-complex-backcompat
|
|
failure_mode: cpu_saturation
|
|
severity: critical
|
|
scenario_difficulty: 3
|
|
available_evidence:
|
|
- aws_cloudwatch_metrics
|
|
""")
|
|
)
|
|
_write_minimal_answer_yml(real_dir)
|
|
|
|
fixture = load_scenario(real_dir)
|
|
assert fixture.metadata.schema_version == "1.0"
|
|
assert fixture.answer_key.required_evidence_sources == []
|
|
finally:
|
|
for f in real_dir.iterdir():
|
|
f.unlink()
|
|
real_dir.rmdir()
|