Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions artifacts/multi_family_admissibility_curves.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"layout": "python scripts/check_repo_layout.py",
"check": "npm run layout && npm run typecheck && npm run validate && npm run build && npm run test",
"generate:layered-admissibility": "python scripts/generate_layered_admissibility_artifact.py",
"generate:multi-family-admissibility": "python scripts/generate_multi_family_admissibility_artifact.py"
"generate:multi-family-admissibility": "python scripts/generate_multi_family_admissibility_artifact.py",
"generate:multi-family-svg": "python scripts/render_multi_family_admissibility_svg.py"
}
}
125 changes: 125 additions & 0 deletions scripts/render_multi_family_admissibility_svg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
from __future__ import annotations

import json
from pathlib import Path

INPUT_PATH = Path("artifacts/multi_family_admissibility_results.json")
OUTPUT_PATH = Path("artifacts/multi_family_admissibility_curves.svg")

WIDTH = 1000
HEIGHT = 560
MARGIN_LEFT = 90
MARGIN_RIGHT = 40
MARGIN_TOP = 70
MARGIN_BOTTOM = 120

TITLE = "Multi-Family Admissibility Degradation Curves"
X_LABEL = "degradation level"
Y_LABEL = "overall_admissibility_score"
LEVELS: tuple[str, ...] = ("baseline", "mild", "moderate", "severe")
PALETTE: tuple[str, ...] = (
"#0055aa",
"#aa5500",
"#117733",
"#882255",
"#44aa99",
"#cc6677",
)


def _fmt(value: float) -> str:
return f"{value:.3f}"


def _level_from_fixture_id(fixture_id: str) -> str:
if "_mild_" in fixture_id:
return "mild"
if "_moderate_" in fixture_id:
return "moderate"
if "_degraded_" in fixture_id:
return "severe"
return "baseline"


def render_svg(payload: dict) -> str:
families = sorted(payload["families"], key=lambda family: family["family"])

plot_width = WIDTH - MARGIN_LEFT - MARGIN_RIGHT
plot_height = HEIGHT - MARGIN_TOP - MARGIN_BOTTOM
plot_right = WIDTH - MARGIN_RIGHT
plot_bottom = HEIGHT - MARGIN_BOTTOM

x_by_level = {
level: MARGIN_LEFT + (plot_width * idx / (len(LEVELS) - 1))
for idx, level in enumerate(LEVELS)
}

elements: list[str] = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" viewBox="0 0 {WIDTH} {HEIGHT}">',
f' <rect x="0" y="0" width="{WIDTH}" height="{HEIGHT}" fill="#ffffff"/>',
f' <text x="{WIDTH/2:.1f}" y="36" text-anchor="middle" font-size="22" font-family="monospace" fill="#111111">{TITLE}</text>',
f' <line x1="{MARGIN_LEFT}" y1="{plot_bottom}" x2="{plot_right}" y2="{plot_bottom}" stroke="#222222" stroke-width="1"/>',
f' <line x1="{MARGIN_LEFT}" y1="{MARGIN_TOP}" x2="{MARGIN_LEFT}" y2="{plot_bottom}" stroke="#222222" stroke-width="1"/>',
]

for tick_score in (0.0, 0.5, 1.0):
y = MARGIN_TOP + ((1.0 - tick_score) * plot_height)
elements.append(
f' <line x1="{MARGIN_LEFT}" y1="{_fmt(y)}" x2="{plot_right}" y2="{_fmt(y)}" stroke="#e0e0e0" stroke-width="1"/>'
)
elements.append(
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>'
)

for level in LEVELS:
x = x_by_level[level]
elements.append(
f' <text x="{_fmt(x)}" y="{plot_bottom+24}" text-anchor="middle" font-size="12" font-family="monospace" fill="#222222">{level}</text>'
)

elements.append(
f' <text x="{WIDTH/2:.1f}" y="{HEIGHT-20}" text-anchor="middle" font-size="13" font-family="monospace" fill="#111111">{X_LABEL}</text>'
)
elements.append(
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>'
)

legend_x = 690
legend_y = 84
legend_height = 30 + 18 * len(families)
elements.append(f' <rect x="{legend_x}" y="{legend_y}" width="270" height="{legend_height}" fill="#f8f8f8" stroke="#cccccc"/>')
Comment on lines +89 to +90
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The legend height calculation and placement do not account for the total SVG height. If the number of families grows significantly (e.g., beyond 18-20), the legend box will overlap with the X-axis labels or extend beyond the bottom margin of the SVG (560px). Consider implementing a multi-column legend or adding a check to ensure the legend fits within the available vertical space.

