diff --git a/src/claimscene/pipeline.py b/src/claimscene/pipeline.py index eef333e..4259153 100644 --- a/src/claimscene/pipeline.py +++ b/src/claimscene/pipeline.py @@ -149,23 +149,28 @@ def run(self, spec: CaseSpec) -> CaseResult: # 5. Illustration (generative, explicitly sealed as such), two steps: # an establish-shot still, then an image-to-video clip chained from # that still (the provider reuses the still's hosted URL live). - # ``illustration_prompt`` already ASKS the model to overlay the - # disclosure text, but that is a request, not a guarantee (a live - # render proved the model can simply ignore it) -- so the - # disclosure is additionally burned into the actual pixels here, - # deterministically, after generation (see watermark.py). The RAW - # (unwatermarked) still bytes are what feed the video step, so the - # Genblaze provider's chained-output optimisation (same sha256 -> - # same hosted URL, see GenblazeMediaProvider._hosted_by_sha) still - # matches live; only the STORED/SEALED copies are watermarked. - still_prompt = illustration_still_prompt(scene) + # Both prompts are built from ``scene`` AND ``timeline`` -- the + # Timeline's already-computed poses at the impact frame give the + # prompt the real relative position/orientation between impact + # participants, so the illustration matches the schematic instead + # of the model inventing its own layout. The prompts also now ask + # for no on-image text -- the disclosure is guaranteed instead by + # burning it into the actual pixels here, deterministically, after + # generation (see watermark.py; a live render proved a prompt + # request alone is not a guarantee, the model can simply ignore + # it). The RAW (unwatermarked) still bytes are what feed the video + # step, so the Genblaze provider's chained-output optimisation + # (same sha256 -> same hosted URL, see + # GenblazeMediaProvider._hosted_by_sha) still matches live; only + # the STORED/SEALED copies are watermarked. + still_prompt = illustration_still_prompt(scene, timeline) still_raw = self.provider.generate( model=spec.illustration_still_model, prompt=still_prompt, modality="image", params={"size": "2K", "output_format": "png", "max_images": 1, "watermark": False}, ) - prompt = illustration_prompt(scene) + prompt = illustration_prompt(scene, timeline) illustration_raw = self.provider.generate( model=spec.illustration_model, prompt=prompt, modality="video", inputs=[still_raw], diff --git a/src/claimscene/report.py b/src/claimscene/report.py index f8b9c17..6a95bbc 100644 --- a/src/claimscene/report.py +++ b/src/claimscene/report.py @@ -6,7 +6,9 @@ """ from __future__ import annotations -from .layout import Timeline +import math + +from .layout import TimedPose, Timeline, clock_point_world from .provenance import DISCLOSURE from .scene import SceneGraph, Signal @@ -126,17 +128,219 @@ def build_report(scene: SceneGraph, timeline: Timeline, *, case_id: str, return "\n".join(lines) -def _forensic_scene_description(scene: SceneGraph) -> str: +# ── impact-geometry relational phrasing ────────────────────────────────────── +# Where one impact participant sits relative to another, and how they are +# angled toward each other, derived purely from the Timeline's already +# computed poses at the impact frame (never by re-reading the movement or +# damage vocabulary text) -- the same factual layer the schematic is drawn +# from, so the wording is consistent with it by construction. See +# ``_impact_relationship_phrase`` for the orchestration. +_POSITION_BUCKETS: tuple[tuple[float, float, str], ...] = ( + (-157.5, -112.5, "behind and to the right of"), + (-112.5, -67.5, "directly to the right of"), + (-67.5, -22.5, "ahead and to the right of"), + (-22.5, 22.5, "directly ahead of"), + (22.5, 67.5, "ahead and to the left of"), + (67.5, 112.5, "directly to the left of"), + (112.5, 157.5, "behind and to the left of"), +) +_POSITION_BEHIND = "directly behind" # the wrap-around bucket past +/-157.5 + +_ALIGNED_MAX_DEG = 25.0 # heading difference at or below this: "same way" +_RIGHT_ANGLE_MIN_DEG = 65.0 +_RIGHT_ANGLE_MAX_DEG = 115.0 +_HEAD_ON_MIN_DEG = 155.0 # heading difference at or above this: head-on + +_CONTACT_MAX_M = 1.0 # own-clock-point gap at/below this: "in contact" +_ALMOST_TOUCHING_MAX_M = 3.0 # gap at/below this (but over contact): "almost touching" + + +def _normalize_deg(deg: float) -> float: + """Normalize an angle to (-180, 180].""" + return ((deg + 180.0) % 360.0) - 180.0 + + +def _impact_pose(timeline: Timeline, vehicle_id: str) -> TimedPose | None: + """``vehicle_id``'s pose at the timeline's impact frame. + + ``None`` only when ``timeline`` has no track for this vehicle -- i.e. it + was not built from the same scene. Defensive rather than raising, so a + caller mismatch degrades to "no relationship claimed" instead of a + crash. + """ + track = next((t for t in timeline.tracks if t.vehicle_id == vehicle_id), None) + if track is None or not track.poses: + return None + return min(track.poses, key=lambda p: abs(p.t - timeline.impact_time_s)) + + +def _bearing_and_heading_diff(a_pose: TimedPose, b_pose: TimedPose) -> tuple[float, float]: + """(bearing of B in A's own frame, A/B heading difference), in degrees. + + Both poses use the Timeline's world-frame heading convention (0 = east, + counter-clockwise). A relative bearing of 0 means B sits directly ahead + of A, in the direction A's nose points; 180 means directly behind; + +90/-90 means to A's left/right. The heading difference is 0 when A and + B face the same way and +/-180 when they face directly opposite each + other. + """ + dx, dy = b_pose.x - a_pose.x, b_pose.y - a_pose.y + bearing_world = math.degrees(math.atan2(dy, dx)) + rel_bearing = _normalize_deg(bearing_world - a_pose.heading_deg) + heading_diff = _normalize_deg(b_pose.heading_deg - a_pose.heading_deg) + return rel_bearing, heading_diff + + +def _position_phrase(rel_bearing_deg: float) -> str: + for lo, hi, phrase in _POSITION_BUCKETS: + if lo < rel_bearing_deg <= hi: + return phrase + return _POSITION_BEHIND + + +def _orientation_phrase(heading_diff_deg: float) -> str: + angle = abs(heading_diff_deg) + if angle <= _ALIGNED_MAX_DEG: + return "both facing the same way" + if angle >= _HEAD_ON_MIN_DEG: + return "facing each other head-on" + if _RIGHT_ANGLE_MIN_DEG <= angle <= _RIGHT_ANGLE_MAX_DEG: + return "meeting at right angles" + return "meeting at an oblique angle" + + +def _idiom_phrase(position: str, orientation: str) -> str | None: + """A short, standard collision idiom for the two configurations it + unambiguously fits -- aligned nose-to-tail or nose-to-nose -- and + nothing for any other configuration (no idiom is stretched to fit a + shape it was not built for).""" + if position not in ("directly ahead of", _POSITION_BEHIND): + return None + if orientation == "both facing the same way": + return "nose to tail" + if orientation == "facing each other head-on": + return "nose to nose" + return None + + +def _impact_point_distance( + timeline: Timeline, a_id: str, a_pose: TimedPose, b_id: str, b_pose: TimedPose +) -> float | None: + """Distance between the two vehicles' own stated impact points. + + The LayoutEngine positions every impact participant so its stated + clock-position point touches the shared contact point at the impact + frame (see ``layout.LayoutEngine.build``), so this comes out near zero + by construction -- computed here rather than assumed, so the wording + stays honestly derived from the poses rather than from that invariant. + ``None`` when either vehicle is missing impact metadata (a + scene/timeline mismatch), never raised. + """ + a_meta = next((v for v in timeline.vehicles if v.id == a_id), None) + b_meta = next((v for v in timeline.vehicles if v.id == b_id), None) + if a_meta is None or b_meta is None: + return None + if a_meta.impact_clock is None or b_meta.impact_clock is None: + return None + ax, ay = clock_point_world(a_pose, a_meta.impact_clock, a_meta.length_m, a_meta.width_m) + bx, by = clock_point_world(b_pose, b_meta.impact_clock, b_meta.length_m, b_meta.width_m) + return math.hypot(bx - ax, by - ay) + + +def _contact_phrase(distance_m: float | None) -> str | None: + if distance_m is None: + return None + if distance_m <= _CONTACT_MAX_M: + return "in contact at the point of impact" + if distance_m <= _ALMOST_TOUCHING_MAX_M: + return "almost touching" + return None + + +def _vehicle_descriptions(scene: SceneGraph) -> dict[str, str]: + return {v.id: f"{v.color.value} {v.kind.value}" for v in scene.vehicles} + + +def _unique_impact_vehicle_ids(scene: SceneGraph) -> list[str]: + """Impact participants in first-seen order, deduplicated by vehicle id.""" + seen: set[str] = set() + ids: list[str] = [] + for imp in scene.impacts: + if imp.vehicle_id in seen: + continue + seen.add(imp.vehicle_id) + ids.append(imp.vehicle_id) + return ids + + +def _impact_relationship_phrase(scene: SceneGraph, timeline: Timeline) -> str: + """Plain-language description of how the impact participants are + positioned and oriented relative to one another at the moment of + impact -- the fact the independent per-vehicle description never + states, which is exactly what let a rear-end collision render as two + cars waiting side by side at a light: nothing in the old prompt said + which vehicle was where relative to the other. + + Every other impact participant is described relative to the + first-listed one, which keeps the output well-defined for the common + two-vehicle case and still well-defined (if more verbose) for more + participants. + + Returns an empty string when there is no pair to relate: a + single-vehicle scene, a parked-only scene with no recorded contact, or + a scene with no impacts at all. Callers must treat that as "nothing to + add", not an error. + """ + ids = _unique_impact_vehicle_ids(scene) + if len(ids) < 2: + return "" + descriptions = _vehicle_descriptions(scene) + anchor_id = ids[0] + anchor_pose = _impact_pose(timeline, anchor_id) + if anchor_pose is None: + return "" + sentences: list[str] = [] + for other_id in ids[1:]: + other_pose = _impact_pose(timeline, other_id) + if other_pose is None: + continue + rel_bearing, heading_diff = _bearing_and_heading_diff(anchor_pose, other_pose) + position = _position_phrase(rel_bearing) + orientation = _orientation_phrase(heading_diff) + clauses: list[str] = [] + idiom = _idiom_phrase(position, orientation) + if idiom: + clauses.append(idiom) + clauses.append(orientation) + distance = _impact_point_distance(timeline, anchor_id, anchor_pose, + other_id, other_pose) + contact = _contact_phrase(distance) + if contact: + clauses.append(contact) + sentences.append( + f"The {descriptions[other_id]} is {position} the " + f"{descriptions[anchor_id]}, " + ", ".join(clauses) + "." + ) + return " ".join(sentences) + + +def _forensic_scene_description(scene: SceneGraph, timeline: Timeline) -> str: """The shared forensic-reconstruction scene wording (still + clip prompts). - Deterministic, built only from the constrained vocabulary, and kept in - the computer-generated forensic-reconstruction register on purpose: a - clean, serious CGI accident-reconstruction render, not a toy and not a - cartoon, that states plainly it is a computer-generated reconstruction - and not a real recording (self-disclosing). It depicts no people and no - injuries, which keeps it clear of the sharper content-moderation - triggers by construction, though unlike the retired toy-diorama register - this exact wording has not yet been probed against a live provider. + Deterministic, built only from the constrained vocabulary plus the + Timeline's already-computed geometry, and kept in the computer-generated + forensic-reconstruction register on purpose: a clean, serious CGI + accident-reconstruction render, not a toy and not a cartoon, that states + plainly it is a computer-generated reconstruction and not a real + recording (self-disclosing). It depicts no people and no injuries, which + keeps it clear of the sharper content-moderation triggers by + construction, though unlike the retired toy-diorama register this exact + wording has not yet been probed against a live provider. + + The impact participants' relative position and orientation (e.g. "the + red car is directly behind the blue car, nose to tail") come from + ``_impact_relationship_phrase``, so the prompt states the scene's actual + layout instead of leaving it for the model to invent. """ road = scene.road movements = {m.vehicle_id: m for m in scene.movements} @@ -157,37 +361,47 @@ def _forensic_scene_description(scene: SceneGraph) -> str: f"{_CLOCK_WORDS[dz.clock_position]}" ) parts.append(phrase) + relationship = _impact_relationship_phrase(scene, timeline) + relationship_sentence = f" {relationship}" if relationship else "" return ( "Computer-generated 3D forensic accident-reconstruction render, not " "a real recording, showing " f"{_LAYOUT_WORDS[scene.road.layout.value]} with " - f"{_SIGNAL_WORDS[road.signal]}: " + "; ".join(parts) + ". " - "Accurate vehicle proportions, real road surface and lane markings, " + f"{_SIGNAL_WORDS[road.signal]}: " + "; ".join(parts) + "." + + relationship_sentence + + " Accurate vehicle proportions, real road surface and lane markings, " "neutral daylight, professional CGI clarity, not a toy, not a " - "cartoon, no people, no injuries." + "cartoon, no people, no injuries. The rendered image itself must " + "contain no text, no labels, no captions, and no watermarks." ) -def illustration_still_prompt(scene: SceneGraph) -> str: +def illustration_still_prompt(scene: SceneGraph, timeline: Timeline) -> str: """Deterministic prompt for the establish-shot still (text → image).""" return ( "Clean, serious forensic accident-reconstruction still, " "establish-shot framing. " - + _forensic_scene_description(scene) + + _forensic_scene_description(scene, timeline) + " Slightly elevated three-quarter view of the whole scene." ) -def illustration_prompt(scene: SceneGraph) -> str: +def illustration_prompt(scene: SceneGraph, timeline: Timeline) -> str: """Deterministic prompt for the illustration clip (still → video). - Built only from the constrained vocabulary; always ends with the - self-disclosing instruction. + Built only from the constrained vocabulary plus the Timeline's computed + geometry. This used to end with an ``Overlay text: '{DISCLOSURE}'`` + request; that line is gone. A live render on 2026-07-30 (case + ``live-forensic-retry-0bb32eeb``) proved the model is free to ignore + such a request, and asking for that text overlay would now directly + contradict the "no text" instruction inside + ``_forensic_scene_description`` above -- a contradictory prompt is + worse than no prompt. The disclosure is guaranteed instead by + ``watermark.burn_clip_watermark``, deterministically, after generation. """ return ( "Slow smooth camera orbit around this forensic " "accident-reconstruction scene. " - + _forensic_scene_description(scene) - + " The vehicles stay perfectly still; gentle parallax only. " - f"Overlay text: '{DISCLOSURE}'." + + _forensic_scene_description(scene, timeline) + + " The vehicles stay perfectly still; gentle parallax only." ) diff --git a/tests/unit/test_report.py b/tests/unit/test_report.py index 645e9d4..ed5a56f 100644 --- a/tests/unit/test_report.py +++ b/tests/unit/test_report.py @@ -1,7 +1,27 @@ -from claimscene.adapters.fakes import _scene_left_cross, _scene_rear_end +from claimscene.adapters.fakes import ( + _scene_left_cross, + _scene_parking_reverse, + _scene_rear_end, + _scene_roundabout_sideswipe, +) from claimscene.layout import LayoutEngine from claimscene.provenance import DISCLOSURE -from claimscene.report import build_report, illustration_prompt, illustration_still_prompt +from claimscene.report import ( + _contact_phrase, + _idiom_phrase, + build_report, + illustration_prompt, + illustration_still_prompt, +) +from claimscene.scene import ( + DamageSeverity, + DamageZone, + Impact, + Movement, + Road, + SceneGraph, + Vehicle, +) def test_report_is_deterministic_and_carries_disclosure(): @@ -38,9 +58,9 @@ def test_report_includes_confidence_notes_and_extras(): def test_illustration_prompt_deterministic_and_self_disclosing(): scene = _scene_left_cross() - a = illustration_prompt(scene) - assert a == illustration_prompt(scene) - assert DISCLOSURE in a + timeline = LayoutEngine().build(scene) + a = illustration_prompt(scene, timeline) + assert a == illustration_prompt(scene, timeline) assert "silver car" in a and "green van" in a assert "not a real recording" in a @@ -51,9 +71,11 @@ def test_prompts_stay_in_the_moderation_safe_forensic_register(): generative model must not be nudged toward looking like a real recording).""" scene = _scene_rear_end() + timeline = LayoutEngine().build(scene) forbidden = ("photorealistic", "photograph", "dashcam", "real footage", "cinematic film still", "documentary footage") - for prompt in (illustration_still_prompt(scene), illustration_prompt(scene)): + for prompt in (illustration_still_prompt(scene, timeline), + illustration_prompt(scene, timeline)): assert "Computer-generated 3D forensic accident-reconstruction render" in prompt assert "no people, no injuries" in prompt assert "not a real recording" in prompt @@ -64,8 +86,316 @@ def test_prompts_stay_in_the_moderation_safe_forensic_register(): def test_still_prompt_describes_vehicles_and_damage(): scene = _scene_rear_end() - a = illustration_still_prompt(scene) - assert a == illustration_still_prompt(scene) + timeline = LayoutEngine().build(scene) + a = illustration_still_prompt(scene, timeline) + assert a == illustration_still_prompt(scene, timeline) assert "blue car" in a and "red car" in a assert "crush mark at its 6 o'clock (rear)" in a - assert DISCLOSURE not in a # the overlay instruction belongs to the clip + # The disclosure is burned into the pixels (watermark.py) rather than + # requested in either generation prompt now -- see the no-on-image-text + # tests below. + assert DISCLOSURE not in a + + +# ── impact-geometry relational phrasing ────────────────────────────────────── +# These fixtures cover three distinct geometries straight out of the real +# LayoutEngine: rear_end is aligned (nose to tail), parking_reverse and +# roundabout_sideswipe are both perpendicular (meeting at right angles, from +# two different scenarios), and left_cross is oblique. The expected sentences +# were verified against the LayoutEngine's actual computed poses (not +# hand-guessed): same math the schematic itself is drawn from. +def test_illustration_prompts_state_rear_end_geometry(): + scene = _scene_rear_end() + timeline = LayoutEngine().build(scene) + expected = ( + "The red car is directly behind the blue car, nose to tail, both " + "facing the same way, in contact at the point of impact." + ) + for prompt in (illustration_still_prompt(scene, timeline), + illustration_prompt(scene, timeline)): + assert expected in prompt + + +def test_illustration_prompts_state_right_angle_geometry(): + scene = _scene_parking_reverse() + timeline = LayoutEngine().build(scene) + expected = ( + "The white van is directly to the left of the black car, meeting " + "at right angles, in contact at the point of impact." + ) + for prompt in (illustration_still_prompt(scene, timeline), + illustration_prompt(scene, timeline)): + assert expected in prompt + + +def test_illustration_prompts_state_right_angle_geometry_second_fixture(): + scene = _scene_roundabout_sideswipe() + timeline = LayoutEngine().build(scene) + expected = ( + "The black motorcycle is ahead and to the right of the yellow car, " + "meeting at right angles, in contact at the point of impact." + ) + for prompt in (illustration_still_prompt(scene, timeline), + illustration_prompt(scene, timeline)): + assert expected in prompt + + +def test_illustration_prompts_state_oblique_geometry(): + scene = _scene_left_cross() + timeline = LayoutEngine().build(scene) + expected = ( + "The green van is ahead and to the right of the silver car, " + "meeting at an oblique angle, in contact at the point of impact." + ) + for prompt in (illustration_still_prompt(scene, timeline), + illustration_prompt(scene, timeline)): + assert expected in prompt + + +def _scene_head_on() -> SceneGraph: + """Two cars approaching from opposite ends of the same straight road, + both struck at their own front (12 o'clock): the fourth orientation + bucket (the other three are covered by the fixtures above) and the + "nose to nose" idiom, which none of the committed fixtures exercise.""" + return SceneGraph( + road=Road(layout="straight", lanes_per_direction=1, signal="none"), + vehicles=[ + Vehicle(id="veh_a", kind="car", color="blue", damage=[ + DamageZone(clock_position=12, severity=DamageSeverity.crush), + ]), + Vehicle(id="veh_b", kind="car", color="red", damage=[ + DamageZone(clock_position=12, severity=DamageSeverity.crush), + ]), + ], + movements=[ + Movement(vehicle_id="veh_a", approach="N", maneuver="straight", + speed_band="moderate"), + Movement(vehicle_id="veh_b", approach="S", maneuver="straight", + speed_band="moderate"), + ], + impacts=[ + Impact(vehicle_id="veh_a", clock_position=12), + Impact(vehicle_id="veh_b", clock_position=12), + ], + ) + + +def test_illustration_prompts_state_head_on_geometry(): + scene = _scene_head_on() + timeline = LayoutEngine().build(scene) + expected = ( + "The red car is directly ahead of the blue car, nose to nose, " + "facing each other head-on, in contact at the point of impact." + ) + for prompt in (illustration_still_prompt(scene, timeline), + illustration_prompt(scene, timeline)): + assert expected in prompt + + +def test_impact_geometry_wording_is_deterministic(): + """Two independently-built Timelines from the same scene must yield the + exact same relational wording (not just the same object reused).""" + scene = _scene_rear_end() + a = illustration_prompt(scene, LayoutEngine().build(scene)) + b = illustration_prompt(scene, LayoutEngine().build(scene)) + assert a == b + a_still = illustration_still_prompt(scene, LayoutEngine().build(scene)) + b_still = illustration_still_prompt(scene, LayoutEngine().build(scene)) + assert a_still == b_still + + +def test_illustration_prompts_instruct_no_on_image_text(): + """The model must be told not to render its own captions -- it used to + invent on-image labels (e.g. "BLUE CAR: 6 O'CLOCK (REAR) DENT") that + compete with the deterministic disclosure burned into the pixels after + generation (watermark.py).""" + scene = _scene_rear_end() + timeline = LayoutEngine().build(scene) + expected = "no text, no labels, no captions, and no watermarks" + for prompt in (illustration_still_prompt(scene, timeline), + illustration_prompt(scene, timeline)): + assert expected in prompt + + +def test_illustration_prompt_drops_the_contradictory_overlay_request(): + """The old ``Overlay text: '...'`` request asked the model to render + text; that now directly contradicts the no-text instruction above, so it + was removed rather than left in to confuse the model. The disclosure + string itself must not appear in either prompt any more -- only in the + manifest and the burned-in pixels.""" + scene = _scene_rear_end() + timeline = LayoutEngine().build(scene) + for prompt in (illustration_still_prompt(scene, timeline), + illustration_prompt(scene, timeline)): + assert "Overlay text" not in prompt + assert DISCLOSURE not in prompt + + +# ── graceful degradation: no pair to relate ─────────────────────────────────── +def _scene_single_vehicle() -> SceneGraph: + return SceneGraph( + road=Road(layout="parking_lot", lanes_per_direction=1, signal="none"), + vehicles=[Vehicle(id="veh_a", kind="car", color="blue")], + ) + + +def _scene_two_parked_no_impact() -> SceneGraph: + return SceneGraph( + road=Road(layout="parking_lot", lanes_per_direction=1, signal="none"), + vehicles=[ + Vehicle(id="veh_a", kind="car", color="blue"), + Vehicle(id="veh_b", kind="car", color="red"), + ], + movements=[ + Movement(vehicle_id="veh_a", approach="N", maneuver="parked", + speed_band="stopped"), + Movement(vehicle_id="veh_b", approach="N", maneuver="parked", + speed_band="stopped"), + ], + ) + + +def _scene_moving_no_impact() -> SceneGraph: + return SceneGraph( + road=Road(layout="straight", lanes_per_direction=1, signal="none"), + vehicles=[ + Vehicle(id="veh_a", kind="car", color="blue"), + Vehicle(id="veh_b", kind="car", color="red"), + ], + movements=[ + Movement(vehicle_id="veh_a", approach="N", maneuver="straight", + speed_band="low"), + Movement(vehicle_id="veh_b", approach="S", maneuver="straight", + speed_band="low"), + ], + ) + + +def _assert_no_relationship_claimed(prompt: str) -> None: + """No relational clause was invented: the two telltale fragments that + only the relationship sentence ever emits (an orientation clause and a + contact clause) are absent, while the rest of the forensic register is + still intact. (Not checking for the bare word "facing": that could + legitimately appear in unrelated future prose, e.g. a camera direction, + so it would be a fragile, over-broad assertion here.)""" + assert "meeting at" not in prompt + assert "point of impact" not in prompt + assert "Computer-generated 3D forensic accident-reconstruction render" in prompt + assert "no text, no labels, no captions, and no watermarks" in prompt + + +def test_illustration_prompts_handle_single_vehicle_scene(): + scene = _scene_single_vehicle() + timeline = LayoutEngine().build(scene) + for prompt in (illustration_still_prompt(scene, timeline), + illustration_prompt(scene, timeline)): + _assert_no_relationship_claimed(prompt) + assert "blue car" in prompt + + +def test_illustration_prompts_handle_parked_only_scene(): + scene = _scene_two_parked_no_impact() + timeline = LayoutEngine().build(scene) + for prompt in (illustration_still_prompt(scene, timeline), + illustration_prompt(scene, timeline)): + _assert_no_relationship_claimed(prompt) + assert "blue car" in prompt and "red car" in prompt + + +def test_illustration_prompts_handle_scene_with_no_impacts(): + scene = _scene_moving_no_impact() + timeline = LayoutEngine().build(scene) + for prompt in (illustration_still_prompt(scene, timeline), + illustration_prompt(scene, timeline)): + _assert_no_relationship_claimed(prompt) + assert "blue car" in prompt and "red car" in prompt + + +def test_illustration_prompt_ignores_a_timeline_from_a_different_scene(): + """A caller bug -- passing a Timeline that shares no vehicle ids with the + scene -- must degrade to "nothing claimed", never crash. This is the + scenario ``_impact_pose`` and ``_impact_relationship_phrase`` are + defensive about.""" + scene = _scene_rear_end() + disjoint = SceneGraph( + road=Road(layout="parking_lot", lanes_per_direction=1, signal="none"), + vehicles=[Vehicle(id="unrelated_vehicle", kind="car", color="green")], + ) + disjoint_timeline = LayoutEngine().build(disjoint) + for prompt in (illustration_still_prompt(scene, disjoint_timeline), + illustration_prompt(scene, disjoint_timeline)): + _assert_no_relationship_claimed(prompt) + assert "blue car" in prompt and "red car" in prompt + + +def test_illustration_prompt_dedupes_a_vehicle_struck_twice(): + """A vehicle sandwiched in a chain collision can legitimately appear + twice in ``scene.impacts`` (front hit, then rear hit). The relationship + phrase must describe it once, relative to the other participant -- + never a nonsensical self-relationship.""" + scene = SceneGraph( + road=Road(layout="straight", lanes_per_direction=1, signal="none"), + vehicles=[ + Vehicle(id="veh_a", kind="car", color="blue", damage=[ + DamageZone(clock_position=12, severity=DamageSeverity.dent), + DamageZone(clock_position=6, severity=DamageSeverity.dent), + ]), + Vehicle(id="veh_b", kind="car", color="red", damage=[ + DamageZone(clock_position=6, severity=DamageSeverity.crush), + ]), + ], + movements=[ + Movement(vehicle_id="veh_a", approach="N", maneuver="straight", + speed_band="stopped"), + Movement(vehicle_id="veh_b", approach="N", maneuver="straight", + speed_band="moderate"), + ], + impacts=[ + Impact(vehicle_id="veh_a", clock_position=12), + Impact(vehicle_id="veh_a", clock_position=6), + Impact(vehicle_id="veh_b", clock_position=6), + ], + ) + timeline = LayoutEngine().build(scene) + expected = ( + "The red car is directly to the left of the blue car, both facing " + "the same way, in contact at the point of impact." + ) + for prompt in (illustration_still_prompt(scene, timeline), + illustration_prompt(scene, timeline)): + # Exactly one relational sentence, and it is the expected one: the + # duplicate impact entry for the blue car never produces a second + # (nonsensical, self-referential) clause. + assert expected in prompt + assert "the blue car is" not in prompt.lower() + + +# ── direct coverage of the small pure helpers' remaining branches ──────────── +# The four fixture-driven geometries above cannot reach every branch: the +# real LayoutEngine always puts impact participants in contact (see +# ``test_layout.test_impact_points_touch_at_contact_frame``), so "almost +# touching" and "too far to claim contact" never occur from real poses, and +# no committed fixture happens to combine a "directly ahead of/behind" +# position with a perpendicular or oblique orientation. These two pure +# functions are total (no branch depends on anything but their arguments), +# so testing them directly is a faithful, white-box check of the same logic +# the fixture-driven tests exercise end to end. +def test_contact_phrase_buckets(): + assert _contact_phrase(None) is None + assert _contact_phrase(0.0) == "in contact at the point of impact" + assert _contact_phrase(1.0) == "in contact at the point of impact" + assert _contact_phrase(2.0) == "almost touching" + assert _contact_phrase(3.0) == "almost touching" + assert _contact_phrase(3.1) is None + + +def test_idiom_phrase_only_fires_for_aligned_ahead_or_behind(): + assert _idiom_phrase("directly ahead of", "both facing the same way") == "nose to tail" + assert _idiom_phrase("directly behind", "both facing the same way") == "nose to tail" + assert _idiom_phrase("directly ahead of", "facing each other head-on") == "nose to nose" + assert _idiom_phrase("directly behind", "facing each other head-on") == "nose to nose" + # Same position, but an orientation neither idiom fits. + assert _idiom_phrase("directly ahead of", "meeting at right angles") is None + assert _idiom_phrase("directly behind", "meeting at an oblique angle") is None + # A position that is never idiomatic, regardless of orientation. + assert _idiom_phrase("directly to the left of", "both facing the same way") is None