From bd0b76c9e52683c30c54596945d0f91f58e12efd Mon Sep 17 00:00:00 2001 From: Efthimios Fousekis Date: Thu, 30 Jul 2026 12:16:53 +0300 Subject: [PATCH 1/2] feat(illustration): derive impact geometry from the factual layout in the prompt The AI illustration could depict a different accident from the one the data describes: the forensic-reconstruction prompt described each vehicle independently and never stated how impact participants are positioned relative to each other, so a rear-end collision could render as two cars waiting side by side. Derive the real relationship (position, orientation, contact) from the Timeline's computed poses at the impact frame, the same factual layer the schematic is drawn from, and state it in plain language in both illustration prompts. Also instruct the model to render no on-image text, and drop the now-contradictory overlay-text request; the disclosure is guaranteed by the pixel burn-in (watermark.py) instead. --- src/claimscene/pipeline.py | 27 +-- src/claimscene/report.py | 256 ++++++++++++++++++++++++--- tests/unit/test_report.py | 343 ++++++++++++++++++++++++++++++++++++- 3 files changed, 585 insertions(+), 41 deletions(-) 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..39bef33 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,311 @@ 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.""" + assert "meeting at" not in prompt + assert "facing" 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) + for prompt in (illustration_still_prompt(scene, timeline), + illustration_prompt(scene, timeline)): + # Exactly one relational sentence: "blue car" never appears as the + # subject of an "is ... the blue car" clause about itself. + assert prompt.count(" the blue car,") == 1 + assert "the blue car is" not in prompt.lower() + assert "in contact at the point of impact" in prompt + + +# ── 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 From 864a1b241efa75449192f18c29c8c0c544266cc9 Mon Sep 17 00:00:00 2001 From: Efthimios Fousekis Date: Thu, 30 Jul 2026 12:20:36 +0300 Subject: [PATCH 2/2] test(illustration): tighten geometry-fidelity assertions Drop the over-broad bare "facing" absence check (could collide with unrelated future prose) and assert the full expected relational sentence in the duplicate-impact-entry test instead of an incidental substring count. --- tests/unit/test_report.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_report.py b/tests/unit/test_report.py index 39bef33..ed5a56f 100644 --- a/tests/unit/test_report.py +++ b/tests/unit/test_report.py @@ -275,9 +275,10 @@ 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.""" + 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 "facing" 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 @@ -356,13 +357,17 @@ def test_illustration_prompt_dedupes_a_vehicle_struck_twice(): ], ) 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: "blue car" never appears as the - # subject of an "is ... the blue car" clause about itself. - assert prompt.count(" the blue car,") == 1 + # 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() - assert "in contact at the point of impact" in prompt # ── direct coverage of the small pure helpers' remaining branches ────────────