elements.append(f' <text x="{legend_x+12}" y="{legend_y+20}" font-size="12" font-family="monospace" fill="#111111">Families</text>')

for idx, family in enumerate(families):
family_name = family["family"]
color = PALETTE[idx % len(PALETTE)]
points_by_level = {
_level_from_fixture_id(point["fixture_id"]): point
for point in family["curve"]["points"]
}

polyline = " ".join(
f"{_fmt(x_by_level[level])},{_fmt(MARGIN_TOP + ((1.0 - float(points_by_level[level]['overall_admissibility_score'])) * plot_height))}"
for level in LEVELS
)
elements.append(f' <polyline points="{polyline}" fill="none" stroke="{color}" stroke-width="3"/>')

for level in LEVELS:
score = float(points_by_level[level]["overall_admissibility_score"])
x = x_by_level[level]
y = MARGIN_TOP + ((1.0 - score) * plot_height)
elements.append(f' <circle cx="{_fmt(x)}" cy="{_fmt(y)}" r="4" fill="{color}"/>')
Comment on lines +96 to +111
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The rendering logic assumes that every family contains a data point for every level defined in LEVELS. If a point is missing for a specific level, the script will raise a KeyError on lines 102 and 108. Additionally, the Y-coordinate calculation is duplicated. It is safer to pre-calculate the coordinates and handle missing points gracefully.

Suggested change
points_by_level = {
_level_from_fixture_id(point["fixture_id"]): point
for point in family["curve"]["points"]
}
polyline = " ".join(
f"{_fmt(x_by_level[level])},{_fmt(MARGIN_TOP + ((1.0 - float(points_by_level[level]['overall_admissibility_score'])) * plot_height))}"
for level in LEVELS
)
elements.append(f' <polyline points="{polyline}" fill="none" stroke="{color}" stroke-width="3"/>')
for level in LEVELS:
score = float(points_by_level[level]["overall_admissibility_score"])
x = x_by_level[level]
y = MARGIN_TOP + ((1.0 - score) * plot_height)
elements.append(f' <circle cx="{_fmt(x)}" cy="{_fmt(y)}" r="4" fill="{color}"/>')
coords: list[tuple[float, float]] = []
for level in LEVELS:
point = points_by_level.get(level)
if not point:
continue
score = float(point["overall_admissibility_score"])
x = x_by_level[level]
y = MARGIN_TOP + ((1.0 - score) * plot_height)
coords.append((x, y))
if not coords:
continue
polyline_pts = " ".join(f"{_fmt(cx)},{_fmt(cy)}" for cx, cy in coords)
elements.append(f' <polyline points="{polyline_pts}" fill="none" stroke="{color}" stroke-width="3"/>')
for cx, cy in coords:
elements.append(f' <circle cx="{_fmt(cx)}" cy="{_fmt(cy)}" r="4" fill="{color}"/>')


ly = legend_y + 38 + idx * 18
elements.append(f' <line x1="{legend_x+12}" y1="{ly-4}" x2="{legend_x+36}" y2="{ly-4}" stroke="{color}" stroke-width="3"/>')
elements.append(f' <text x="{legend_x+44}" y="{ly}" font-size="11" font-family="monospace" fill="#222222">{family_name}</text>')

elements.append("</svg>")
return "\n".join(elements) + "\n"


if __name__ == "__main__":
payload = json.loads(INPUT_PATH.read_text(encoding="utf-8"))
svg = render_svg(payload)
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_PATH.write_text(svg, encoding="utf-8")
47 changes: 47 additions & 0 deletions tests/test_multi_family_svg_renderer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from __future__ import annotations

import json
from pathlib import Path

from scripts.render_multi_family_admissibility_svg import render_svg

INPUT_PATH = Path("artifacts/multi_family_admissibility_results.json")
SVG_PATH = Path("artifacts/multi_family_admissibility_curves.svg")



def _render() -> str:
payload = json.loads(INPUT_PATH.read_text(encoding="utf-8"))
return render_svg(payload)



def test_multi_family_svg_render_is_deterministic() -> None:
assert _render() == _render()



def test_rendered_svg_matches_committed_artifact() -> None:
assert _render() == SVG_PATH.read_text(encoding="utf-8")



def test_svg_contains_current_families() -> None:
output = _render()
assert "coding_workflow_pr_review" in output
assert "incident_response_page_triage" in output



def test_svg_contains_degradation_levels() -> None:
output = _render()
for level in ("baseline", "mild", "moderate", "severe"):
assert f">{level}<" in output



def test_svg_has_no_nondeterministic_fields() -> None:
output = _render().lower()
banned_tokens = ("timestamp", "date", "time", "random", "uuid", "id=")
for token in banned_tokens:
assert token not in output
Loading