Viewing files at commit f0c34b306c79Commit detailsView latest

Browse files
scripts/oakbench/scenario_spec.py 151 lines · 5.9 KB · python
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