|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +from dataclasses import dataclass |
| 4 | +from html import escape |
| 5 | + |
| 6 | + |
| 7 | +@dataclass(frozen=True, slots=True) |
| 8 | +class _PointLayout: |
| 9 | + fixture_id: str |
| 10 | + score: float |
| 11 | + x: float |
| 12 | + y: float |
| 13 | + failure_labels: tuple[str, ...] |
| 14 | + |
| 15 | + |
| 16 | +class SVGCurveRenderer: |
| 17 | + WIDTH = 1000 |
| 18 | + HEIGHT = 520 |
| 19 | + MARGIN_LEFT = 90 |
| 20 | + MARGIN_RIGHT = 40 |
| 21 | + MARGIN_TOP = 70 |
| 22 | + MARGIN_BOTTOM = 140 |
| 23 | + |
| 24 | + TITLE = "Layered Admissibility Degradation Curve" |
| 25 | + X_LABEL = "Fixture progression" |
| 26 | + Y_LABEL = "overall_admissibility_score" |
| 27 | + |
| 28 | + X_TICKS: tuple[tuple[str, str], ...] = ( |
| 29 | + ("coding_workflow_pr_review_v1", "positive"), |
| 30 | + ("coding_workflow_pr_review_mild_v1", "mild"), |
| 31 | + ("coding_workflow_pr_review_moderate_v1", "moderate"), |
| 32 | + ("coding_workflow_pr_review_degraded_v1", "severe"), |
| 33 | + ) |
| 34 | + |
| 35 | + LEGEND_ITEMS: tuple[str, ...] = ("structural", "relational", "operational", "governance") |
| 36 | + |
| 37 | + FAILURE_ANNOTATION_ORDER: tuple[str, ...] = ( |
| 38 | + "RECOVERY_PATH_INVALID", |
| 39 | + "CAUSAL_DEPENDENCY_LOSS", |
| 40 | + "POLICY_ORDER_BROKEN", |
| 41 | + "INVARIANT_VIOLATION", |
| 42 | + ) |
| 43 | + |
| 44 | + def _fmt(self, value: float) -> str: |
| 45 | + return f"{value:.3f}" |
| 46 | + |
| 47 | + def _layout_points(self, curve_json: dict) -> tuple[_PointLayout, ...]: |
| 48 | + points_by_fixture = {point["fixture_id"]: point for point in curve_json["points"]} |
| 49 | + plot_width = self.WIDTH - self.MARGIN_LEFT - self.MARGIN_RIGHT |
| 50 | + plot_height = self.HEIGHT - self.MARGIN_TOP - self.MARGIN_BOTTOM |
| 51 | + |
| 52 | + layouts: list[_PointLayout] = [] |
| 53 | + for index, (fixture_id, _) in enumerate(self.X_TICKS): |
| 54 | + point = points_by_fixture[fixture_id] |
| 55 | + score = float(point["overall_admissibility_score"]) |
| 56 | + x = self.MARGIN_LEFT + (plot_width * index / (len(self.X_TICKS) - 1)) |
| 57 | + y = self.MARGIN_TOP + ((1.0 - score) * plot_height) |
| 58 | + layouts.append( |
| 59 | + _PointLayout( |
| 60 | + fixture_id=fixture_id, |
| 61 | + score=score, |
| 62 | + x=x, |
| 63 | + y=y, |
| 64 | + failure_labels=tuple(sorted(point["failure_labels"])), |
| 65 | + ) |
| 66 | + ) |
| 67 | + return tuple(layouts) |
| 68 | + |
| 69 | + def render(self, curve_json: dict) -> str: |
| 70 | + layouts = self._layout_points(curve_json) |
| 71 | + plot_bottom = self.HEIGHT - self.MARGIN_BOTTOM |
| 72 | + plot_right = self.WIDTH - self.MARGIN_RIGHT |
| 73 | + |
| 74 | + polyline_points = " ".join(f"{self._fmt(p.x)},{self._fmt(p.y)}" for p in layouts) |
| 75 | + elements: list[str] = [ |
| 76 | + f'<svg xmlns="http://www.w3.org/2000/svg" width="{self.WIDTH}" height="{self.HEIGHT}" viewBox="0 0 {self.WIDTH} {self.HEIGHT}">', |
| 77 | + ' <rect x="0" y="0" width="1000" height="520" fill="#ffffff"/>', |
| 78 | + f' <text x="{self.WIDTH/2:.1f}" y="36" text-anchor="middle" font-size="22" font-family="monospace" fill="#111111">{self.TITLE}</text>', |
| 79 | + f' <line x1="{self.MARGIN_LEFT}" y1="{plot_bottom}" x2="{plot_right}" y2="{plot_bottom}" stroke="#222222" stroke-width="1"/>', |
| 80 | + f' <line x1="{self.MARGIN_LEFT}" y1="{self.MARGIN_TOP}" x2="{self.MARGIN_LEFT}" y2="{plot_bottom}" stroke="#222222" stroke-width="1"/>', |
| 81 | + ] |
| 82 | + |
| 83 | + for tick_score in (0.0, 0.5, 1.0): |
| 84 | + y = self.MARGIN_TOP + ((1.0 - tick_score) * (self.HEIGHT - self.MARGIN_TOP - self.MARGIN_BOTTOM)) |
| 85 | + elements.append( |
| 86 | + f' <line x1="{self.MARGIN_LEFT}" y1="{self._fmt(y)}" x2="{plot_right}" y2="{self._fmt(y)}" stroke="#e0e0e0" stroke-width="1"/>' |
| 87 | + ) |
| 88 | + elements.append( |
| 89 | + f' <text x="{self.MARGIN_LEFT-12}" y="{self._fmt(y+4)}" text-anchor="end" font-size="12" font-family="monospace" fill="#333333">{self._fmt(tick_score)}</text>' |
| 90 | + ) |
| 91 | + |
| 92 | + for point, (_, stage_name) in zip(layouts, self.X_TICKS): |
| 93 | + elements.append( |
| 94 | + f' <text x="{self._fmt(point.x)}" y="{plot_bottom+22}" text-anchor="middle" font-size="12" font-family="monospace" fill="#222222">{stage_name}</text>' |
| 95 | + ) |
| 96 | + |
| 97 | + elements.extend( |
| 98 | + [ |
| 99 | + f' <polyline points="{polyline_points}" fill="none" stroke="#0055aa" stroke-width="3"/>', |
| 100 | + f' <text x="{self.WIDTH/2:.1f}" y="{self.HEIGHT-20}" text-anchor="middle" font-size="13" font-family="monospace" fill="#111111">{self.X_LABEL}</text>', |
| 101 | + f' <text x="20" y="{self.HEIGHT/2:.1f}" transform="rotate(-90 20 {self.HEIGHT/2:.1f})" text-anchor="middle" font-size="13" font-family="monospace" fill="#111111">{self.Y_LABEL}</text>', |
| 102 | + ] |
| 103 | + ) |
| 104 | + |
| 105 | + for point in layouts: |
| 106 | + elements.append( |
| 107 | + f' <circle cx="{self._fmt(point.x)}" cy="{self._fmt(point.y)}" r="5" fill="#0055aa"/>' |
| 108 | + ) |
| 109 | + elements.append( |
| 110 | + f' <text x="{self._fmt(point.x)}" y="{self._fmt(point.y-12)}" text-anchor="middle" font-size="11" font-family="monospace" fill="#111111">{escape(point.fixture_id)} | {self._fmt(point.score)}</text>' |
| 111 | + ) |
| 112 | + |
| 113 | + y_base = plot_bottom + 44 |
| 114 | + for point in layouts[1:]: |
| 115 | + ordered_labels = [label for label in self.FAILURE_ANNOTATION_ORDER if label in point.failure_labels] |
| 116 | + if ordered_labels: |
| 117 | + elements.append( |
| 118 | + f' <text x="{self._fmt(point.x)}" y="{y_base}" text-anchor="middle" font-size="10" font-family="monospace" fill="#aa2200">{", ".join(ordered_labels)}</text>' |
| 119 | + ) |
| 120 | + |
| 121 | + legend_x = 700 |
| 122 | + legend_y = 84 |
| 123 | + elements.append(f' <rect x="{legend_x}" y="{legend_y}" width="250" height="104" fill="#f8f8f8" stroke="#cccccc"/>') |
| 124 | + elements.append(f' <text x="{legend_x+12}" y="{legend_y+18}" font-size="12" font-family="monospace" fill="#111111">Legend (component scores)</text>') |
| 125 | + for idx, item in enumerate(self.LEGEND_ITEMS): |
| 126 | + elements.append( |
| 127 | + f' <text x="{legend_x+16}" y="{legend_y+36 + idx*16}" font-size="11" font-family="monospace" fill="#333333">- {item}</text>' |
| 128 | + ) |
| 129 | + |
| 130 | + elements.append("</svg>") |
| 131 | + return "\n".join(elements) + "\n" |
0 commit comments