| 1 | """Near-declarative scenario specs: one loader for scenarios/*.yaml.
|
| 2 |
|
| 3 | A ScenarioSpec is the lane-agnostic shape of a scenario: immutable id
|
| 4 | (ADR-0005), optional fixture-registry reference (validated against
|
| 5 | config/fixtures.json ids), the operation vocabulary, declarative checks,
|
| 6 | modes, and a runtime class for runner placement.
|
| 7 |
|
| 8 | Parsing reuses the same minimal YAML-subset reader the lanes already use
|
| 9 | (scripts/mount_probe.parse_yaml_subset, also imported by
|
| 10 | platform_lifecycle) so a spec that loads here loads identically in a lane.
|
| 11 |
|
| 12 | MIGRATION STATUS: existing lanes do NOT load their specs through this
|
| 13 | module yet. Each lane keeps its current reader until a parity-gated
|
| 14 | migration (row_parity proof per lane, zero invariant drift) lands; this
|
| 15 | module only has to agree with what the lanes parse today. New
|
| 16 | near-declarative lanes should start here.
|
| 17 | """
|
| 18 |
|
| 19 | from __future__ import annotations
|
| 20 |
|
| 21 | import json
|
| 22 | from dataclasses import dataclass, field
|
| 23 | from pathlib import Path
|
| 24 | from typing import Any
|
| 25 |
|
| 26 | # scripts/ is on sys.path wherever oakbench is importable (oakbench lives in
|
| 27 | # scripts/), so the lanes' shared mini-parser imports as a top-level module.
|
| 28 | from mount_probe import parse_yaml_subset
|
| 29 |
|
| 30 | ROOT = Path(__file__).resolve().parents[2]
|
| 31 | DEFAULT_FIXTURES = ROOT / "config" / "fixtures.json"
|
| 32 |
|
| 33 | RUNTIME_CLASSES = ("micro", "standard", "long")
|
| 34 | DEFAULT_RUNTIME_CLASS = "standard"
|
| 35 |
|
| 36 |
|
| 37 | @dataclass(frozen=True)
|
| 38 | class ScenarioSpec:
|
| 39 | """One scenario, declaratively: identity plus what it runs and checks."""
|
| 40 |
|
| 41 | id: str
|
| 42 | fixture: str | None
|
| 43 | operations: tuple[str, ...]
|
| 44 | checks: dict[str, Any] = field(default_factory=dict)
|
| 45 | modes: tuple[str, ...] = ()
|
| 46 | runtime_class: str = DEFAULT_RUNTIME_CLASS
|
| 47 | extras: dict[str, Any] = field(default_factory=dict)
|
| 48 |
|
| 49 |
|
| 50 | def _fail(path: Path, scenario: str | None, message: str) -> ValueError:
|
| 51 | where = f"{path}" if scenario is None else f"{path} (scenario {scenario!r})"
|
| 52 | return ValueError(f"{where}: {message}")
|
| 53 |
|
| 54 |
|
| 55 | def _registered_fixture_ids(fixtures_path: Path) -> set[str]:
|
| 56 | data = json.loads(fixtures_path.read_text())
|
| 57 | fixtures = data.get("fixtures")
|
| 58 | if not isinstance(fixtures, dict):
|
| 59 | raise ValueError(f"{fixtures_path}: expected a 'fixtures' object")
|
| 60 | return set(fixtures)
|
| 61 |
|
| 62 |
|
| 63 | def _string_list(path: Path, scenario: str, key: str, value: Any, *, required: bool) -> tuple[str, ...]:
|
| 64 | if value is None:
|
| 65 | if required:
|
| 66 | raise _fail(path, scenario, f"missing required key {key!r}")
|
| 67 | return ()
|
| 68 | if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
|
| 69 | raise _fail(path, scenario, f"{key!r} must be a list of non-empty strings, got {value!r}")
|
| 70 | if required and not value:
|
| 71 | raise _fail(path, scenario, f"{key!r} must not be empty")
|
| 72 | return tuple(value)
|
| 73 |
|
| 74 |
|
| 75 | _KNOWN_KEYS = {"id", "name", "fixture", "operations", "checks", "modes", "runtime_class"}
|
| 76 |
|
| 77 |
|
| 78 | def _parse_scenario(path: Path, raw: Any, fixture_ids: set[str]) -> ScenarioSpec:
|
| 79 | if not isinstance(raw, dict):
|
| 80 | raise _fail(path, None, f"each scenario must be a mapping, got {raw!r}")
|
| 81 |
|
| 82 | # Existing specs (scenarios/platform.yaml and friends) use "name" for the
|
| 83 | # immutable scenario identity; near-declarative specs may say "id".
|
| 84 | spec_id = raw.get("id", raw.get("name"))
|
| 85 | if not isinstance(spec_id, str) or not spec_id:
|
| 86 | raise _fail(path, None, f"scenario missing 'id' (or legacy 'name'): {raw!r}")
|
| 87 |
|
| 88 | fixture = raw.get("fixture")
|
| 89 | if fixture is not None:
|
| 90 | if not isinstance(fixture, str) or not fixture:
|
| 91 | raise _fail(path, spec_id, f"'fixture' must be null or a fixture-registry id, got {fixture!r}")
|
| 92 | if fixture not in fixture_ids:
|
| 93 | known = ", ".join(sorted(fixture_ids))
|
| 94 | raise _fail(path, spec_id, f"unknown fixture ref {fixture!r}; registered ids: {known}")
|
| 95 |
|
| 96 | operations = _string_list(path, spec_id, "operations", raw.get("operations"), required=True)
|
| 97 | modes = _string_list(path, spec_id, "modes", raw.get("modes"), required=False)
|
| 98 |
|
| 99 | checks = raw.get("checks")
|
| 100 | if checks is None:
|
| 101 | checks = {}
|
| 102 | if not isinstance(checks, dict):
|
| 103 | raise _fail(path, spec_id, f"'checks' must be a mapping, got {checks!r}")
|
| 104 |
|
| 105 | runtime_class = raw.get("runtime_class", DEFAULT_RUNTIME_CLASS)
|
| 106 | if runtime_class not in RUNTIME_CLASSES:
|
| 107 | raise _fail(
|
| 108 | path, spec_id,
|
| 109 | f"'runtime_class' must be one of {RUNTIME_CLASSES}, got {runtime_class!r}",
|
| 110 | )
|
| 111 |
|
| 112 | extras = {key: value for key, value in raw.items() if key not in _KNOWN_KEYS}
|
| 113 | return ScenarioSpec(
|
| 114 | id=spec_id,
|
| 115 | fixture=fixture,
|
| 116 | operations=operations,
|
| 117 | checks=checks,
|
| 118 | modes=modes,
|
| 119 | runtime_class=runtime_class,
|
| 120 | extras=extras,
|
| 121 | )
|
| 122 |
|
| 123 |
|
| 124 | def load_scenario_specs(path: Path, fixtures_path: Path = DEFAULT_FIXTURES) -> list[ScenarioSpec]:
|
| 125 | """Load every scenario in a scenarios/*.yaml spec as ScenarioSpec values.
|
| 126 |
|
| 127 | Raises ValueError with the spec path (and scenario id once known) in the
|
| 128 | message for any structural problem: unparseable spec, missing id,
|
| 129 | missing/empty operations, unknown fixture ref, bad runtime_class.
|
| 130 | """
|
| 131 | path = Path(path)
|
| 132 | if not path.is_file():
|
| 133 | raise ValueError(f"{path}: scenario spec not found")
|
| 134 | try:
|
| 135 | document = parse_yaml_subset(path.read_text())
|
| 136 | except Exception as exc:
|
| 137 | raise ValueError(f"{path}: could not parse scenario spec: {exc}") from exc
|
| 138 |
|
| 139 | raw_scenarios = document.get("scenarios")
|
| 140 | if not isinstance(raw_scenarios, list) or not raw_scenarios:
|
| 141 | raise ValueError(f"{path}: expected a non-empty top-level 'scenarios' list")
|
| 142 |
|
| 143 | fixture_ids = _registered_fixture_ids(Path(fixtures_path))
|
| 144 | specs = [_parse_scenario(path, raw, fixture_ids) for raw in raw_scenarios]
|
| 145 |
|
| 146 | seen: set[str] = set()
|
| 147 | for spec in specs:
|
| 148 | if spec.id in seen:
|
| 149 | raise ValueError(f"{path}: duplicate scenario id {spec.id!r} (ADR-0005: ids are immutable identity)")
|
| 150 | seen.add(spec.id)
|
| 151 | return specs
|