Skip to content

Commit 511f57c

Browse files
authored
Add deterministic multi-family admissibility SVG rendering
1 parent 600d154 commit 511f57c

4 files changed

Lines changed: 208 additions & 1 deletion

File tree

Lines changed: 34 additions & 0 deletions
Loading

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"layout": "python scripts/check_repo_layout.py",
1414
"check": "npm run layout && npm run typecheck && npm run validate && npm run build && npm run test",
1515
"generate:layered-admissibility": "python scripts/generate_layered_admissibility_artifact.py",
16-
"generate:multi-family-admissibility": "python scripts/generate_multi_family_admissibility_artifact.py"
16+
"generate:multi-family-admissibility": "python scripts/generate_multi_family_admissibility_artifact.py",
17+
"generate:multi-family-svg": "python scripts/render_multi_family_admissibility_svg.py"
1718
}
1819
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
6+
INPUT_PATH = Path("artifacts/multi_family_admissibility_results.json")
7+
OUTPUT_PATH = Path("artifacts/multi_family_admissibility_curves.svg")
8+
9+
WIDTH = 1000
10+
HEIGHT = 560
11+
MARGIN_LEFT = 90
12+
MARGIN_RIGHT = 40
13+
MARGIN_TOP = 70
14+
MARGIN_BOTTOM = 120
15+
16+
TITLE = "Multi-Family Admissibility Degradation Curves"
17+
X_LABEL = "degradation level"
18+
Y_LABEL = "overall_admissibility_score"
19+
LEVELS: tuple[str, ...] = ("baseline", "mild", "moderate", "severe")
20+
PALETTE: tuple[str, ...] = (
21+
"#0055aa",
22+
"#aa5500",
23+
"#117733",
24+
"#882255",
25+
"#44aa99",
26+
"#cc6677",
27+
)
28+
29+
30+
def _fmt(value: float) -> str:
31+
return f"{value:.3f}"
32+
33+
34+
def _level_from_fixture_id(fixture_id: str) -> str:
35+
if "_mild_" in fixture_id:
36+
return "mild"
37+
if "_moderate_" in fixture_id:
38+
return "moderate"
39+
if "_degraded_" in fixture_id:
40+
return "severe"
41+
return "baseline"
42+
43+
44+
def render_svg(payload: dict) -> str:
45+
families = sorted(payload["families"], key=lambda family: family["family"])
46+
47+
plot_width = WIDTH - MARGIN_LEFT - MARGIN_RIGHT
48+
plot_height = HEIGHT - MARGIN_TOP - MARGIN_BOTTOM
49+
plot_right = WIDTH - MARGIN_RIGHT
50+
plot_bottom = HEIGHT - MARGIN_BOTTOM
51+
52+
x_by_level = {
53+
level: MARGIN_LEFT + (plot_width * idx / (len(LEVELS) - 1))
54+
for idx, level in enumerate(LEVELS)
55+
}
56+
57+
elements: list[str] = [
58+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" viewBox="0 0 {WIDTH} {HEIGHT}">',
59+
f' <rect x="0" y="0" width="{WIDTH}" height="{HEIGHT}" fill="#ffffff"/>',
60+
f' <text x="{WIDTH/2:.1f}" y="36" text-anchor="middle" font-size="22" font-family="monospace" fill="#111111">{TITLE}</text>',
61+
f' <line x1="{MARGIN_LEFT}" y1="{plot_bottom}" x2="{plot_right}" y2="{plot_bottom}" stroke="#222222" stroke-width="1"/>',
62+
f' <line x1="{MARGIN_LEFT}" y1="{MARGIN_TOP}" x2="{MARGIN_LEFT}" y2="{plot_bottom}" stroke="#222222" stroke-width="1"/>',
63+
]
64+
65+
for tick_score in (0.0, 0.5, 1.0):
66+
y = MARGIN_TOP + ((1.0 - tick_score) * plot_height)
67+
elements.append(
68+
f' <line x1="{MARGIN_LEFT}" y1="{_fmt(y)}" x2="{plot_right}" y2="{_fmt(y)}" stroke="#e0e0e0" stroke-width="1"/>'
69+
)
70+
elements.append(
71+
f' <text x="{MARGIN_LEFT-12}" y="{_fmt(y+4)}" text-anchor="end" font-size="12" font-family="monospace" fill="#333333">{_fmt(tick_score)}</text>'
72+
)
73+
74+
for level in LEVELS:
75+
x = x_by_level[level]
76+
elements.append(
77+
f' <text x="{_fmt(x)}" y="{plot_bottom+24}" text-anchor="middle" font-size="12" font-family="monospace" fill="#222222">{level}</text>'
78+
)
79+
80+
elements.append(
81+
f' <text x="{WIDTH/2:.1f}" y="{HEIGHT-20}" text-anchor="middle" font-size="13" font-family="monospace" fill="#111111">{X_LABEL}</text>'
82+
)
83+
elements.append(
84+
f' <text x="20" y="{HEIGHT/2:.1f}" transform="rotate(-90 20 {HEIGHT/2:.1f})" text-anchor="middle" font-size="13" font-family="monospace" fill="#111111">{Y_LABEL}</text>'
85+
)
86+
87+
legend_x = 690
88+
legend_y = 84
89+
legend_height = 30 + 18 * len(families)
90+
elements.append(f' <rect x="{legend_x}" y="{legend_y}" width="270" height="{legend_height}" fill="#f8f8f8" stroke="#cccccc"/>')
91+
elements.append(f' <text x="{legend_x+12}" y="{legend_y+20}" font-size="12" font-family="monospace" fill="#111111">Families</text>')
92+
93+
for idx, family in enumerate(families):
94+
family_name = family["family"]
95+
color = PALETTE[idx % len(PALETTE)]
96+
points_by_level = {
97+
_level_from_fixture_id(point["fixture_id"]): point
98+
for point in family["curve"]["points"]
99+
}
100+
101+
polyline = " ".join(
102+
f"{_fmt(x_by_level[level])},{_fmt(MARGIN_TOP + ((1.0 - float(points_by_level[level]['overall_admissibility_score'])) * plot_height))}"
103+
for level in LEVELS
104+
)
105+
elements.append(f' <polyline points="{polyline}" fill="none" stroke="{color}" stroke-width="3"/>')
106+
107+
for level in LEVELS:
108+
score = float(points_by_level[level]["overall_admissibility_score"])
109+
x = x_by_level[level]
110+
y = MARGIN_TOP + ((1.0 - score) * plot_height)
111+
elements.append(f' <circle cx="{_fmt(x)}" cy="{_fmt(y)}" r="4" fill="{color}"/>')
112+
113+
ly = legend_y + 38 + idx * 18
114+
elements.append(f' <line x1="{legend_x+12}" y1="{ly-4}" x2="{legend_x+36}" y2="{ly-4}" stroke="{color}" stroke-width="3"/>')
115+
elements.append(f' <text x="{legend_x+44}" y="{ly}" font-size="11" font-family="monospace" fill="#222222">{family_name}</text>')
116+
117+
elements.append("</svg>")
118+
return "\n".join(elements) + "\n"
119+
120+
121+
if __name__ == "__main__":
122+
payload = json.loads(INPUT_PATH.read_text(encoding="utf-8"))
123+
svg = render_svg(payload)
124+
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
125+
OUTPUT_PATH.write_text(svg, encoding="utf-8")
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
6+
from scripts.render_multi_family_admissibility_svg import render_svg
7+
8+
INPUT_PATH = Path("artifacts/multi_family_admissibility_results.json")
9+
SVG_PATH = Path("artifacts/multi_family_admissibility_curves.svg")
10+
11+
12+
13+
def _render() -> str:
14+
payload = json.loads(INPUT_PATH.read_text(encoding="utf-8"))
15+
return render_svg(payload)
16+
17+
18+
19+
def test_multi_family_svg_render_is_deterministic() -> None:
20+
assert _render() == _render()
21+
22+
23+
24+
def test_rendered_svg_matches_committed_artifact() -> None:
25+
assert _render() == SVG_PATH.read_text(encoding="utf-8")
26+
27+
28+
29+
def test_svg_contains_current_families() -> None:
30+
output = _render()
31+
assert "coding_workflow_pr_review" in output
32+
assert "incident_response_page_triage" in output
33+
34+
35+
36+
def test_svg_contains_degradation_levels() -> None:
37+
output = _render()
38+
for level in ("baseline", "mild", "moderate", "severe"):
39+
assert f">{level}<" in output
40+
41+
42+
43+
def test_svg_has_no_nondeterministic_fields() -> None:
44+
output = _render().lower()
45+
banned_tokens = ("timestamp", "date", "time", "random", "uuid", "id=")
46+
for token in banned_tokens:
47+
assert token not in output

0 commit comments

Comments
 (0)