Skip to content

Commit 728c127

Browse files
authored
Add deterministic SVG degradation curve renderer
Add deterministic SVG degradation curve renderer - Add hand-written SVGCurveRenderer for layered admissibility curve artifacts. - Add script to regenerate docs/media/layered_admissibility_curve.svg from committed JSON. - Add deterministic SVG regression tests against the committed artifact. - Update benchmark docs with static visualization reference. Validation reported in PR: SVG renderer tests, full pytest suite, and npm run check passed.
1 parent b090826 commit 728c127

6 files changed

Lines changed: 263 additions & 2 deletions

File tree

docs/benchmarks/layered_admissibility.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,14 @@ Deterministically compare admissibility outcomes across fixture bundles using Co
2727
- no fuzzy matching
2828
- no semantic equivalence
2929

30+
## Visualization
31+
32+
![Layered admissibility degradation curve](../media/layered_admissibility_curve.svg)
33+
34+
This SVG is a deterministic benchmark artifact generated directly from `artifacts/layered_admissibility_results.json` via the hand-written renderer (`src/visualization/svg_curve_renderer.py`). Rendering is pure SVG text generation with fixed canvas geometry, stable ordering, and fixed float precision (three decimals), so output is CI-friendly and reproducible with no stochastic rendering.
35+
3036
## Future
3137

3238
- add more fixture families
33-
- add progressive degradation levels
34-
- add SVG curve visualization later
39+
- extend deterministic benchmark artifacts
40+
- keep visualization static and reproducible
Lines changed: 36 additions & 0 deletions
Loading

scripts/render_curve_svg.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
import sys
6+
7+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
8+
if str(PROJECT_ROOT) not in sys.path:
9+
sys.path.insert(0, str(PROJECT_ROOT))
10+
11+
from src.visualization.svg_curve_renderer import SVGCurveRenderer
12+
13+
INPUT_PATH = Path("artifacts/layered_admissibility_results.json")
14+
OUTPUT_PATH = Path("docs/media/layered_admissibility_curve.svg")
15+
16+
17+
if __name__ == "__main__":
18+
payload = json.loads(INPUT_PATH.read_text(encoding="utf-8"))
19+
svg = SVGCurveRenderer().render(payload)
20+
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
21+
OUTPUT_PATH.write_text(svg, encoding="utf-8")

src/visualization/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Deterministic visualization helpers."""
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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"

tests/test_svg_curve_renderer.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import re
5+
from pathlib import Path
6+
7+
from src.visualization.svg_curve_renderer import SVGCurveRenderer
8+
9+
INPUT_PATH = Path("artifacts/layered_admissibility_results.json")
10+
SVG_PATH = Path("docs/media/layered_admissibility_curve.svg")
11+
12+
13+
def _render() -> str:
14+
payload = json.loads(INPUT_PATH.read_text(encoding="utf-8"))
15+
return SVGCurveRenderer().render(payload)
16+
17+
18+
def test_svg_render_is_deterministic() -> None:
19+
assert _render() == _render()
20+
21+
22+
def test_svg_root_exists() -> None:
23+
output = _render()
24+
assert output.startswith('<svg xmlns="http://www.w3.org/2000/svg"')
25+
assert output.strip().endswith("</svg>")
26+
27+
28+
def test_svg_contains_fixture_labels() -> None:
29+
output = _render()
30+
assert "coding_workflow_pr_review_v1" in output
31+
assert "coding_workflow_pr_review_mild_v1" in output
32+
assert "coding_workflow_pr_review_moderate_v1" in output
33+
assert "coding_workflow_pr_review_degraded_v1" in output
34+
35+
36+
def test_svg_contains_expected_failure_annotations() -> None:
37+
output = _render()
38+
for label in [
39+
"RECOVERY_PATH_INVALID",
40+
"CAUSAL_DEPENDENCY_LOSS",
41+
"POLICY_ORDER_BROKEN",
42+
"INVARIANT_VIOLATION",
43+
]:
44+
assert label in output
45+
46+
47+
def test_svg_polyline_coordinates_monotonic_degradation() -> None:
48+
output = _render()
49+
match = re.search(r'<polyline points="([^"]+)"', output)
50+
assert match
51+
points = match.group(1).split(" ")
52+
y_values = [float(point.split(",")[1]) for point in points]
53+
assert y_values == sorted(y_values)
54+
55+
56+
def test_svg_uses_stable_float_formatting() -> None:
57+
output = _render()
58+
assert "960.000,225.000" in output
59+
assert "380.000,95.833" in output
60+
assert "0.917" in output
61+
62+
63+
def test_rendered_svg_matches_committed_artifact() -> None:
64+
generated = _render()
65+
committed = SVG_PATH.read_text(encoding="utf-8")
66+
assert generated == committed

0 commit comments

Comments
 (0)