From ce6663b39cd9c320dadc01c482891134a7f72674 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:32:21 +0200 Subject: [PATCH 01/10] feat(video-routing): add deterministic multi-subject strategy router --- src/video_validation/multisubject_router.py | 135 ++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 src/video_validation/multisubject_router.py diff --git a/src/video_validation/multisubject_router.py b/src/video_validation/multisubject_router.py new file mode 100644 index 00000000..2878c396 --- /dev/null +++ b/src/video_validation/multisubject_router.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class GenerationStrategy(str, Enum): + DIRECT = "DIRECT" + PARALLEL = "PARALLEL" + SEQUENTIAL = "SEQUENTIAL" + + +@dataclass(frozen=True) +class MultiSubjectShot: + subject_count: int + interaction_strength: float = 0.0 + contact_required: bool = False + occlusion_level: float = 0.0 + identity_criticality: float = 0.5 + camera_motion: float = 0.0 + temporal_dependency: float = 0.0 + compute_budget: float = 0.5 + + def __post_init__(self) -> None: + if self.subject_count < 0: + raise ValueError("subject_count must be non-negative") + for name in ( + "interaction_strength", + "occlusion_level", + "identity_criticality", + "camera_motion", + "temporal_dependency", + "compute_budget", + ): + value = getattr(self, name) + if not 0.0 <= value <= 1.0: + raise ValueError(f"{name} must be in [0, 1]") + + +@dataclass(frozen=True) +class RoutingDecision: + strategy: GenerationStrategy + confidence: float + reasons: tuple[str, ...] + requires_intermediate_validation: bool + + +def route_multi_subject_shot(shot: MultiSubjectShot) -> RoutingDecision: + """Choose a generation strategy using deterministic, explainable rules. + + The router does not call a model and does not generate media. It only + chooses the safest/cheapest generation topology for the supplied shot + complexity. Downstream validators still decide whether the result passes. + """ + + reasons: list[str] = [] + + if shot.subject_count <= 1: + return RoutingDecision( + strategy=GenerationStrategy.DIRECT, + confidence=0.98, + reasons=("single_or_no_subject",), + requires_intermediate_validation=False, + ) + + # Strong physical/spatial coupling is the clearest case for staged + # generation because independent branches cannot reliably preserve contact. + if shot.contact_required: + reasons.append("contact_required") + if shot.interaction_strength >= 0.7: + reasons.append("strong_subject_interaction") + if shot.occlusion_level >= 0.75: + reasons.append("heavy_occlusion") + if shot.temporal_dependency >= 0.8: + reasons.append("strong_temporal_dependency") + + if reasons: + confidence = min(0.98, 0.80 + 0.04 * len(reasons)) + return RoutingDecision( + strategy=GenerationStrategy.SEQUENTIAL, + confidence=confidence, + reasons=tuple(reasons), + requires_intermediate_validation=True, + ) + + # Independent subjects with high identity requirements benefit from + # separate generation branches followed by composition/reconciliation. + parallel_score = 0.0 + if shot.identity_criticality >= 0.7: + parallel_score += 0.45 + reasons.append("identity_critical") + if shot.subject_count >= 3: + parallel_score += 0.25 + reasons.append("many_subjects") + if shot.interaction_strength <= 0.35: + parallel_score += 0.20 + reasons.append("weak_subject_interaction") + if shot.occlusion_level <= 0.35: + parallel_score += 0.10 + reasons.append("low_occlusion") + + if parallel_score >= 0.65: + return RoutingDecision( + strategy=GenerationStrategy.PARALLEL, + confidence=min(0.95, 0.70 + parallel_score * 0.25), + reasons=tuple(reasons), + requires_intermediate_validation=True, + ) + + # Complex camera motion and previous-shot continuity can still favor a + # staged build even without direct contact between subjects. + if shot.camera_motion >= 0.75 and shot.temporal_dependency >= 0.55: + return RoutingDecision( + strategy=GenerationStrategy.SEQUENTIAL, + confidence=0.78, + reasons=("camera_motion_with_temporal_dependency",), + requires_intermediate_validation=True, + ) + + # When compute is severely constrained, use the single-pass baseline and + # rely on validators rather than multiplying generation branches. + if shot.compute_budget <= 0.2: + return RoutingDecision( + strategy=GenerationStrategy.DIRECT, + confidence=0.72, + reasons=("compute_budget_constrained",), + requires_intermediate_validation=False, + ) + + return RoutingDecision( + strategy=GenerationStrategy.DIRECT, + confidence=0.70, + reasons=("low_interaction_complexity",), + requires_intermediate_validation=False, + ) From 4b54ab04cc07fee25d14867af78a204d1e6c3f9d Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:32:35 +0200 Subject: [PATCH 02/10] feat(video-routing): expose multi-subject routing primitives --- src/video_validation/__init__.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/video_validation/__init__.py diff --git a/src/video_validation/__init__.py b/src/video_validation/__init__.py new file mode 100644 index 00000000..defa884f --- /dev/null +++ b/src/video_validation/__init__.py @@ -0,0 +1,15 @@ +"""Video validation and generation-routing primitives for StoryCore.""" + +from .multisubject_router import ( + GenerationStrategy, + MultiSubjectShot, + RoutingDecision, + route_multi_subject_shot, +) + +__all__ = [ + "GenerationStrategy", + "MultiSubjectShot", + "RoutingDecision", + "route_multi_subject_shot", +] From 7d438ee2e11fd4bc00cb18970883834eacba6ba9 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:32:48 +0200 Subject: [PATCH 03/10] test(video-routing): cover multi-subject strategy decisions --- tests/test_multi_subject_router.py | 93 ++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/test_multi_subject_router.py diff --git a/tests/test_multi_subject_router.py b/tests/test_multi_subject_router.py new file mode 100644 index 00000000..dc2ce46d --- /dev/null +++ b/tests/test_multi_subject_router.py @@ -0,0 +1,93 @@ +import pytest + +from src.video_validation.multisubject_router import ( + GenerationStrategy, + MultiSubjectShot, + route_multi_subject_shot, +) + + +def test_single_subject_uses_direct(): + decision = route_multi_subject_shot(MultiSubjectShot(subject_count=1)) + assert decision.strategy == GenerationStrategy.DIRECT + assert decision.requires_intermediate_validation is False + + +def test_contact_required_uses_sequential(): + decision = route_multi_subject_shot( + MultiSubjectShot(subject_count=2, contact_required=True) + ) + assert decision.strategy == GenerationStrategy.SEQUENTIAL + assert "contact_required" in decision.reasons + assert decision.requires_intermediate_validation is True + + +def test_strong_interaction_uses_sequential(): + decision = route_multi_subject_shot( + MultiSubjectShot(subject_count=2, interaction_strength=0.85) + ) + assert decision.strategy == GenerationStrategy.SEQUENTIAL + assert "strong_subject_interaction" in decision.reasons + + +def test_identity_critical_independent_subjects_use_parallel(): + decision = route_multi_subject_shot( + MultiSubjectShot( + subject_count=3, + identity_criticality=0.95, + interaction_strength=0.1, + occlusion_level=0.1, + ) + ) + assert decision.strategy == GenerationStrategy.PARALLEL + assert decision.requires_intermediate_validation is True + assert "identity_critical" in decision.reasons + + +def test_camera_motion_plus_continuity_uses_sequential(): + decision = route_multi_subject_shot( + MultiSubjectShot( + subject_count=2, + interaction_strength=0.5, + occlusion_level=0.5, + identity_criticality=0.5, + camera_motion=0.9, + temporal_dependency=0.6, + ) + ) + assert decision.strategy == GenerationStrategy.SEQUENTIAL + assert decision.reasons == ("camera_motion_with_temporal_dependency",) + + +def test_simple_two_subject_scene_defaults_direct(): + decision = route_multi_subject_shot( + MultiSubjectShot( + subject_count=2, + interaction_strength=0.45, + occlusion_level=0.2, + identity_criticality=0.45, + ) + ) + assert decision.strategy == GenerationStrategy.DIRECT + assert decision.reasons == ("low_interaction_complexity",) + + +def test_compute_constrained_scene_can_stay_direct(): + decision = route_multi_subject_shot( + MultiSubjectShot( + subject_count=2, + interaction_strength=0.4, + occlusion_level=0.4, + identity_criticality=0.5, + compute_budget=0.1, + ) + ) + assert decision.strategy == GenerationStrategy.DIRECT + assert decision.reasons == ("compute_budget_constrained",) + + +def test_invalid_inputs_fail_closed(): + with pytest.raises(ValueError): + MultiSubjectShot(subject_count=-1) + with pytest.raises(ValueError): + MultiSubjectShot(subject_count=2, interaction_strength=1.1) From b8c8cd5d738e7352e41c97c6eebb216076fa824a Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:06:38 +0200 Subject: [PATCH 04/10] feat(video-routing): adapt existing shot specs to routing inputs --- src/video_validation/shot_spec_adapter.py | 139 ++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 src/video_validation/shot_spec_adapter.py diff --git a/src/video_validation/shot_spec_adapter.py b/src/video_validation/shot_spec_adapter.py new file mode 100644 index 00000000..86b6cac8 --- /dev/null +++ b/src/video_validation/shot_spec_adapter.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from .multisubject_router import MultiSubjectShot, RoutingDecision, route_multi_subject_shot + + +_CAMERA_MOTION_INTENSITY = { + "static": 0.0, + "locked": 0.0, + "none": 0.0, + "pan": 0.35, + "tilt": 0.35, + "zoom": 0.45, + "dolly": 0.55, + "tracking": 0.6, + "truck": 0.6, + "pedestal": 0.55, + "orbit": 0.75, + "crane": 0.75, + "handheld": 0.8, + "whip": 0.9, +} + + +def _read(source: Any, key: str, default: Any = None) -> Any: + if isinstance(source, Mapping): + return source.get(key, default) + return getattr(source, key, default) + + +def _routing_metadata(shot_spec: Any) -> Mapping[str, Any]: + metadata = _read(shot_spec, "metadata", {}) + if not isinstance(metadata, Mapping): + return {} + routing = metadata.get("multi_subject_routing", {}) + return routing if isinstance(routing, Mapping) else {} + + +def _normalize_score(value: Any, *, name: str, default: float) -> float: + if value is None: + return default + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{name} must be numeric") + score = float(value) + if not 0.0 <= score <= 1.0: + raise ValueError(f"{name} must be in [0, 1]") + return score + + +def _camera_motion_score(value: Any) -> float: + if value is None: + return 0.0 + if isinstance(value, (int, float)) and not isinstance(value, bool): + return _normalize_score(value, name="camera_motion", default=0.0) + if not isinstance(value, str): + raise ValueError("camera_motion must be numeric or text") + + normalized = value.lower().replace("-", " ").replace("_", " ") + matched = [score for token, score in _CAMERA_MOTION_INTENSITY.items() if token in normalized] + return max(matched, default=0.25 if normalized.strip() else 0.0) + + +def _subject_count(shot_spec: Any, *, subjects: Sequence[Any] | None, routing: Mapping[str, Any]) -> int: + explicit = routing.get("subject_count", _read(shot_spec, "subject_count", None)) + if explicit is not None: + if isinstance(explicit, bool) or not isinstance(explicit, int) or explicit < 0: + raise ValueError("subject_count must be a non-negative integer") + return explicit + + for key in ("characters_present", "subjects", "characters"): + value = _read(shot_spec, key, None) + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return len(value) + + if subjects is not None: + return len(subjects) + + # Fail closed rather than silently treating an unknown multi-character shot + # as a single-subject DIRECT generation. + raise ValueError("subject_count is required when the shot spec has no subject list") + + +def extract_multi_subject_shot( + shot_spec: Any, + *, + subjects: Sequence[Any] | None = None, + overrides: Mapping[str, Any] | None = None, +) -> MultiSubjectShot: + """Adapt an existing StoryCore shot/dict into the deterministic routing contract. + + Rich routing metadata can live under ``metadata.multi_subject_routing``. + ``overrides`` is intended for the orchestration layer when scene context knows + more than the shot object itself. No NLP guessing is performed here. + """ + + routing = dict(_routing_metadata(shot_spec)) + if overrides: + routing.update(overrides) + + camera_value = routing.get( + "camera_motion", + _read(shot_spec, "camera_movement", _read(shot_spec, "camera_motion", None)), + ) + + return MultiSubjectShot( + subject_count=_subject_count(shot_spec, subjects=subjects, routing=routing), + interaction_strength=_normalize_score( + routing.get("interaction_strength"), name="interaction_strength", default=0.0 + ), + contact_required=bool(routing.get("contact_required", False)), + occlusion_level=_normalize_score( + routing.get("occlusion_level"), name="occlusion_level", default=0.0 + ), + identity_criticality=_normalize_score( + routing.get("identity_criticality"), name="identity_criticality", default=0.5 + ), + camera_motion=_camera_motion_score(camera_value), + temporal_dependency=_normalize_score( + routing.get("temporal_dependency"), name="temporal_dependency", default=0.0 + ), + compute_budget=_normalize_score( + routing.get("compute_budget"), name="compute_budget", default=0.5 + ), + ) + + +def route_shot_spec( + shot_spec: Any, + *, + subjects: Sequence[Any] | None = None, + overrides: Mapping[str, Any] | None = None, +) -> RoutingDecision: + """Extract a routing input from a StoryCore shot and choose its topology.""" + + return route_multi_subject_shot( + extract_multi_subject_shot(shot_spec, subjects=subjects, overrides=overrides) + ) From c946da6238fee525dd4da17ae15378c55b3903cb Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:07:01 +0200 Subject: [PATCH 05/10] test(video-routing): cover shot-spec adapter --- tests/test_shot_spec_router_adapter.py | 96 ++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/test_shot_spec_router_adapter.py diff --git a/tests/test_shot_spec_router_adapter.py b/tests/test_shot_spec_router_adapter.py new file mode 100644 index 00000000..f92afdf8 --- /dev/null +++ b/tests/test_shot_spec_router_adapter.py @@ -0,0 +1,96 @@ +from dataclasses import dataclass, field + +import pytest + +from src.video_validation.multisubject_router import GenerationStrategy +from src.video_validation.shot_spec_adapter import ( + extract_multi_subject_shot, + route_shot_spec, +) + + +@dataclass +class ShotLike: + camera_movement: str = "static" + metadata: dict = field(default_factory=dict) + + +def test_single_subject_existing_shot_routes_direct(): + decision = route_shot_spec(ShotLike(camera_movement="static"), subjects=["hero"]) + assert decision.strategy is GenerationStrategy.DIRECT + + +def test_scene_characters_present_can_supply_subject_count(): + spec = { + "characters_present": ["hero", "rival"], + "camera_movement": "static", + "metadata": { + "multi_subject_routing": { + "contact_required": True, + "interaction_strength": 0.9, + } + }, + } + decision = route_shot_spec(spec) + assert decision.strategy is GenerationStrategy.SEQUENTIAL + assert decision.requires_intermediate_validation is True + + +def test_identity_critical_independent_subjects_route_parallel(): + shot = ShotLike(camera_movement="tracking") + decision = route_shot_spec( + shot, + subjects=["a", "b", "c"], + overrides={ + "identity_criticality": 0.95, + "interaction_strength": 0.1, + "occlusion_level": 0.1, + }, + ) + assert decision.strategy is GenerationStrategy.PARALLEL + + +def test_camera_motion_text_is_normalized_deterministically(): + routing_input = extract_multi_subject_shot( + ShotLike(camera_movement="fast orbit camera"), + subjects=["a", "b"], + overrides={"temporal_dependency": 0.7}, + ) + assert routing_input.camera_motion == 0.75 + decision = route_shot_spec( + ShotLike(camera_movement="fast orbit camera"), + subjects=["a", "b"], + overrides={"temporal_dependency": 0.7}, + ) + assert decision.strategy is GenerationStrategy.SEQUENTIAL + + +def test_unknown_subject_count_fails_closed(): + with pytest.raises(ValueError, match="subject_count is required"): + route_shot_spec(ShotLike()) + + +def test_routing_metadata_can_override_subject_count_and_budget(): + spec = { + "camera_movement": "pan", + "metadata": { + "multi_subject_routing": { + "subject_count": 2, + "compute_budget": 0.1, + "interaction_strength": 0.4, + "occlusion_level": 0.4, + } + }, + } + decision = route_shot_spec(spec) + assert decision.strategy is GenerationStrategy.DIRECT + assert "compute_budget_constrained" in decision.reasons + + +def test_invalid_score_fails_closed(): + with pytest.raises(ValueError, match="interaction_strength"): + extract_multi_subject_shot( + ShotLike(), + subjects=["a", "b"], + overrides={"interaction_strength": 1.2}, + ) From 1cf11e4cdd458994d42027783d73fe4066fc42b9 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:07:31 +0200 Subject: [PATCH 06/10] fix(video-routing): fail closed on non-boolean contact metadata --- src/video_validation/shot_spec_adapter.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/video_validation/shot_spec_adapter.py b/src/video_validation/shot_spec_adapter.py index 86b6cac8..c199c124 100644 --- a/src/video_validation/shot_spec_adapter.py +++ b/src/video_validation/shot_spec_adapter.py @@ -49,6 +49,14 @@ def _normalize_score(value: Any, *, name: str, default: float) -> float: return score +def _normalize_bool(value: Any, *, name: str, default: bool) -> bool: + if value is None: + return default + if not isinstance(value, bool): + raise ValueError(f"{name} must be boolean") + return value + + def _camera_motion_score(value: Any) -> float: if value is None: return 0.0 @@ -109,7 +117,9 @@ def extract_multi_subject_shot( interaction_strength=_normalize_score( routing.get("interaction_strength"), name="interaction_strength", default=0.0 ), - contact_required=bool(routing.get("contact_required", False)), + contact_required=_normalize_bool( + routing.get("contact_required"), name="contact_required", default=False + ), occlusion_level=_normalize_score( routing.get("occlusion_level"), name="occlusion_level", default=0.0 ), From 96eaf1f26a78e54f26df4d11434b9cda72a02f79 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:07:41 +0200 Subject: [PATCH 07/10] feat(video-routing): export shot-spec routing adapter --- src/video_validation/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/video_validation/__init__.py b/src/video_validation/__init__.py index defa884f..76a32ec7 100644 --- a/src/video_validation/__init__.py +++ b/src/video_validation/__init__.py @@ -6,10 +6,13 @@ RoutingDecision, route_multi_subject_shot, ) +from .shot_spec_adapter import extract_multi_subject_shot, route_shot_spec __all__ = [ "GenerationStrategy", "MultiSubjectShot", "RoutingDecision", "route_multi_subject_shot", + "extract_multi_subject_shot", + "route_shot_spec", ] From 1eed5169ee8ed0a3cf40aa8fa7da73f3f45daf07 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:11:10 +0200 Subject: [PATCH 08/10] test(video-routing): reject ambiguous contact metadata --- tests/test_shot_spec_router_adapter.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_shot_spec_router_adapter.py b/tests/test_shot_spec_router_adapter.py index f92afdf8..b3e746a1 100644 --- a/tests/test_shot_spec_router_adapter.py +++ b/tests/test_shot_spec_router_adapter.py @@ -94,3 +94,12 @@ def test_invalid_score_fails_closed(): subjects=["a", "b"], overrides={"interaction_strength": 1.2}, ) + + +def test_non_boolean_contact_required_fails_closed(): + with pytest.raises(ValueError, match="contact_required"): + extract_multi_subject_shot( + ShotLike(), + subjects=["a", "b"], + overrides={"contact_required": "yes"}, + ) From 00abcead8f30bd8001f075c5fbf4151831107d02 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:35:38 +0200 Subject: [PATCH 09/10] fix(video-routing): reject contradictory subject counts --- src/video_validation/shot_spec_adapter.py | 34 ++++++++++++++++------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/video_validation/shot_spec_adapter.py b/src/video_validation/shot_spec_adapter.py index c199c124..354a3afd 100644 --- a/src/video_validation/shot_spec_adapter.py +++ b/src/video_validation/shot_spec_adapter.py @@ -1,5 +1,6 @@ from __future__ import annotations +import math from collections.abc import Mapping, Sequence from typing import Any @@ -44,8 +45,8 @@ def _normalize_score(value: Any, *, name: str, default: float) -> float: if isinstance(value, bool) or not isinstance(value, (int, float)): raise ValueError(f"{name} must be numeric") score = float(value) - if not 0.0 <= score <= 1.0: - raise ValueError(f"{name} must be in [0, 1]") + if not math.isfinite(score) or not 0.0 <= score <= 1.0: + raise ValueError(f"{name} must be finite and in [0, 1]") return score @@ -70,24 +71,37 @@ def _camera_motion_score(value: Any) -> float: return max(matched, default=0.25 if normalized.strip() else 0.0) +def _sequence_count(value: Any) -> int | None: + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return len(value) + return None + + def _subject_count(shot_spec: Any, *, subjects: Sequence[Any] | None, routing: Mapping[str, Any]) -> int: + candidates: list[tuple[str, int]] = [] + explicit = routing.get("subject_count", _read(shot_spec, "subject_count", None)) if explicit is not None: if isinstance(explicit, bool) or not isinstance(explicit, int) or explicit < 0: raise ValueError("subject_count must be a non-negative integer") - return explicit + candidates.append(("subject_count", explicit)) for key in ("characters_present", "subjects", "characters"): - value = _read(shot_spec, key, None) - if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): - return len(value) + count = _sequence_count(_read(shot_spec, key, None)) + if count is not None: + candidates.append((key, count)) if subjects is not None: - return len(subjects) + candidates.append(("subjects_argument", len(subjects))) + + if not candidates: + raise ValueError("subject_count is required when the shot spec has no subject list") - # Fail closed rather than silently treating an unknown multi-character shot - # as a single-subject DIRECT generation. - raise ValueError("subject_count is required when the shot spec has no subject list") + distinct = {count for _, count in candidates} + if len(distinct) != 1: + detail = ", ".join(f"{name}={count}" for name, count in candidates) + raise ValueError(f"contradictory subject counts: {detail}") + return candidates[0][1] def extract_multi_subject_shot( From 33d83467fa6353d13bd64e66d33ef164d9c8f7c1 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:36:01 +0200 Subject: [PATCH 10/10] test(video-routing): cover contradictory counts and non-finite scores --- tests/test_shot_spec_router_adapter.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_shot_spec_router_adapter.py b/tests/test_shot_spec_router_adapter.py index b3e746a1..d482cc9a 100644 --- a/tests/test_shot_spec_router_adapter.py +++ b/tests/test_shot_spec_router_adapter.py @@ -87,6 +87,22 @@ def test_routing_metadata_can_override_subject_count_and_budget(): assert "compute_budget_constrained" in decision.reasons +def test_contradictory_subject_counts_fail_closed(): + spec = { + "subject_count": 1, + "characters_present": ["hero", "rival", "witness"], + "camera_movement": "static", + } + with pytest.raises(ValueError, match="contradictory subject counts"): + route_shot_spec(spec) + + +def test_explicit_subject_argument_must_match_shot_metadata(): + spec = {"characters_present": ["hero", "rival"], "camera_movement": "static"} + with pytest.raises(ValueError, match="contradictory subject counts"): + route_shot_spec(spec, subjects=["hero"]) + + def test_invalid_score_fails_closed(): with pytest.raises(ValueError, match="interaction_strength"): extract_multi_subject_shot( @@ -96,6 +112,16 @@ def test_invalid_score_fails_closed(): ) +def test_non_finite_score_fails_closed(): + for value in (float("nan"), float("inf"), float("-inf")): + with pytest.raises(ValueError, match="interaction_strength"): + extract_multi_subject_shot( + ShotLike(), + subjects=["a", "b"], + overrides={"interaction_strength": value}, + ) + + def test_non_boolean_contact_required_fails_closed(): with pytest.raises(ValueError, match="contact_required"): extract_multi_subject_shot(