File size: 4,920 Bytes
a464cc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
"""POST /api/what-if-replay deterministic re-projection (Phase 4 task 4.M3b).

Spec at docs/wave-41-backend-spec-handoff.md L91-150.

Determinism contract:
  - Same (baseline_fixture_id, mutation_key) MUST produce byte-identical
    replayed_violation_log per violations.py to_text() output.
  - Backend MUST use the same V2 cvxpylayers projector instance + the
    same friction-ellipse coefficients as /api/forecast.

The mutation catalogue is the minimum frontend the wave-41 spec
references; new mutations land here as new keys + a `.apply()` function.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Callable, Final

import numpy as np

from apex.physics.projection import CvxpyLayersProjector
from apex.shared.contracts import (
    CHANNEL_COUNT,
    HORIZON,
    PhysicsViolationLog,
    PROTOCOL_VERSION,
    SCHEMA_VERSION,
    channel_index,
)


class UnknownFixtureError(ValueError):
    """400 surface: baseline_fixture_id not in BASELINE_FIXTURES."""


class UnknownMutationError(ValueError):
    """400 surface: mutation_key not in MUTATIONS."""


def _build_jerk_bound_fixture() -> np.ndarray:
    """Minimal fixture producing a friction-ellipse violation under V2.

    Mirrors the C14-04 jerk-bound fixture the frontend stub references.
    The actual jerk-bound check is Tier-8 kinematic; for V2 projector
    re-projection we surface the friction-ellipse hit as the visible
    log line.
    """
    f = np.zeros((HORIZON, CHANNEL_COUNT), dtype=np.float32)
    f[10, channel_index("long_g")] = 1.5
    f[10, channel_index("speed_mps")] = 40.0
    f[10, channel_index("coa_overlap_flag")] = 1.0
    return f


BASELINE_FIXTURES: Final[dict[str, dict[str, Any]]] = {
    "C14-04-jerk-bound": {
        "id": "C14-04-jerk-bound",
        "label": "Convergence-14 jerk-bound fixture (C14-04)",
        "build_forecast": _build_jerk_bound_fixture,
    },
}


def _mutation_coa_overlap_invert(forecast: np.ndarray) -> np.ndarray:
    """Invert the COA simultaneity channel from 1.0 -> 0.0 (or vice versa).

    Produces a counterfactual "what if the COA did not permit overlap"
    scenario; the validator will then flag any brake+throttle overlap
    that survives the re-projection.
    """
    mutated = forecast.copy()
    idx = channel_index("coa_overlap_flag")
    mutated[:, idx] = 1.0 - mutated[:, idx]
    return mutated


MUTATIONS: Final[dict[str, Callable[[np.ndarray], np.ndarray]]] = {
    "MUTATION_COA_OVERLAP_INVERT": _mutation_coa_overlap_invert,
}


@dataclass(frozen=True)
class ReplayResult:
    mutated_fixture: dict[str, Any]
    replayed_violation_log: PhysicsViolationLog
    schema_version: str
    protocol_version: str


# Module-level projector. Single instance per process so the
# cvxpylayers DPP-compiled problem is reused across calls (matches the
# /api/forecast determinism contract per spec L130-138).
_projector_singleton: CvxpyLayersProjector | None = None


def _get_projector() -> CvxpyLayersProjector:
    global _projector_singleton
    if _projector_singleton is None:
        _projector_singleton = CvxpyLayersProjector()
    return _projector_singleton


def run_what_if_replay(
    *,
    baseline_fixture_id: str,
    mutation_key: str,
) -> ReplayResult:
    """Run the V2 projector over the mutated fixture; return the
    re-projected violation log.

    Determinism: caller may call this function any number of times
    with the same arguments and receive byte-identical
    `replayed_violation_log.to_text()` output. The cvxpylayers solve
    is itself deterministic given the same DPP-compiled problem +
    same input tensor; the singleton + fixed-fixture path guarantees
    those invariants.
    """
    if baseline_fixture_id not in BASELINE_FIXTURES:
        raise UnknownFixtureError(
            f"baseline_fixture_id {baseline_fixture_id!r} not in "
            f"BASELINE_FIXTURES; known keys: {sorted(BASELINE_FIXTURES.keys())}"
        )
    if mutation_key not in MUTATIONS:
        raise UnknownMutationError(
            f"mutation_key {mutation_key!r} not in MUTATIONS; known keys: "
            f"{sorted(MUTATIONS.keys())}"
        )

    import torch

    fixture = BASELINE_FIXTURES[baseline_fixture_id]
    baseline = fixture["build_forecast"]()
    mutated = MUTATIONS[mutation_key](baseline)
    tensor = torch.from_numpy(mutated).unsqueeze(0).float()
    result = _get_projector().project(tensor)
    return ReplayResult(
        mutated_fixture={
            "id": baseline_fixture_id,
            "mutation": mutation_key,
            "shape": list(mutated.shape),
        },
        replayed_violation_log=result.violation_log,
        schema_version=SCHEMA_VERSION,
        protocol_version=PROTOCOL_VERSION,
    )


__all__ = [
    "BASELINE_FIXTURES",
    "MUTATIONS",
    "ReplayResult",
    "UnknownFixtureError",
    "UnknownMutationError",
    "run_what_if_replay",
]