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
657 lines
23 KiB
Python
657 lines
23 KiB
Python
"""
|
||
Centralized schema definitions for synthetic testing fixtures.
|
||
|
||
All scenario fixture files (alert.json, aws_cloudwatch_metrics.json, aws_rds_events.json,
|
||
aws_performance_insights.json, answer.yml, scenario.yml) must conform to these TypedDicts.
|
||
Validators enforce required fields so every scenario is structurally consistent.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Any, NotRequired
|
||
|
||
from typing_extensions import TypedDict
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Controlled vocabularies for scenario metadata
|
||
# ---------------------------------------------------------------------------
|
||
|
||
VALID_ENGINES = frozenset({"postgres", "mysql", "aurora-postgres", "aurora-mysql", "mariadb"})
|
||
VALID_FAILURE_MODES = frozenset(
|
||
{
|
||
"replication_lag",
|
||
"connection_exhaustion",
|
||
"storage_full",
|
||
"cpu_saturation",
|
||
"failover",
|
||
"healthy",
|
||
"application_load_spike",
|
||
}
|
||
)
|
||
VALID_EVIDENCE_SOURCES = frozenset(
|
||
{
|
||
"aws_cloudwatch_metrics",
|
||
"aws_rds_events",
|
||
"aws_performance_insights",
|
||
"ec2_instances_by_tag",
|
||
"elb_target_health",
|
||
"k8s_events",
|
||
"k8s_pod_metrics",
|
||
"k8s_node_metrics",
|
||
"k8s_dns_metrics",
|
||
"k8s_mesh_metrics",
|
||
"k8s_rollout",
|
||
}
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Alert fixture (alert.json)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class AlertLabels(TypedDict, total=False):
|
||
alertname: str
|
||
severity: str
|
||
pipeline_name: str
|
||
service: str
|
||
engine: str
|
||
|
||
|
||
class AlertAnnotations(TypedDict, total=False):
|
||
summary: str
|
||
error: str
|
||
suspected_symptom: str
|
||
db_instance_identifier: str
|
||
db_instance: str
|
||
db_cluster: str
|
||
read_replica: str
|
||
cloudwatch_region: str
|
||
rds_failure_mode: str
|
||
context_sources: str
|
||
|
||
|
||
class AlertFixture(TypedDict):
|
||
title: str
|
||
state: str
|
||
alert_source: str
|
||
commonLabels: AlertLabels
|
||
commonAnnotations: AlertAnnotations
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CloudWatch metrics fixture (aws_cloudwatch_metrics.json)
|
||
# Models the AWS GetMetricData API response shape.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class MetricDimension(TypedDict):
|
||
Name: str
|
||
Value: str
|
||
|
||
|
||
class MetricDataResult(TypedDict):
|
||
"""One metric query result, combining query context with response data."""
|
||
|
||
id: str
|
||
label: str
|
||
metric_name: str
|
||
dimensions: list[MetricDimension]
|
||
stat: str
|
||
unit: str
|
||
status_code: str
|
||
timestamps: list[str]
|
||
values: list[float]
|
||
|
||
|
||
class CloudWatchMetricsFixture(TypedDict):
|
||
namespace: str
|
||
period: int
|
||
start_time: str
|
||
end_time: str
|
||
metric_data_results: list[MetricDataResult]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# RDS events fixture (aws_rds_events.json)
|
||
# Models the AWS DescribeEvents API response shape.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class RDSEvent(TypedDict):
|
||
date: str
|
||
message: str
|
||
source_identifier: str
|
||
source_type: str
|
||
event_categories: list[str]
|
||
|
||
|
||
class RDSEventsFixture(TypedDict):
|
||
events: list[RDSEvent]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Performance insights fixture (aws_performance_insights.json)
|
||
# Models the AWS GetResourceMetrics + DescribeDimensionKeys API response shape.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class DBLoadTimeSeries(TypedDict):
|
||
timestamps: list[str]
|
||
values: list[float]
|
||
unit: str
|
||
|
||
|
||
class TopSQLWaitEvent(TypedDict):
|
||
name: str
|
||
type: str
|
||
db_load_avg: float
|
||
|
||
|
||
class TopSQL(TypedDict):
|
||
statement: str
|
||
db_load_avg: float
|
||
wait_events: list[TopSQLWaitEvent]
|
||
calls_per_sec: float
|
||
|
||
|
||
class TopWaitEvent(TypedDict):
|
||
name: str
|
||
type: str
|
||
db_load_avg: float
|
||
|
||
|
||
class TopUser(TypedDict):
|
||
name: str
|
||
db_load_avg: float
|
||
|
||
|
||
class TopHost(TypedDict):
|
||
id: str
|
||
db_load_avg: float
|
||
|
||
|
||
class PerformanceInsightsFixture(TypedDict):
|
||
db_instance_identifier: str
|
||
start_time: str
|
||
end_time: str
|
||
db_load: DBLoadTimeSeries
|
||
top_sql: list[TopSQL]
|
||
top_wait_events: list[TopWaitEvent]
|
||
top_users: list[TopUser]
|
||
top_hosts: list[TopHost]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Answer key (answer.yml)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
VALID_TRAJECTORY_ACTIONS = frozenset(
|
||
{
|
||
"query_grafana_metrics",
|
||
"query_grafana_logs",
|
||
"query_grafana_alert_rules",
|
||
"describe_rds_instance",
|
||
"describe_rds_events",
|
||
"ec2_instances_by_tag",
|
||
"get_elb_target_health",
|
||
}
|
||
)
|
||
|
||
|
||
class AnswerKeySchema(TypedDict):
|
||
root_cause_category: str
|
||
required_keywords: list[str]
|
||
model_response: str
|
||
# Additional categories that pass the category gate alongside root_cause_category
|
||
equivalent_root_cause_categories: NotRequired[list[str]]
|
||
# Optional adversarial constraints (level 2+ scenarios)
|
||
forbidden_categories: NotRequired[list[str]] # root_cause_category must NOT be any of these
|
||
forbidden_keywords: NotRequired[list[str]] # none of these may appear in evidence_text
|
||
required_evidence_sources: NotRequired[
|
||
list[str]
|
||
] # these keys must be non-empty in final_state["evidence"]
|
||
# Trajectory efficiency (Axis 1)
|
||
optimal_trajectory: NotRequired[list[str]] # ordered action names the agent should call
|
||
max_investigation_loops: NotRequired[int] # how many investigation loops is acceptable
|
||
# Adversarial reasoning (Axis 2)
|
||
ruling_out_keywords: NotRequired[
|
||
list[str]
|
||
] # agent output must contain these tokens (proof it dismissed alternatives)
|
||
required_queries: NotRequired[
|
||
list[str]
|
||
] # metric names agent must have specifically requested via query_timeseries
|
||
golden_trajectory: NotRequired[GoldenTrajectorySchema]
|
||
|
||
|
||
class GoldenTrajectorySchema(TypedDict, total=False):
|
||
ordered_actions: list[str]
|
||
matching: str
|
||
max_edit_distance: int
|
||
max_extra_actions: int
|
||
max_redundancy: int
|
||
max_loops: int
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Scenario metadata (scenario.yml)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TopologyTier(TypedDict):
|
||
"""Application tier description for EC2/RDS topology fixtures."""
|
||
|
||
name: str
|
||
instance_ids: list[str]
|
||
asg: NotRequired[str]
|
||
|
||
|
||
class TopologyMetadata(TypedDict, total=False):
|
||
"""Optional EC2/RDS topology block for non-K8s scenarios.
|
||
|
||
Present in fixtures that exercise the DNS → LB → Target Group → EC2 → RDS
|
||
request path. Absent in legacy RDS-only scenarios (000–014).
|
||
"""
|
||
|
||
vpc_id: str
|
||
load_balancer_arn: str
|
||
target_group_arn: str
|
||
tiers: list[TopologyTier]
|
||
|
||
|
||
class ScenarioMetadataSchema(TypedDict):
|
||
schema_version: str
|
||
scenario_id: str
|
||
engine: str
|
||
engine_version: str
|
||
instance_class: str
|
||
region: str
|
||
db_instance_identifier: str
|
||
failure_mode: str
|
||
severity: str
|
||
available_evidence: list[str]
|
||
db_cluster: NotRequired[str]
|
||
scenario_difficulty: NotRequired[int] # 1–4 curriculum level
|
||
adversarial_signals: NotRequired[list[str]] # metrics that are intentional confounders
|
||
depends_on: NotRequired[str] # e.g. "healthy_rca_state" — CI skip flag
|
||
topology: NotRequired[TopologyMetadata]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Typed evidence container
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class EC2Instance(TypedDict, total=False):
|
||
instance_id: str
|
||
tier: str
|
||
asg: str
|
||
private_ip: str
|
||
vpc_id: str
|
||
subnet_id: str
|
||
state: str
|
||
instance_type: str
|
||
security_groups: list[str]
|
||
|
||
|
||
class EC2InstancesByTagFixture(TypedDict):
|
||
instances: list[EC2Instance]
|
||
|
||
|
||
class ELBTargetHealthEntry(TypedDict, total=False):
|
||
target_group_arn: str
|
||
instance_id: str
|
||
port: int
|
||
state: str
|
||
reason: str
|
||
description: str
|
||
|
||
|
||
class ELBTargetGroup(TypedDict, total=False):
|
||
TargetGroupArn: str
|
||
TargetGroupName: str
|
||
LoadBalancerArns: list[str]
|
||
|
||
|
||
class ELBTargetHealthFixture(TypedDict):
|
||
target_groups: list[ELBTargetGroup]
|
||
targets: list[ELBTargetHealthEntry]
|
||
|
||
|
||
class GenericEvidenceFixture(TypedDict, total=False):
|
||
"""Flexible schema for suite-specific evidence not strongly typed here."""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ScenarioEvidence:
|
||
"""Typed container for all evidence sources in a scenario fixture.
|
||
|
||
Each attribute is None when the corresponding file was not listed in
|
||
scenario.yml:available_evidence, making evidence presence explicit.
|
||
"""
|
||
|
||
aws_cloudwatch_metrics: CloudWatchMetricsFixture | None
|
||
aws_rds_events: list[RDSEvent] | None
|
||
aws_performance_insights: PerformanceInsightsFixture | None
|
||
ec2_instances_by_tag: EC2InstancesByTagFixture | None = None
|
||
elb_target_health: ELBTargetHealthFixture | None = None
|
||
k8s_events: GenericEvidenceFixture | None = None
|
||
k8s_pod_metrics: GenericEvidenceFixture | None = None
|
||
k8s_node_metrics: GenericEvidenceFixture | None = None
|
||
k8s_dns_metrics: GenericEvidenceFixture | None = None
|
||
k8s_mesh_metrics: GenericEvidenceFixture | None = None
|
||
k8s_rollout: GenericEvidenceFixture | None = None
|
||
|
||
def as_dict(self) -> dict[str, Any]:
|
||
"""Return only the non-None sources as a plain dict."""
|
||
result: dict[str, Any] = {}
|
||
if self.aws_cloudwatch_metrics is not None:
|
||
result["aws_cloudwatch_metrics"] = self.aws_cloudwatch_metrics
|
||
if self.aws_rds_events is not None:
|
||
result["aws_rds_events"] = self.aws_rds_events
|
||
if self.aws_performance_insights is not None:
|
||
result["aws_performance_insights"] = self.aws_performance_insights
|
||
if self.ec2_instances_by_tag is not None:
|
||
result["ec2_instances_by_tag"] = self.ec2_instances_by_tag
|
||
if self.elb_target_health is not None:
|
||
result["elb_target_health"] = self.elb_target_health
|
||
if self.k8s_events is not None:
|
||
result["k8s_events"] = self.k8s_events
|
||
if self.k8s_pod_metrics is not None:
|
||
result["k8s_pod_metrics"] = self.k8s_pod_metrics
|
||
if self.k8s_node_metrics is not None:
|
||
result["k8s_node_metrics"] = self.k8s_node_metrics
|
||
if self.k8s_dns_metrics is not None:
|
||
result["k8s_dns_metrics"] = self.k8s_dns_metrics
|
||
if self.k8s_mesh_metrics is not None:
|
||
result["k8s_mesh_metrics"] = self.k8s_mesh_metrics
|
||
if self.k8s_rollout is not None:
|
||
result["k8s_rollout"] = self.k8s_rollout
|
||
return result
|
||
|
||
def get(self, key: str) -> Any:
|
||
return self.as_dict().get(key)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Validators — raise ValueError with a descriptive message on bad data
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def validate_alert(data: dict[str, Any]) -> AlertFixture:
|
||
_require_str(data, "title", ctx="alert.json")
|
||
_require_str(data, "state", ctx="alert.json")
|
||
_require_str(data, "alert_source", ctx="alert.json")
|
||
if not isinstance(data.get("commonLabels"), dict):
|
||
raise ValueError("alert.json: 'commonLabels' must be an object")
|
||
if not isinstance(data.get("commonAnnotations"), dict):
|
||
raise ValueError("alert.json: 'commonAnnotations' must be an object")
|
||
return data # type: ignore[return-value]
|
||
|
||
|
||
def validate_cloudwatch_metrics(data: dict[str, Any]) -> CloudWatchMetricsFixture:
|
||
ctx = "aws_cloudwatch_metrics.json"
|
||
_require_str(data, "namespace", ctx=ctx)
|
||
_require_str(data, "start_time", ctx=ctx)
|
||
_require_str(data, "end_time", ctx=ctx)
|
||
if not isinstance(data.get("period"), int):
|
||
raise ValueError(f"{ctx}: 'period' must be an integer (seconds)")
|
||
results = data.get("metric_data_results")
|
||
if not isinstance(results, list) or not results:
|
||
raise ValueError(f"{ctx}: 'metric_data_results' must be a non-empty list")
|
||
for i, result in enumerate(results):
|
||
rctx = f"{ctx}:metric_data_results[{i}]"
|
||
for field in ("id", "label", "metric_name", "stat", "unit", "status_code"):
|
||
_require_str(result, field, ctx=rctx)
|
||
if not isinstance(result.get("dimensions"), list):
|
||
raise ValueError(f"{rctx}: 'dimensions' must be a list")
|
||
for dim in result["dimensions"]:
|
||
_require_str(dim, "Name", ctx=rctx)
|
||
_require_str(dim, "Value", ctx=rctx)
|
||
if not isinstance(result.get("timestamps"), list):
|
||
raise ValueError(f"{rctx}: 'timestamps' must be a list")
|
||
if not isinstance(result.get("values"), list):
|
||
raise ValueError(f"{rctx}: 'values' must be a list")
|
||
if len(result["timestamps"]) != len(result["values"]):
|
||
raise ValueError(f"{rctx}: 'timestamps' and 'values' must have the same length")
|
||
return data # type: ignore[return-value]
|
||
|
||
|
||
def validate_rds_events(data: dict[str, Any]) -> RDSEventsFixture:
|
||
if not isinstance(data.get("events"), list):
|
||
raise ValueError("aws_rds_events.json: 'events' must be a list")
|
||
for i, event in enumerate(data["events"]):
|
||
ctx = f"aws_rds_events.json:events[{i}]"
|
||
_require_str(event, "date", ctx=ctx)
|
||
_require_str(event, "message", ctx=ctx)
|
||
_require_str(event, "source_identifier", ctx=ctx)
|
||
_require_str(event, "source_type", ctx=ctx)
|
||
if not isinstance(event.get("event_categories"), list):
|
||
raise ValueError(f"{ctx}: 'event_categories' must be a list")
|
||
return data # type: ignore[return-value]
|
||
|
||
|
||
def validate_performance_insights(data: dict[str, Any]) -> PerformanceInsightsFixture:
|
||
ctx = "aws_performance_insights.json"
|
||
_require_str(data, "db_instance_identifier", ctx=ctx)
|
||
_require_str(data, "start_time", ctx=ctx)
|
||
_require_str(data, "end_time", ctx=ctx)
|
||
db_load = data.get("db_load")
|
||
if not isinstance(db_load, dict):
|
||
raise ValueError(f"{ctx}: 'db_load' must be an object")
|
||
if not isinstance(db_load.get("timestamps"), list):
|
||
raise ValueError(f"{ctx}: 'db_load.timestamps' must be a list")
|
||
if not isinstance(db_load.get("values"), list):
|
||
raise ValueError(f"{ctx}: 'db_load.values' must be a list")
|
||
if len(db_load["timestamps"]) != len(db_load["values"]):
|
||
raise ValueError(
|
||
f"{ctx}: 'db_load.timestamps' and 'db_load.values' must have the same length"
|
||
)
|
||
if not isinstance(data.get("top_sql"), list):
|
||
raise ValueError(f"{ctx}: 'top_sql' must be a list")
|
||
if not isinstance(data.get("top_wait_events"), list):
|
||
raise ValueError(f"{ctx}: 'top_wait_events' must be a list")
|
||
if not isinstance(data.get("top_users"), list):
|
||
raise ValueError(f"{ctx}: 'top_users' must be a list")
|
||
if not isinstance(data.get("top_hosts"), list):
|
||
raise ValueError(f"{ctx}: 'top_hosts' must be a list")
|
||
return data # type: ignore[return-value]
|
||
|
||
|
||
def validate_ec2_instances_by_tag(data: dict[str, Any]) -> EC2InstancesByTagFixture:
|
||
ctx = "ec2_instances_by_tag.json"
|
||
instances = data.get("instances")
|
||
if not isinstance(instances, list):
|
||
raise ValueError(f"{ctx}: 'instances' must be a list")
|
||
for i, inst in enumerate(instances):
|
||
ictx = f"{ctx}:instances[{i}]"
|
||
if not isinstance(inst, dict):
|
||
raise ValueError(f"{ictx}: must be an object")
|
||
_require_str(inst, "instance_id", ctx=ictx)
|
||
return data # type: ignore[return-value]
|
||
|
||
|
||
def validate_elb_target_health(data: dict[str, Any]) -> ELBTargetHealthFixture:
|
||
ctx = "elb_target_health.json"
|
||
if not isinstance(data.get("target_groups"), list):
|
||
raise ValueError(f"{ctx}: 'target_groups' must be a list")
|
||
if not isinstance(data.get("targets"), list):
|
||
raise ValueError(f"{ctx}: 'targets' must be a list")
|
||
for i, target in enumerate(data["targets"]):
|
||
tctx = f"{ctx}:targets[{i}]"
|
||
if not isinstance(target, dict):
|
||
raise ValueError(f"{tctx}: must be an object")
|
||
_require_str(target, "instance_id", ctx=tctx)
|
||
_require_str(target, "state", ctx=tctx)
|
||
return data # type: ignore[return-value]
|
||
|
||
|
||
def validate_generic_evidence(data: dict[str, Any], *, filename: str) -> GenericEvidenceFixture:
|
||
if not isinstance(data, dict):
|
||
raise ValueError(f"{filename}: expected an object")
|
||
return data # type: ignore[return-value]
|
||
|
||
|
||
def validate_answer_key(data: dict[str, Any]) -> AnswerKeySchema:
|
||
_require_str(data, "root_cause_category", ctx="answer.yml")
|
||
_require_non_empty_str_list(data, "required_keywords", "answer.yml", required=True)
|
||
_require_str(data, "model_response", ctx="answer.yml")
|
||
for opt_list_field in (
|
||
"forbidden_categories",
|
||
"forbidden_keywords",
|
||
"required_evidence_sources",
|
||
"equivalent_root_cause_categories",
|
||
):
|
||
val = data.get(opt_list_field)
|
||
if val is not None and not isinstance(val, list):
|
||
raise ValueError(f"answer.yml: '{opt_list_field}' must be a list when present")
|
||
required_sources = data.get("required_evidence_sources")
|
||
if required_sources is not None and required_sources:
|
||
if not all(isinstance(item, str) and item.strip() for item in required_sources):
|
||
raise ValueError(
|
||
"answer.yml: 'required_evidence_sources' must contain only non-empty strings"
|
||
)
|
||
unknown_sources = [item for item in required_sources if item not in VALID_EVIDENCE_SOURCES]
|
||
if unknown_sources:
|
||
raise ValueError(
|
||
"answer.yml: unknown source(s) in required_evidence_sources "
|
||
f"{unknown_sources}; expected subset of {sorted(VALID_EVIDENCE_SOURCES)}"
|
||
)
|
||
equiv = data.get("equivalent_root_cause_categories")
|
||
if (
|
||
equiv is not None
|
||
and equiv
|
||
and not all(isinstance(item, str) and item.strip() for item in equiv)
|
||
):
|
||
raise ValueError(
|
||
"answer.yml: 'equivalent_root_cause_categories' "
|
||
"must contain only non-empty strings when present"
|
||
)
|
||
trajectory = data.get("optimal_trajectory")
|
||
if trajectory is not None:
|
||
if not isinstance(trajectory, list) or not trajectory:
|
||
raise ValueError(
|
||
"answer.yml: 'optimal_trajectory' must be a non-empty list when present"
|
||
)
|
||
unknown_actions = [a for a in trajectory if a not in VALID_TRAJECTORY_ACTIONS]
|
||
if unknown_actions:
|
||
raise ValueError(
|
||
f"answer.yml: unknown action(s) in optimal_trajectory {unknown_actions}; "
|
||
f"expected subset of {sorted(VALID_TRAJECTORY_ACTIONS)}"
|
||
)
|
||
max_loops = data.get("max_investigation_loops")
|
||
if max_loops is not None and (not isinstance(max_loops, int) or max_loops < 1):
|
||
raise ValueError(
|
||
"answer.yml: 'max_investigation_loops' must be a positive integer when present"
|
||
)
|
||
for axis2_list_field in ("ruling_out_keywords", "required_queries"):
|
||
_require_non_empty_str_list(data, axis2_list_field, "answer.yml")
|
||
golden = data.get("golden_trajectory")
|
||
if golden is not None:
|
||
if not isinstance(golden, dict):
|
||
raise ValueError("answer.yml: 'golden_trajectory' must be an object when present")
|
||
ordered_actions = golden.get("ordered_actions")
|
||
if ordered_actions is not None:
|
||
if (
|
||
not isinstance(ordered_actions, list)
|
||
or not ordered_actions
|
||
or not all(isinstance(action, str) and action.strip() for action in ordered_actions)
|
||
):
|
||
raise ValueError(
|
||
"answer.yml: 'golden_trajectory.ordered_actions' must be a non-empty list "
|
||
"of strings when present"
|
||
)
|
||
unknown_actions = [a for a in ordered_actions if a not in VALID_TRAJECTORY_ACTIONS]
|
||
if unknown_actions:
|
||
raise ValueError(
|
||
"answer.yml: unknown action(s) in golden_trajectory.ordered_actions "
|
||
f"{unknown_actions}; expected subset of {sorted(VALID_TRAJECTORY_ACTIONS)}"
|
||
)
|
||
matching = golden.get("matching")
|
||
if matching is not None and matching not in {"strict", "lcs", "set"}:
|
||
raise ValueError(
|
||
"answer.yml: 'golden_trajectory.matching' must be one of "
|
||
"'strict', 'lcs', or 'set' when present"
|
||
)
|
||
for int_field in ("max_edit_distance", "max_extra_actions", "max_redundancy", "max_loops"):
|
||
value = golden.get(int_field)
|
||
if value is not None and (not isinstance(value, int) or value < 0):
|
||
raise ValueError(
|
||
f"answer.yml: 'golden_trajectory.{int_field}' must be a non-negative integer "
|
||
"when present"
|
||
)
|
||
return data # type: ignore[return-value]
|
||
|
||
|
||
def validate_scenario_metadata(data: dict[str, Any]) -> ScenarioMetadataSchema:
|
||
ctx = "scenario.yml"
|
||
for field in (
|
||
"schema_version",
|
||
"scenario_id",
|
||
"engine",
|
||
"engine_version",
|
||
"instance_class",
|
||
"region",
|
||
"db_instance_identifier",
|
||
"failure_mode",
|
||
"severity",
|
||
):
|
||
_require_str(data, field, ctx=ctx)
|
||
|
||
engine = data["engine"]
|
||
if engine not in VALID_ENGINES:
|
||
raise ValueError(
|
||
f"{ctx}: unknown engine {engine!r}; expected one of {sorted(VALID_ENGINES)}"
|
||
)
|
||
|
||
failure_mode = data["failure_mode"]
|
||
if failure_mode not in VALID_FAILURE_MODES:
|
||
raise ValueError(
|
||
f"{ctx}: unknown failure_mode {failure_mode!r}; expected one of {sorted(VALID_FAILURE_MODES)}"
|
||
)
|
||
|
||
sources = data.get("available_evidence")
|
||
if not isinstance(sources, list) or not sources:
|
||
raise ValueError(f"{ctx}: 'available_evidence' must be a non-empty list")
|
||
unknown = [s for s in sources if s not in VALID_EVIDENCE_SOURCES]
|
||
if unknown:
|
||
raise ValueError(
|
||
f"{ctx}: unknown evidence source(s) {unknown}; expected subset of {sorted(VALID_EVIDENCE_SOURCES)}"
|
||
)
|
||
|
||
return data # type: ignore[return-value]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Internal helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _require_str(obj: dict[str, Any], key: str, ctx: str = "") -> None:
|
||
value = obj.get(key)
|
||
prefix = f"{ctx}: " if ctx else ""
|
||
if not isinstance(value, str) or not value.strip():
|
||
raise ValueError(f"{prefix}missing or empty required string field '{key}'")
|
||
|
||
|
||
def _require_non_empty_str_list(
|
||
obj: dict[str, Any],
|
||
key: str,
|
||
ctx: str,
|
||
*,
|
||
required: bool = False,
|
||
) -> None:
|
||
value = obj.get(key)
|
||
|
||
if value is None:
|
||
if required:
|
||
raise ValueError(f"{ctx}: '{key}' must be a non-empty list")
|
||
return
|
||
|
||
if not isinstance(value, list) or not value:
|
||
raise ValueError(f"{ctx}: '{key}' must be a non-empty list")
|
||
|
||
if not all(isinstance(item, str) and item.strip() for item in value):
|
||
raise ValueError(f"{ctx}: all '{key}' entries must be non-empty strings")
|