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
115 changes: 109 additions & 6 deletions src/rtichoke/_report_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,81 @@ def _resolve_render_report_symbol(
)


def _performance_metrics_cheat_sheet_html() -> str:
"""Generate Performance Metrics Cheat Sheet HTML for browser summary reports."""
return (
'<details class="rtichoke-cheat-sheet">\n'
" <summary>Performance Metrics Cheat Sheet</summary>\n"
' <div class="rtichoke-cheat-sheet__content">\n'
' <section class="rtichoke-cheat-sheet__section">\n'
" <h4>Confusion Matrix</h4>\n"
' <table class="rtichoke-cheat-sheet__table">\n'
" <thead>\n"
" <tr>\n"
" <th></th>\n"
" <th>Predicted +</th>\n"
" <th>Predicted -</th>\n"
" </tr>\n"
" </thead>\n"
" <tbody>\n"
" <tr>\n"
" <th>Real Positive</th>\n"
" <td>TP</td>\n"
" <td>FN</td>\n"
" </tr>\n"
" <tr>\n"
" <th>Real Negative</th>\n"
" <td>FP</td>\n"
" <td>TN</td>\n"
" </tr>\n"
" </tbody>\n"
" </table>\n"
" </section>\n"
' <section class="rtichoke-cheat-sheet__section">\n'
" <h4>Metrics &amp; Formulas</h4>\n"
' <dl class="rtichoke-cheat-sheet__metrics">\n'
" <dt>Prevalence</dt>\n"
" <dd><code>(TP + FN) / (TP + FP + TN + FN)</code></dd>\n"
" <dt>PPCR</dt>\n"
" <dd><code>(TP + FP) / (TP + FP + TN + FN)</code></dd>\n"
" <dt>Sensitivity / Recall / TPR</dt>\n"
" <dd>\n"
" <code>TP / (TP + FN)</code><br />\n"
" <code>TP / Real Positives</code><br />\n"
" <code>P(Predicted Positive | Real Positive)</code>\n"
" </dd>\n"
" <dt>Specificity / TNR</dt>\n"
" <dd>\n"
" <code>TN / (TN + FP)</code><br />\n"
" <code>TN / Real Negatives</code><br />\n"
" <code>P(Predicted Negative | Real Negative)</code>\n"
" </dd>\n"
" <dt>PPV / Precision</dt>\n"
" <dd>\n"
" <code>TP / (TP + FP)</code><br />\n"
" <code>TP / Predicted Positives</code><br />\n"
" <code>P(Real Positive | Predicted Positive)</code>\n"
" </dd>\n"
" <dt>NPV</dt>\n"
" <dd>\n"
" <code>TN / (TN + FN)</code><br />\n"
" <code>TN / Predicted Negatives</code><br />\n"
" <code>P(Real Negative | Predicted Negative)</code>\n"
" </dd>\n"
" <dt>Lift</dt>\n"
" <dd><code>PPV / Prevalence</code></dd>\n"
" <dt>Net Benefit</dt>\n"
" <dd>\n"
" <code>TP / N - FP / N * p_t / (1 - p_t)</code><br />\n"
" <small>where N = TP + FP + TN + FN</small>\n"
" </dd>\n"
" </dl>\n"
" </section>\n"
" </div>\n"
"</details>"
)


def _sanitize_nan_values(obj: Any) -> Any:
"""Recursively replace NaN and Inf float values with None for valid JSON serialization."""
if isinstance(obj, dict):
Expand Down Expand Up @@ -104,8 +179,14 @@ def _summary_report_density_css() -> str:
class RtichokeBrowserReport:
"""A complete canonical ReportSpec rendered by shared ``rtichoke_viz``."""

def __init__(self, spec: dict[str, Any]) -> None:
def __init__(
self,
spec: dict[str, Any],
*,
include_cheat_sheet: bool = False,
) -> None:
self.spec = spec
self.include_cheat_sheet = include_cheat_sheet

def write_html(self, path: str | Path) -> Path:
"""Write an offline HTML page that delegates composition to renderReport()."""
Expand All @@ -128,6 +209,32 @@ def write_html(self, path: str | Path) -> Path:
else:
doc_title = "rtichoke report"

