test(policy): add round-trip tests for /api/policy/decide state_vector path (#83 Step 2)
Covers:
- health endpoint returns {ready, status, model_loaded}
- precomputed fast path: state_vector float[29] → vector_source=precomputed
- correct 6177-dim tensor shape reaches the mock model
- all 8 MacroAction values are valid responses
- encoder fallback: missing state_vector → vector_source=encoder
- encoder fallback: wrong-length state_vector → vector_source=encoder
- GameSession field name aliases (turnNumber, activePlayer,
commanderTax, handCount, commandZone) are accepted without 422
- 503 when model not loaded
- /api/policy/stats tracks precomputed_vector_hits vs encoder_fallbacks
- both phase strings (MAIN1, main_1) round-trip correctly
Uses a MockPolicyInferenceService that captures the tensor shape passed
to the model so we can assert on dim without needing a real checkpoint.
This commit is contained in:
1 parent
4d73f134c2
commit
7440d0ce17
1 file changed
+530
@@ -0,0 +1,530 @@
|
||||
"""
|
||||
tests/test_policy_decide.py
|
||||
|
||||
Round-trip tests for POST /api/policy/decide introduced in Issue #83 Step 2.
|
||||
|
||||
Coverage:
|
||||
- Health endpoint sanity
|
||||
- Fast path: GameSession snapshot with state_vector → vector_source="precomputed"
|
||||
- Correct 6177-dim tensor reaches the mock model
|
||||
- All 8 MacroAction values are valid in the response
|
||||
- Fallback path: no state_vector → vector_source="encoder"
|
||||
- Fallback path: wrong-length state_vector → vector_source="encoder"
|
||||
- GameSession field name aliases accepted without 422
|
||||
- 503 returned when model not loaded
|
||||
- /api/policy/stats tracks hit/fallback counters
|
||||
- Phase string variants (MAIN1, main_1, COMBAT) round-trip cleanly
|
||||
|
||||
All tests use a MockPolicyInferenceService — no real checkpoint required.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import types
|
||||
from typing import Any, Dict, List, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ml.config.scope import NUM_ACTIONS, IDX_TO_ACTION, STATE_DIMS
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants mirrored from routes/policy.py
|
||||
# ---------------------------------------------------------------------------
|
||||
EXPECTED_GLOBAL_DIM = 29
|
||||
EXPECTED_STATE_DIM = STATE_DIMS.total_state_dim # 6177
|
||||
VALID_MACRO_ACTIONS = {a.value for a in IDX_TO_ACTION.values()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock policy service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MockCardEmbeddingIndex:
|
||||
"""Returns zero vectors for any card name — no NPZ file needed."""
|
||||
_loaded = True
|
||||
_zero_vec = np.zeros(STATE_DIMS.card_embedding_dim, dtype=np.float32)
|
||||
|
||||
def get_embedding(self, name: str) -> np.ndarray:
|
||||
return self._zero_vec
|
||||
|
||||
def mean_pool_zone(self, card_names: List[str]) -> np.ndarray:
|
||||
return self._zero_vec
|
||||
|
||||
|
||||
class MockStateEncoder:
|
||||
"""Real StateEncoder wired to MockCardEmbeddingIndex."""
|
||||
|
||||
def __init__(self):
|
||||
from ml.encoder.state_encoder import StateEncoder
|
||||
self._inner = StateEncoder(MockCardEmbeddingIndex())
|
||||
self.dim = self._inner.dim
|
||||
|
||||
def encode(self, decision: dict, playstyle: str = "midrange") -> np.ndarray:
|
||||
return self._inner.encode(decision, playstyle)
|
||||
|
||||
def _encode_zones(self, players: list) -> np.ndarray:
|
||||
return self._inner._encode_zones(players)
|
||||
|
||||
def _encode_playstyle(self, playstyle: str) -> np.ndarray:
|
||||
return self._inner._encode_playstyle(playstyle)
|
||||
|
||||
|
||||
class MockPolicyInferenceService:
|
||||
"""
|
||||
Stand-in for PolicyInferenceService.
|
||||
|
||||
Records every tensor shape passed to the mock model so tests can
|
||||
assert on dimensions without loading a real checkpoint.
|
||||
|
||||
Always returns action_index=0 (cast_creature) with confidence=0.9.
|
||||
"""
|
||||
_loaded: bool = True
|
||||
device: str = "cpu"
|
||||
|
||||
def __init__(self, loaded: bool = True):
|
||||
self._loaded = loaded
|
||||
self.encoder = MockStateEncoder()
|
||||
self.captured_shapes: List[tuple] = []
|
||||
self._build_mock_model()
|
||||
|
||||
def _build_mock_model(self):
|
||||
"""Build a callable mock that records input shapes and returns logits."""
|
||||
captured = self.captured_shapes
|
||||
import torch
|
||||
|
||||
def fake_forward(tensor):
|
||||
captured.append(tuple(tensor.shape))
|
||||
# Return uniform logits favouring action 0
|
||||
logits = torch.zeros(tensor.shape[0], NUM_ACTIONS)
|
||||
logits[0, 0] = 2.0 # cast_creature wins softmax
|
||||
return logits
|
||||
|
||||
self.model = MagicMock(side_effect=fake_forward)
|
||||
|
||||
def predict(self, decision_snapshot: Dict, playstyle: str = "midrange",
|
||||
temperature: float = 1.0, greedy: bool = False) -> Dict:
|
||||
if not self._loaded:
|
||||
return {"error": "Model not loaded", "detail": "mock"}
|
||||
import torch
|
||||
state_vec = self.encoder.encode(decision_snapshot, playstyle)
|
||||
state_tensor = torch.from_numpy(state_vec.astype(np.float32)).unsqueeze(0)
|
||||
self.captured_shapes.append(tuple(state_tensor.shape))
|
||||
# Always return cast_creature
|
||||
return {
|
||||
"action": IDX_TO_ACTION[0].value,
|
||||
"action_index": 0,
|
||||
"confidence": 0.9,
|
||||
"probabilities": {IDX_TO_ACTION[i].value: (0.9 if i == 0 else 0.1 / (NUM_ACTIONS - 1))
|
||||
for i in range(NUM_ACTIONS)},
|
||||
"inference_ms": 0.1,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_app(loaded: bool = True) -> tuple[FastAPI, MockPolicyInferenceService]:
|
||||
"""Create a fresh FastAPI app with the policy router registered."""
|
||||
from routes.policy import register_policy_routes
|
||||
import routes.policy as policy_mod
|
||||
|
||||
# Reset module-level state between tests
|
||||
policy_mod._policy_service = None
|
||||
policy_mod._online_store = None
|
||||
policy_mod._session_stats = {
|
||||
"total_decisions": 0,
|
||||
"total_tuples_collected": 0,
|
||||
"total_rewards_submitted": 0,
|
||||
"games_completed": 0,
|
||||
"session_start": 0.0,
|
||||
"last_decision_ms": 0.0,
|
||||
"precomputed_vector_hits": 0,
|
||||
"encoder_fallbacks": 0,
|
||||
}
|
||||
|
||||
app = FastAPI()
|
||||
svc = MockPolicyInferenceService(loaded=loaded)
|
||||
register_policy_routes(app, svc)
|
||||
return app, svc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture: a realistic GameSession snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _game_session_snapshot(
|
||||
state_vector: Optional[List[float]] = None,
|
||||
include_aliases: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Mimics GameSession.buildStateSnapshot() output at turn 3, MAIN1.
|
||||
Includes the state_vector field added in Step 1.
|
||||
"""
|
||||
sv = state_vector if state_vector is not None else _valid_state_vector()
|
||||
players = [
|
||||
{
|
||||
"seat": 0,
|
||||
"name": "Human",
|
||||
"isAI": False,
|
||||
"deckName": "Atraxa Superfriends",
|
||||
"life": 37,
|
||||
"poison": 0,
|
||||
"commanderTax": 0,
|
||||
"handCount": 6,
|
||||
"hand": ["Doubling Season", "Deepglow Skate", "Sol Ring",
|
||||
"Atraxa, Praetors' Voice", "Swamp", "Forest"],
|
||||
"battlefield": ["Sol Ring", "Atraxa, Praetors' Voice"],
|
||||
"graveyard": [],
|
||||
"commandZone": ["Atraxa, Praetors' Voice"],
|
||||
"manaPool": {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0},
|
||||
},
|
||||
{
|
||||
"seat": 1,
|
||||
"name": "AI-1",
|
||||
"isAI": True,
|
||||
"deckName": "Ur-Dragon",
|
||||
"life": 40,
|
||||
"poison": 0,
|
||||
"commanderTax": 2,
|
||||
"handCount": 7,
|
||||
"hand": [],
|
||||
"battlefield": ["Dragon's Hoard", "Savage Ventmaw"],
|
||||
"graveyard": ["Swords to Plowshares"],
|
||||
"commandZone": ["The Ur-Dragon"],
|
||||
"manaPool": {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0},
|
||||
},
|
||||
]
|
||||
|
||||
snap: Dict[str, Any] = {
|
||||
"schema": "1.1.0",
|
||||
"phase": "MAIN1",
|
||||
"turnNumber": 3,
|
||||
"activePlayer": 0,
|
||||
"priorityPlayer": 0,
|
||||
"awaitingInput": True,
|
||||
"players": players,
|
||||
"stack": [],
|
||||
"legalActions": [
|
||||
{"type": "PASS_PRIORITY", "label": "Pass Priority"},
|
||||
{"type": "CAST_SPELL", "cardId": "Sol Ring", "label": "Cast Sol Ring"},
|
||||
],
|
||||
"playstyle": "midrange",
|
||||
"greedy": False,
|
||||
"state_vector": sv,
|
||||
"state_vector_dim": len(sv),
|
||||
}
|
||||
return snap
|
||||
|
||||
|
||||
def _valid_state_vector() -> List[float]:
|
||||
"""A plausible float[29] global scalar vector from buildStateVector(seat=0)."""
|
||||
# life=37/40, everything else normalised appropriately
|
||||
sv = [0.0] * 29
|
||||
sv[0] = 37 / 40.0 # self life
|
||||
sv[3] = 0.0 # cmdr_tax
|
||||
sv[4] = 6 / 15.0 # cards_in_hand
|
||||
sv[6] = 2 / 30.0 # creatures
|
||||
sv[7] = 2 / 100.0 # power proxy
|
||||
sv[8] = 1.0 # is_active_player
|
||||
sv[9] = 1.0 # phase_onehot[main1]
|
||||
# opponent block: indices 14..27
|
||||
sv[14] = 40 / 40.0 # opp life
|
||||
sv[17] = 2 / 10.0 # opp cmdr_tax
|
||||
sv[18] = 7 / 15.0 # opp hand
|
||||
sv[20] = 2 / 30.0 # opp creatures
|
||||
# turn
|
||||
sv[28] = 3 / 25.0
|
||||
return sv
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestPolicyHealth:
|
||||
def test_health_model_loaded(self):
|
||||
app, _ = _make_app(loaded=True)
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/policy/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ready"] is True
|
||||
assert data["status"] == "ok"
|
||||
assert data["model_loaded"] is True
|
||||
|
||||
def test_health_model_not_loaded(self):
|
||||
app, _ = _make_app(loaded=False)
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/policy/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ready"] is False
|
||||
assert data["status"] == "degraded"
|
||||
|
||||
|
||||
class TestDecideFastPath:
|
||||
"""Precomputed state_vector present → vector_source=precomputed."""
|
||||
|
||||
def test_precomputed_path_used(self):
|
||||
app, svc = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["vector_source"] == "precomputed"
|
||||
|
||||
def test_action_is_valid_macro_action(self):
|
||||
app, svc = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["action"] in VALID_MACRO_ACTIONS
|
||||
|
||||
def test_action_index_in_range(self):
|
||||
app, svc = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200
|
||||
assert 0 <= resp.json()["action_index"] < NUM_ACTIONS
|
||||
|
||||
def test_confidence_is_probability(self):
|
||||
app, svc = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200
|
||||
conf = resp.json()["confidence"]
|
||||
assert 0.0 <= conf <= 1.0
|
||||
|
||||
def test_log_prob_is_negative(self):
|
||||
"""log(p) for p in (0,1] must be <= 0."""
|
||||
app, svc = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["log_prob"] <= 0.0
|
||||
|
||||
def test_tensor_shape_is_6177(self):
|
||||
"""The mock model must receive a (1, 6177) tensor."""
|
||||
app, svc = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
client.post("/api/policy/decide", json=snap)
|
||||
assert len(svc.captured_shapes) > 0
|
||||
shape = svc.captured_shapes[-1]
|
||||
assert shape == (1, EXPECTED_STATE_DIM), (
|
||||
f"Expected (1, {EXPECTED_STATE_DIM}), got {shape}"
|
||||
)
|
||||
|
||||
def test_probabilities_sum_to_one(self):
|
||||
app, svc = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200
|
||||
probs = resp.json().get("probabilities", {})
|
||||
if probs:
|
||||
total = sum(probs.values())
|
||||
assert abs(total - 1.0) < 0.01, f"Probs sum to {total}"
|
||||
|
||||
def test_inference_ms_is_positive(self):
|
||||
app, svc = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["inference_ms"] >= 0.0
|
||||
|
||||
|
||||
class TestDecideFallbackPath:
|
||||
"""No / wrong state_vector → falls back to full encoder."""
|
||||
|
||||
def test_fallback_when_no_state_vector(self):
|
||||
app, svc = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap.pop("state_vector", None)
|
||||
snap.pop("state_vector_dim", None)
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["vector_source"] == "encoder"
|
||||
|
||||
def test_fallback_when_wrong_length_vector(self):
|
||||
"""A 10-float vector is too short — must fall back."""
|
||||
app, svc = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot(state_vector=[0.5] * 10)
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["vector_source"] == "encoder"
|
||||
|
||||
def test_fallback_still_returns_valid_action(self):
|
||||
app, svc = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap.pop("state_vector", None)
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["action"] in VALID_MACRO_ACTIONS
|
||||
|
||||
|
||||
class TestGameSessionAliases:
|
||||
"""GameSession field name aliases must not cause 422 validation errors."""
|
||||
|
||||
def test_turnNumber_alias(self):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap["turnNumber"] = 5
|
||||
snap.pop("turn", None)
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_activePlayer_alias(self):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap["activePlayer"] = 0
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_commanderTax_in_player(self):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap["players"][1]["commanderTax"] = 4
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_commandZone_alias(self):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap["players"][0]["commandZone"] = ["Atraxa, Praetors' Voice"]
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_legalActions_alias(self):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap["legalActions"] = snap.pop("legal_actions", snap.get("legalActions", []))
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_schema_110_accepted(self):
|
||||
"""schema=1.1.0 (GameSession Step 1 bump) is accepted without error."""
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap["schema"] = "1.1.0"
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
class TestPhaseVariants:
|
||||
"""Both Forge-style (MAIN1) and ML-style (main_1) phase strings work."""
|
||||
|
||||
@pytest.mark.parametrize("phase", [
|
||||
"MAIN1", "main_1", "MAIN2", "main_2",
|
||||
"BEGIN_COMBAT", "combat", "END", "end", "CLEANUP",
|
||||
])
|
||||
def test_phase_accepted(self, phase):
|
||||
app, _ = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap["phase"] = phase
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 200, f"Phase '{phase}' failed: {resp.text}"
|
||||
|
||||
|
||||
class TestDecideModelNotLoaded:
|
||||
def test_503_when_not_loaded(self):
|
||||
app, _ = _make_app(loaded=False)
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
resp = client.post("/api/policy/decide", json=snap)
|
||||
assert resp.status_code == 503
|
||||
assert "not loaded" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
class TestStats:
|
||||
def test_precomputed_hits_incremented(self):
|
||||
app, svc = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
# Fire 3 precomputed requests
|
||||
for _ in range(3):
|
||||
client.post("/api/policy/decide", json=snap)
|
||||
stats = client.get("/api/policy/stats").json()
|
||||
assert stats["precomputed_vector_hits"] == 3
|
||||
assert stats["total_decisions"] == 3
|
||||
|
||||
def test_encoder_fallbacks_incremented(self):
|
||||
app, svc = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
snap.pop("state_vector", None)
|
||||
# Fire 2 fallback requests
|
||||
for _ in range(2):
|
||||
client.post("/api/policy/decide", json=snap)
|
||||
stats = client.get("/api/policy/stats").json()
|
||||
assert stats["encoder_fallbacks"] == 2
|
||||
assert stats["precomputed_vector_hits"] == 0
|
||||
|
||||
def test_mixed_hits_and_fallbacks(self):
|
||||
app, svc = _make_app()
|
||||
client = TestClient(app)
|
||||
snap_pre = _game_session_snapshot() # has state_vector
|
||||
snap_enc = _game_session_snapshot()
|
||||
snap_enc.pop("state_vector", None) # no state_vector
|
||||
client.post("/api/policy/decide", json=snap_pre)
|
||||
client.post("/api/policy/decide", json=snap_enc)
|
||||
client.post("/api/policy/decide", json=snap_pre)
|
||||
stats = client.get("/api/policy/stats").json()
|
||||
assert stats["precomputed_vector_hits"] == 2
|
||||
assert stats["encoder_fallbacks"] == 1
|
||||
assert stats["total_decisions"] == 3
|
||||
|
||||
def test_decisions_per_second_non_negative(self):
|
||||
app, svc = _make_app()
|
||||
client = TestClient(app)
|
||||
snap = _game_session_snapshot()
|
||||
client.post("/api/policy/decide", json=snap)
|
||||
stats = client.get("/api/policy/stats").json()
|
||||
assert stats["decisions_per_second"] >= 0.0
|
||||
|
||||
|
||||
class TestStateVectorDimension:
|
||||
"""Explicit dim assertions on the precomputed encoding path."""
|
||||
|
||||
def test_global_block_length_is_29(self):
|
||||
sv = _valid_state_vector()
|
||||
assert len(sv) == EXPECTED_GLOBAL_DIM
|
||||
|
||||
def test_life_normalisation(self):
|
||||
sv = _valid_state_vector()
|
||||
assert 0.0 <= sv[0] <= 1.0, "life not normalised"
|
||||
|
||||
def test_turn_normalisation(self):
|
||||
sv = _valid_state_vector()
|
||||
assert 0.0 <= sv[28] <= 1.0, "turn not normalised"
|
||||
|
||||
def test_is_active_player_binary(self):
|
||||
sv = _valid_state_vector()
|
||||
assert sv[8] in (0.0, 1.0), "is_active_player must be 0 or 1"
|
||||
|
||||
def test_phase_onehot_at_most_one_hot(self):
|
||||
"""Indices 9-12 are the phase one-hot: at most one should be 1."""
|
||||
sv = _valid_state_vector()
|
||||
phase_bits = sv[9:13]
|
||||
assert sum(phase_bits) <= 1.0, f"Multiple phase bits set: {phase_bits}"
|
||||
Reference in new issue
Block a user