if self.include_cheat_sheet:
cheat_sheet_json = json.dumps(
_performance_metrics_cheat_sheet_html()
).replace("</", "<\\/")
mount_js = f""" const reportNode = {render_fn}(spec, {{
sectionGroupPresentation: "tabs",
groupPresentation: "tabs",
sectionComponentPresentation: "tabs"
}});
const headerNode = reportNode.querySelector(".rtichoke-report__header");
const cheatSheetWrapper = document.createElement("div");
cheatSheetWrapper.innerHTML = {cheat_sheet_json};
const cheatSheetNode = cheatSheetWrapper.firstElementChild;
if (headerNode && headerNode.nextSibling) {{
reportNode.insertBefore(cheatSheetNode, headerNode.nextSibling);
}} else {{
reportNode.appendChild(cheatSheetNode);
}}
document.querySelector("#rtichoke-report").append(reportNode);"""
else:
mount_js = f""" document.querySelector("#rtichoke-report").append({render_fn}(spec, {{
sectionGroupPresentation: "tabs",
groupPresentation: "tabs",
sectionComponentPresentation: "tabs"
}}));"""

html_content = f"""<!doctype html>
<html lang="en">
<head>
Expand All @@ -147,11 +254,7 @@ def write_html(self, path: str | Path) -> Path:
const spec = JSON.parse(
document.querySelector("#rtichoke-report-spec").textContent
);
document.querySelector("#rtichoke-report").append({render_fn}(spec, {{
sectionGroupPresentation: "tabs",
groupPresentation: "tabs",
sectionComponentPresentation: "tabs"
}}));
{mount_js}
</script>
</body>
</html>
Expand Down
4 changes: 3 additions & 1 deletion src/rtichoke/summary_report/summary_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,9 @@ def _create_browser_summary_report(
]

report = _build_report_spec_v11(sections, title="Summary Report")
return RtichokeBrowserReport(cast(dict[str, Any], report)).write_html(output_file)
return RtichokeBrowserReport(
cast(dict[str, Any], report), include_cheat_sheet=True
).write_html(output_file)


def render_summary_report():
Expand Down
4 changes: 2 additions & 2 deletions tests/test_quarto_summary_report_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ def test_quarto_single_browser_summary_report(tmp_path):
assert frame is not None

frame.wait_for_selector("#rtichoke-report")
frame.wait_for_selector("table")
frame.wait_for_selector(".rtichoke-performance-table__table")
frame.wait_for_selector("svg")

assert "Performance" in frame.content()
Expand Down Expand Up @@ -230,7 +230,7 @@ def test_quarto_two_browser_summary_reports(tmp_path):
assert frame is not None

frame.wait_for_selector("#rtichoke-report")
frame.wait_for_selector("table")
frame.wait_for_selector(".rtichoke-performance-table__table")
frame.wait_for_selector("svg")

assert "Performance" in frame.content()
Expand Down
66 changes: 66 additions & 0 deletions tests/test_report_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
)
from rtichoke._report_browser import (
RtichokeBrowserReport,
_performance_metrics_cheat_sheet_html,
_resolve_render_report_symbol,
)
from rtichoke._report_spec import _build_report_spec_v11
Expand Down Expand Up @@ -253,3 +254,68 @@ def test_resolve_render_report_symbol_export_clauses():
missing_js = "export { foo, bar };"
with pytest.raises(ValueError, match="Could not resolve 'renderReport' export"):
_resolve_render_report_symbol(missing_js)


def test_cheat_sheet_html_exact_markup():
html = _performance_metrics_cheat_sheet_html()
assert html.startswith('<details class="rtichoke-cheat-sheet">\n')
assert "<summary>Performance Metrics Cheat Sheet</summary>" in html
assert "<h4>Confusion Matrix</h4>" in html
assert "<th>Predicted +</th>" in html
assert "<th>Predicted -</th>" in html
assert "<th>Real Positive</th>" in html
assert "<td>TP</td>" in html
assert "<td>FN</td>" in html
assert "<th>Real Negative</th>" in html
assert "<td>FP</td>" in html
assert "<td>TN</td>" in html
assert "<h4>Metrics &amp; Formulas</h4>" in html
assert "<dt>Prevalence</dt>" in html
assert "<dd><code>(TP + FN) / (TP + FP + TN + FN)</code></dd>" in html
assert "<dt>PPCR</dt>" in html
assert "<dd><code>(TP + FP) / (TP + FP + TN + FN)</code></dd>" in html
assert "<dt>Sensitivity / Recall / TPR</dt>" in html
assert "<code>TP / (TP + FN)</code>" in html
assert "<code>TP / Real Positives</code>" in html
assert "<code>P(Predicted Positive | Real Positive)</code>" in html
assert "<dt>Specificity / TNR</dt>" in html
assert "<code>TN / (TN + FP)</code>" in html
assert "<code>TN / Real Negatives</code>" in html
assert "<code>P(Predicted Negative | Real Negative)</code>" in html
assert "<dt>PPV / Precision</dt>" in html
assert "<code>TP / (TP + FP)</code>" in html
assert "<code>TP / Predicted Positives</code>" in html
assert "<code>P(Real Positive | Predicted Positive)</code>" in html
assert "<dt>NPV</dt>" in html
assert "<code>TN / (TN + FN)</code>" in html
assert "<code>TN / Predicted Negatives</code>" in html
assert "<code>P(Real Negative | Predicted Negative)</code>" in html
assert "<dt>Lift</dt>" in html
assert "<dd><code>PPV / Prevalence</code></dd>" in html
assert "<dt>Net Benefit</dt>" in html
assert "<code>TP / N - FP / N * p_t / (1 - p_t)</code>" in html
assert "<small>where N = TP + FP + TN + FN</small>" in html
assert html.endswith("</details>")


def test_browser_report_include_cheat_sheet_flag_default(tmp_path):
report_spec = {"title": "Summary Report", "sections": []}
report = RtichokeBrowserReport(report_spec)
assert report.include_cheat_sheet is False

output = report.write_html(tmp_path / "default_report.html")
html_content = output.read_text(encoding="utf-8")
assert "rtichoke-cheat-sheet" not in html_content


def test_browser_report_include_cheat_sheet_opt_in(tmp_path):
report_spec = {"title": "Summary Report", "sections": []}
report = RtichokeBrowserReport(report_spec, include_cheat_sheet=True)
assert report.include_cheat_sheet is True

output = report.write_html(tmp_path / "cheat_sheet_report.html")
html_content = output.read_text(encoding="utf-8")
assert "rtichoke-cheat-sheet" in html_content
assert "cheatSheetWrapper.innerHTML =" in html_content
assert "insertBefore(cheatSheetNode, headerNode.nextSibling)" in html_content
assert _embedded_report(html_content) == report_spec
35 changes: 35 additions & 0 deletions tests/test_summary_report_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,41 @@ def test_static_performance_table_confusion_matrix_disclosure(tmp_path):
page.on("console", lambda msg: print("CONSOLE:", msg.type, msg.text))
page.on("pageerror", lambda err: print("PAGE ERROR:", err))

# 0. Cheat sheet presence, placement, and content in static summary report
page.goto(f"{base_url}/{output_thresh.name}")
page.wait_for_selector(".rtichoke-report")

cheat_sheets = page.locator(".rtichoke-cheat-sheet")
assert cheat_sheets.count() == 1

cs = cheat_sheets.first
summary_el = cs.locator("summary")
assert summary_el.inner_text() == "Performance Metrics Cheat Sheet"

# Verify element order: .rtichoke-report__header -> .rtichoke-cheat-sheet -> .rtichoke-report__nav
is_correct_order = page.evaluate("""() => {
const header = document.querySelector('.rtichoke-report__header');
const cheatSheet = document.querySelector('.rtichoke-cheat-sheet');
const nav = document.querySelector('.rtichoke-report__nav');
if (!header || !cheatSheet || !nav) return false;
const afterHeader = (header.compareDocumentPosition(cheatSheet) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
const beforeNav = (cheatSheet.compareDocumentPosition(nav) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
return afterHeader && beforeNav;
}""")
assert is_correct_order is True

# Check cheat sheet text/formulas
cs_text = cs.text_content()
assert "Confusion Matrix" in cs_text
assert "Prevalence" in cs_text
assert "PPCR" in cs_text
assert "Sensitivity / Recall / TPR" in cs_text
assert "Specificity / TNR" in cs_text
assert "PPV / Precision" in cs_text
assert "NPV" in cs_text
assert "Lift" in cs_text
assert "Net Benefit" in cs_text

# 1. Static probability threshold performance table disclosure
page.goto(f"{base_url}/{output_thresh.name}")
page.wait_for_selector(".rtichoke-performance-table__table")
Expand Down
1 change: 1 addition & 0 deletions tests/test_summary_report_times_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def _assert_report_rendered(browser: subprocess.CompletedProcess[str]) -> None:
assert "By Predicted Positives Condition Rate (PPCR)" in rendered
assert "<table" in rendered
assert rendered.count("<svg") >= 2
assert "rtichoke-cheat-sheet" not in rendered


def test_public_export_create_summary_report_times():
Expand Down
Loading