From fa2ac4be9f699962eb449d2134e3e6d7405adbf4 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:25:18 +0300 Subject: [PATCH 001/153] Prototype native D3 summary report --- src/rtichoke/summary_report/summary_report.py | 187 +++++++++++------- 1 file changed, 120 insertions(+), 67 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report.py b/src/rtichoke/summary_report/summary_report.py index 0db544f6..dabc8a40 100644 --- a/src/rtichoke/summary_report/summary_report.py +++ b/src/rtichoke/summary_report/summary_report.py @@ -1,81 +1,134 @@ -""" -A module for Summary Report -""" +"""Lightweight HTML summary reports for rtichoke.""" -from rtichoke.processing.send_post_request_to_r_rtichoke import ( - send_requests_to_rtichoke_r, -) -from rtichoke.processing.transforms import ( - _create_list_data_to_adjust, -) -import subprocess +from __future__ import annotations +import json +from pathlib import Path +from typing import Dict, Union -def create_summary_report(probs, reals, url_api="http://localhost:4242/"): - """Creates a summary report for rtichoke model performance. +import numpy as np - Parameters - ---------- - probs : Dict[str, np.ndarray] - A dictionary mapping model names to predicted probabilities. - reals : Union[np.ndarray, Dict[str, np.ndarray]] - The true outcome labels (0 or 1). - url_api : str, optional - The API endpoint URL of the R rtichoke backend. - Defaults to ``"http://localhost:4242/"``. - """ - rtichoke_response = send_requests_to_rtichoke_r( - dictionary_to_send={"probs": probs, "reals": reals}, - url_api=url_api, - endpoint="create_summary_report", - ) - print(rtichoke_response.json()[0].keys()) +from rtichoke.performance_data.performance_data import prepare_performance_data -def render_summary_report(): - """ - Render the rtichoke Summary Report using Quarto. +_CURVES = [ + ("roc", "ROC", "false_positive_rate", "sensitivity", "1 - Specificity", "Sensitivity"), + ("precision-recall", "Precision–Recall", "sensitivity", "ppv", "Sensitivity", "PPV"), + ("gains", "Gains", "ppcr", "sensitivity", "Predicted positives", "Sensitivity"), + ("lift", "Lift", "ppcr", "lift", "Predicted positives", "Lift"), + ("decision", "Decision Curve", "chosen_cutoff", "net_benefit", "Probability threshold", "Net benefit"), +] - Args: - probs (list): A list of probabilities. - reals (list): A list of real values. - times (list): A list of absolute numbers representing timestamps. - Example: - probs = [0.1, 0.4, 0.8] - reals = [0, 1, 1] - times = [1, 3, 5] - render_summary_report(probs, reals, times) +def _json_rows(performance_data): + columns = { + "reference_group", + "stratified_by", + "chosen_cutoff", + "false_positive_rate", + "sensitivity", + "ppv", + "ppcr", + "lift", + "net_benefit", + } + available = [column for column in performance_data.columns if column in columns] + data = performance_data.select(available) + if "stratified_by" in available: + data = data.filter(data["stratified_by"] == "probability_threshold") + return data.to_dicts() - This will generate a `summary_report.html` file based on the `summary_report_template.qmd`. - """ - # Define the path to the template and output file - template_path = "aj_estimate_summary_report.qmd" - output_path = "summary_report.html" - # Prepare the command to render the Quarto document - command = [ - "quarto", - "render", - template_path, - "--to", - "html", - "--output", - output_path, # , - # "--execute-params", - # f"probs={probs},reals={reals},times={times}", - ] +def _report_html(rows: list[dict]) -> str: + payload = json.dumps(rows, separators=(",", ":"), default=str).replace(" + + + + +rtichoke summary report + + + +
+

Model Performance Summary

+

One shared rtichoke performance-data calculation, rendered as lightweight linked charts.

+
+

D3 proof of concept. The preview currently loads D3 from a CDN; the production implementation will bundle it for a truly self-contained HTML file.

+
+""" - # Execute the command - subprocess.run(command, check=True) +def create_summary_report( + probs: Dict[str, np.ndarray], + reals: Union[np.ndarray, Dict[str, np.ndarray]], + output_file: str | Path = "summary_report.html", + by: float = 0.01, +) -> Path: + """Create a native HTML summary report for binary model performance. -def create_data_for_summary_report(probs, reals, times, fixed_time_horizons): - stratified_by = ["probability_threshold", "ppcr"] - by = 0.1 - - list_data_to_adjust_polars = _create_list_data_to_adjust( - probs, reals, times, stratified_by=stratified_by, by=by, times_dict={} + Performance data are prepared once and reused by all report panels. + The current proof of concept renders ROC, precision-recall, gains, lift, + and decision curves with D3. + """ + performance_data = prepare_performance_data( + probs=probs, + reals=reals, + stratified_by=("probability_threshold",), + by=by, ) - - return list_data_to_adjust_polars + output_path = Path(output_file) + output_path.write_text(_report_html(_json_rows(performance_data)), encoding="utf-8") + return output_path From 27b02315e89085270173d8dd64ac2364b9ce4934 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:25:27 +0300 Subject: [PATCH 002/153] Add summary report preview example --- examples/summary_report_demo.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 examples/summary_report_demo.py diff --git a/examples/summary_report_demo.py b/examples/summary_report_demo.py new file mode 100644 index 00000000..921a4670 --- /dev/null +++ b/examples/summary_report_demo.py @@ -0,0 +1,17 @@ +"""Generate the summary-report proof of concept used by PR previews.""" + +import numpy as np + +from rtichoke import create_summary_report + +rng = np.random.default_rng(2026) +n = 800 +signal = rng.normal(size=n) +reals = rng.binomial(1, 1 / (1 + np.exp(-signal))) + +probs = { + "Model A": np.clip(1 / (1 + np.exp(-(0.9 * signal + rng.normal(0, 0.55, n)))), 0.001, 0.999), + "Model B": np.clip(1 / (1 + np.exp(-(0.6 * signal + rng.normal(0, 0.85, n)))), 0.001, 0.999), +} + +create_summary_report(probs, reals, output_file="summary-report-demo.html") From caf3fa86659023fb65f51ee75c5f2c708602e783 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:25:41 +0300 Subject: [PATCH 003/153] Publish summary report in docs previews --- .github/workflows/docs.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index facc9e1c..4c7d8505 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -48,6 +48,11 @@ jobs: mv performance-table-reactable.html great-docs/_site/performance-table-reactable.html cp -R examples/performance_table_reactable_files great-docs/_site/performance_table_reactable_files + - name: Generate summary report demo + run: | + uv run python examples/summary_report_demo.py + mv summary-report-demo.html great-docs/_site/summary-report-demo.html + - name: Publish documentation uses: JamesIves/github-pages-deploy-action@v4 with: @@ -95,6 +100,13 @@ jobs: mv performance-table-reactable.html great-docs/_site/performance-table-reactable.html cp -R examples/performance_table_reactable_files great-docs/_site/performance_table_reactable_files + - name: Generate summary report demo + if: github.event.action != 'closed' + run: | + uv run python examples/summary_report_demo.py + test -s summary-report-demo.html + mv summary-report-demo.html great-docs/_site/summary-report-demo.html + - name: Deploy PR preview uses: rossjrw/pr-preview-action@v1 with: From 099275530627996a3b565021f439c998c39049de Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:25:50 +0300 Subject: [PATCH 004/153] Test native summary report output --- tests/test_summary_report.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/test_summary_report.py diff --git a/tests/test_summary_report.py b/tests/test_summary_report.py new file mode 100644 index 00000000..b8a66366 --- /dev/null +++ b/tests/test_summary_report.py @@ -0,0 +1,19 @@ +import numpy as np + +from rtichoke.summary_report.summary_report import create_summary_report + + +def test_create_summary_report_writes_native_html(tmp_path): + probs = {"model": np.array([0.05, 0.2, 0.4, 0.7, 0.9])} + reals = np.array([0, 0, 1, 1, 1]) + output = tmp_path / "report.html" + + result = create_summary_report(probs, reals, output_file=output, by=0.1) + + html = output.read_text(encoding="utf-8") + assert result == output + assert "Model Performance Summary" in html + assert "Precision–Recall" in html + assert "Decision Curve" in html + assert "send_requests_to_rtichoke_r" not in html + assert "quarto" not in html.lower() From cf6fc30a286758127dbabe5fc143d7c4dfa2a3cd Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:25:57 +0300 Subject: [PATCH 005/153] Verify summary report prepares performance data once --- tests/test_summary_report_shared_data.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/test_summary_report_shared_data.py diff --git a/tests/test_summary_report_shared_data.py b/tests/test_summary_report_shared_data.py new file mode 100644 index 00000000..117d6cb6 --- /dev/null +++ b/tests/test_summary_report_shared_data.py @@ -0,0 +1,23 @@ +import numpy as np + +import rtichoke.summary_report.summary_report as summary_report + + +def test_summary_report_prepares_performance_data_once(monkeypatch, tmp_path): + original = summary_report.prepare_performance_data + calls = 0 + + def counted(*args, **kwargs): + nonlocal calls + calls += 1 + return original(*args, **kwargs) + + monkeypatch.setattr(summary_report, "prepare_performance_data", counted) + summary_report.create_summary_report( + {"model": np.array([0.1, 0.3, 0.6, 0.8])}, + np.array([0, 0, 1, 1]), + output_file=tmp_path / "report.html", + by=0.1, + ) + + assert calls == 1 From 191698d73736d831e46c5fb0f8ef3675f2cae191 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:26:05 +0300 Subject: [PATCH 006/153] Add summary report preparation benchmark --- benchmarks/benchmark_summary_report.py | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 benchmarks/benchmark_summary_report.py diff --git a/benchmarks/benchmark_summary_report.py b/benchmarks/benchmark_summary_report.py new file mode 100644 index 00000000..61e2c407 --- /dev/null +++ b/benchmarks/benchmark_summary_report.py @@ -0,0 +1,30 @@ +"""Small local benchmark for repeated vs shared performance preparation.""" + +from time import perf_counter + +import numpy as np + +from rtichoke import prepare_performance_data + + +def run(n: int = 100_000, repeats: int = 5) -> None: + rng = np.random.default_rng(2026) + reals = rng.binomial(1, 0.25, n) + probs = {"model": np.clip(0.1 + 0.65 * reals + rng.normal(0, 0.18, n), 0, 1)} + + start = perf_counter() + for _ in range(repeats): + prepare_performance_data(probs, reals) + repeated = perf_counter() - start + + start = perf_counter() + performance_data = prepare_performance_data(probs, reals) + for _ in range(repeats): + _ = performance_data + shared = perf_counter() - start + + print(f"n={n:,}; repeated={repeated:.3f}s; shared={shared:.3f}s; ratio={repeated/shared:.2f}x") + + +if __name__ == "__main__": + run() From 51c6562af069945ca79d4baaa6d4ab31637e72e4 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:26:12 +0300 Subject: [PATCH 007/153] Keep benchmark directory --- benchmarks/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 benchmarks/.gitkeep diff --git a/benchmarks/.gitkeep b/benchmarks/.gitkeep new file mode 100644 index 00000000..e69de29b From f5c584a18c857f43e6dbf3423aa2cae884570d52 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:26:18 +0300 Subject: [PATCH 008/153] Document summary report benchmark --- benchmarks/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 benchmarks/README.md diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..4bb56bfd --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,9 @@ +# Benchmarks + +Run the summary-report preparation benchmark with: + +```bash +uv run python benchmarks/benchmark_summary_report.py +``` + +It compares the current repeated preparation pattern for five report panels with preparing the shared performance table once. From de7355ba0674cfe9d0d554e3e4e83c88b27bb9b8 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:26:25 +0300 Subject: [PATCH 009/153] Link summary report proof of concept from docs --- great-docs/summary-report-preview.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 great-docs/summary-report-preview.md diff --git a/great-docs/summary-report-preview.md b/great-docs/summary-report-preview.md new file mode 100644 index 00000000..1e23a71a --- /dev/null +++ b/great-docs/summary-report-preview.md @@ -0,0 +1,7 @@ +# Summary report preview + +The current pull request contains a lightweight D3 proof of concept for `create_summary_report()`. + +[Open the generated summary report](../summary-report-demo.html) + +The report prepares the shared binary performance data once and reuses it for ROC, precision–recall, gains, lift, and decision-curve panels. From 75638d43fb3947b159e52b8f3952c46f417a4e85 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:26:32 +0300 Subject: [PATCH 010/153] Mark summary report proof of concept --- great-docs/.summary-report-poc | 1 + 1 file changed, 1 insertion(+) create mode 100644 great-docs/.summary-report-poc diff --git a/great-docs/.summary-report-poc b/great-docs/.summary-report-poc new file mode 100644 index 00000000..2e00032f --- /dev/null +++ b/great-docs/.summary-report-poc @@ -0,0 +1 @@ +D3 summary report proof of concept From 45ee6e42ce9363aba9f1e849aa50f49cfc51083d Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:26:40 +0300 Subject: [PATCH 011/153] Document summary report preview --- examples/README-summary-report.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 examples/README-summary-report.md diff --git a/examples/README-summary-report.md b/examples/README-summary-report.md new file mode 100644 index 00000000..4e4ff194 --- /dev/null +++ b/examples/README-summary-report.md @@ -0,0 +1,3 @@ +# Summary report proof of concept + +`summary_report_demo.py` generates the report used in pull-request previews. The report is intentionally limited to the binary shared-performance-data path while the D3 architecture is evaluated. From bfef12c1460583ae4a87fb19fe29ecf7edea2d70 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:26:46 +0300 Subject: [PATCH 012/153] Keep summary report preview marker --- great-docs/.gitkeep-summary-report | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 great-docs/.gitkeep-summary-report diff --git a/great-docs/.gitkeep-summary-report b/great-docs/.gitkeep-summary-report new file mode 100644 index 00000000..e69de29b From f9b5d288a96337f597f69fcdb913b0bb288b873b Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:26:54 +0300 Subject: [PATCH 013/153] Clarify benchmark scope --- benchmarks/NOTES.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 benchmarks/NOTES.md diff --git a/benchmarks/NOTES.md b/benchmarks/NOTES.md new file mode 100644 index 00000000..1b64a31a --- /dev/null +++ b/benchmarks/NOTES.md @@ -0,0 +1 @@ +This benchmark is diagnostic only and is not run in CI. It is intended to quantify the upper-bound saving from replacing five identical `prepare_performance_data()` calls with one shared call. From fa4f12e6f5bd2bc01f1f7ff095f0f56b7e50de08 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:27:03 +0300 Subject: [PATCH 014/153] Mark summary report preview example --- examples/.summary-report-preview | 1 + 1 file changed, 1 insertion(+) create mode 100644 examples/.summary-report-preview diff --git a/examples/.summary-report-preview b/examples/.summary-report-preview new file mode 100644 index 00000000..4d408936 --- /dev/null +++ b/examples/.summary-report-preview @@ -0,0 +1 @@ +summary_report_demo.py From 9d762c727c21bdb6cd3bcbda3b47eafd025308fb Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:27:09 +0300 Subject: [PATCH 015/153] Add preview note --- great-docs/summary-report-preview-note.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 great-docs/summary-report-preview-note.txt diff --git a/great-docs/summary-report-preview-note.txt b/great-docs/summary-report-preview-note.txt new file mode 100644 index 00000000..c9d0b50b --- /dev/null +++ b/great-docs/summary-report-preview-note.txt @@ -0,0 +1 @@ +The generated preview is published as summary-report-demo.html by the documentation workflow. From 11a9a9e6dba6be9cb0244347eb8855ec0b91241d Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:27:16 +0300 Subject: [PATCH 016/153] Mark summary report benchmark --- benchmarks/.summary-report | 1 + 1 file changed, 1 insertion(+) create mode 100644 benchmarks/.summary-report diff --git a/benchmarks/.summary-report b/benchmarks/.summary-report new file mode 100644 index 00000000..1a23a7a6 --- /dev/null +++ b/benchmarks/.summary-report @@ -0,0 +1 @@ +benchmark_summary_report.py From b093bb1a678b7870e42f4740cd771837fdfd3c9e Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:27:21 +0300 Subject: [PATCH 017/153] Mark summary report tests --- tests/.summary-report | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 tests/.summary-report diff --git a/tests/.summary-report b/tests/.summary-report new file mode 100644 index 00000000..38fb01c0 --- /dev/null +++ b/tests/.summary-report @@ -0,0 +1,2 @@ +test_summary_report.py +test_summary_report_shared_data.py From e5095e0f568588cfd0ada0fa756b21f214cee61c Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:27:26 +0300 Subject: [PATCH 018/153] Describe D3 summary report proof of concept --- SUMMARY_REPORT_POC.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 SUMMARY_REPORT_POC.md diff --git a/SUMMARY_REPORT_POC.md b/SUMMARY_REPORT_POC.md new file mode 100644 index 00000000..34023fe5 --- /dev/null +++ b/SUMMARY_REPORT_POC.md @@ -0,0 +1,5 @@ +# D3 summary report proof of concept + +This branch replaces the old R-backend `create_summary_report()` stub with a native Python binary report. It prepares the Polars performance table once and serializes only the columns needed by five D3 panels. The PR preview workflow publishes the generated example as `summary-report-demo.html`. + +The first preview deliberately uses the D3 CDN so the architecture and interaction can be reviewed before vendoring D3 into the package. A production version should bundle D3 to make the output genuinely self-contained. From 66741b6362f143e689a9ba80b6a2e97d1bda72cb Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:27:31 +0300 Subject: [PATCH 019/153] Document preview output name --- great-docs/.summary-report-preview-url | 1 + 1 file changed, 1 insertion(+) create mode 100644 great-docs/.summary-report-preview-url diff --git a/great-docs/.summary-report-preview-url b/great-docs/.summary-report-preview-url new file mode 100644 index 00000000..b52471ae --- /dev/null +++ b/great-docs/.summary-report-preview-url @@ -0,0 +1 @@ +summary-report-demo.html From 35aaa9118018c38562f7f7785c01f5f3b63b46e0 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:27:34 +0300 Subject: [PATCH 020/153] Note summary report demo purpose --- examples/summary_report_demo.README | 1 + 1 file changed, 1 insertion(+) create mode 100644 examples/summary_report_demo.README diff --git a/examples/summary_report_demo.README b/examples/summary_report_demo.README new file mode 100644 index 00000000..dc6ac74c --- /dev/null +++ b/examples/summary_report_demo.README @@ -0,0 +1 @@ +Generated by CI for visual review of the D3 summary-report proof of concept. From c67cd8637cdee20d6e00b80b945e8dd7224cc41a Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:27:38 +0300 Subject: [PATCH 021/153] Note benchmark purpose --- benchmarks/summary_report_benchmark.README | 1 + 1 file changed, 1 insertion(+) create mode 100644 benchmarks/summary_report_benchmark.README diff --git a/benchmarks/summary_report_benchmark.README b/benchmarks/summary_report_benchmark.README new file mode 100644 index 00000000..85324c8f --- /dev/null +++ b/benchmarks/summary_report_benchmark.README @@ -0,0 +1 @@ +Diagnostic benchmark for shared report preparation. From 9de30070784c32e6382391bc968cf5931f6f20a8 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:27:42 +0300 Subject: [PATCH 022/153] Note summary report tests --- tests/summary_report_tests.README | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/summary_report_tests.README diff --git a/tests/summary_report_tests.README b/tests/summary_report_tests.README new file mode 100644 index 00000000..3a4079eb --- /dev/null +++ b/tests/summary_report_tests.README @@ -0,0 +1 @@ +Tests native HTML generation and single performance-data preparation. From cf9e5e68e598cb0a3c2d11908e9b79795c3b9875 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:27:59 +0300 Subject: [PATCH 023/153] Clean up POC marker files --- benchmarks/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 benchmarks/.gitkeep diff --git a/benchmarks/.gitkeep b/benchmarks/.gitkeep deleted file mode 100644 index e69de29b..00000000 From 7849047041cc25f28a2b05facb452a82c9705675 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:28:03 +0300 Subject: [PATCH 024/153] Clean up POC marker files --- benchmarks/.summary-report | 1 - 1 file changed, 1 deletion(-) delete mode 100644 benchmarks/.summary-report diff --git a/benchmarks/.summary-report b/benchmarks/.summary-report deleted file mode 100644 index 1a23a7a6..00000000 --- a/benchmarks/.summary-report +++ /dev/null @@ -1 +0,0 @@ -benchmark_summary_report.py From b5861593500396b6f36823d566a48d7caa360538 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:28:06 +0300 Subject: [PATCH 025/153] Clean up POC marker files --- benchmarks/NOTES.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 benchmarks/NOTES.md diff --git a/benchmarks/NOTES.md b/benchmarks/NOTES.md deleted file mode 100644 index 1b64a31a..00000000 --- a/benchmarks/NOTES.md +++ /dev/null @@ -1 +0,0 @@ -This benchmark is diagnostic only and is not run in CI. It is intended to quantify the upper-bound saving from replacing five identical `prepare_performance_data()` calls with one shared call. From 53a0407e6639ec72d1dbc3871e74ffcbe4c6dc1f Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:28:10 +0300 Subject: [PATCH 026/153] Clean up POC marker files --- benchmarks/summary_report_benchmark.README | 1 - 1 file changed, 1 deletion(-) delete mode 100644 benchmarks/summary_report_benchmark.README diff --git a/benchmarks/summary_report_benchmark.README b/benchmarks/summary_report_benchmark.README deleted file mode 100644 index 85324c8f..00000000 --- a/benchmarks/summary_report_benchmark.README +++ /dev/null @@ -1 +0,0 @@ -Diagnostic benchmark for shared report preparation. From 992dcb2b86ecb93dbb8c542c9bb2ab000e7fba9a Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:28:16 +0300 Subject: [PATCH 027/153] Clean up POC marker files --- examples/.summary-report-preview | 1 - 1 file changed, 1 deletion(-) delete mode 100644 examples/.summary-report-preview diff --git a/examples/.summary-report-preview b/examples/.summary-report-preview deleted file mode 100644 index 4d408936..00000000 --- a/examples/.summary-report-preview +++ /dev/null @@ -1 +0,0 @@ -summary_report_demo.py From 83852632275a5ba403193b2579a60c29e90e6760 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:28:19 +0300 Subject: [PATCH 028/153] Clean up POC marker files --- examples/README-summary-report.md | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 examples/README-summary-report.md diff --git a/examples/README-summary-report.md b/examples/README-summary-report.md deleted file mode 100644 index 4e4ff194..00000000 --- a/examples/README-summary-report.md +++ /dev/null @@ -1,3 +0,0 @@ -# Summary report proof of concept - -`summary_report_demo.py` generates the report used in pull-request previews. The report is intentionally limited to the binary shared-performance-data path while the D3 architecture is evaluated. From 10c773d9420fff62098bec17e9acf117bccbd629 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:28:23 +0300 Subject: [PATCH 029/153] Clean up POC marker files --- examples/summary_report_demo.README | 1 - 1 file changed, 1 deletion(-) delete mode 100644 examples/summary_report_demo.README diff --git a/examples/summary_report_demo.README b/examples/summary_report_demo.README deleted file mode 100644 index dc6ac74c..00000000 --- a/examples/summary_report_demo.README +++ /dev/null @@ -1 +0,0 @@ -Generated by CI for visual review of the D3 summary-report proof of concept. From 010ef892f005497d8c0f31ab6eac9f7f0fbec5f4 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:28:30 +0300 Subject: [PATCH 030/153] Clean up POC marker files --- great-docs/.gitkeep-summary-report | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 great-docs/.gitkeep-summary-report diff --git a/great-docs/.gitkeep-summary-report b/great-docs/.gitkeep-summary-report deleted file mode 100644 index e69de29b..00000000 From b65fa0e710643bb867b0db929d1aa784f60202e9 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:28:34 +0300 Subject: [PATCH 031/153] Clean up POC marker files --- great-docs/.summary-report-poc | 1 - 1 file changed, 1 deletion(-) delete mode 100644 great-docs/.summary-report-poc diff --git a/great-docs/.summary-report-poc b/great-docs/.summary-report-poc deleted file mode 100644 index 2e00032f..00000000 --- a/great-docs/.summary-report-poc +++ /dev/null @@ -1 +0,0 @@ -D3 summary report proof of concept From ecf8b27b5d00c9e83dc3de17ee223ab0c3a95148 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:28:39 +0300 Subject: [PATCH 032/153] Clean up POC marker files --- great-docs/.summary-report-preview-url | 1 - 1 file changed, 1 deletion(-) delete mode 100644 great-docs/.summary-report-preview-url diff --git a/great-docs/.summary-report-preview-url b/great-docs/.summary-report-preview-url deleted file mode 100644 index b52471ae..00000000 --- a/great-docs/.summary-report-preview-url +++ /dev/null @@ -1 +0,0 @@ -summary-report-demo.html From e48fcd460c721b9a31fdded9b9e436f392c25110 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:28:42 +0300 Subject: [PATCH 033/153] Clean up POC marker files --- great-docs/summary-report-preview-note.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 great-docs/summary-report-preview-note.txt diff --git a/great-docs/summary-report-preview-note.txt b/great-docs/summary-report-preview-note.txt deleted file mode 100644 index c9d0b50b..00000000 --- a/great-docs/summary-report-preview-note.txt +++ /dev/null @@ -1 +0,0 @@ -The generated preview is published as summary-report-demo.html by the documentation workflow. From 1abd5cc62180a59b8025b02d94dc316f4df5e03e Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:28:46 +0300 Subject: [PATCH 034/153] Clean up POC marker files --- great-docs/summary-report-preview.md | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 great-docs/summary-report-preview.md diff --git a/great-docs/summary-report-preview.md b/great-docs/summary-report-preview.md deleted file mode 100644 index 1e23a71a..00000000 --- a/great-docs/summary-report-preview.md +++ /dev/null @@ -1,7 +0,0 @@ -# Summary report preview - -The current pull request contains a lightweight D3 proof of concept for `create_summary_report()`. - -[Open the generated summary report](../summary-report-demo.html) - -The report prepares the shared binary performance data once and reuses it for ROC, precision–recall, gains, lift, and decision-curve panels. From 42bea6765ed3fcecd3d3962f2286a218a86801e7 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:28:52 +0300 Subject: [PATCH 035/153] Clean up POC marker files --- tests/.summary-report | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 tests/.summary-report diff --git a/tests/.summary-report b/tests/.summary-report deleted file mode 100644 index 38fb01c0..00000000 --- a/tests/.summary-report +++ /dev/null @@ -1,2 +0,0 @@ -test_summary_report.py -test_summary_report_shared_data.py From b206713f36757ffeb198a423f1df3f850f13523f Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 08:28:55 +0300 Subject: [PATCH 036/153] Clean up POC marker files --- tests/summary_report_tests.README | 1 - 1 file changed, 1 deletion(-) delete mode 100644 tests/summary_report_tests.README diff --git a/tests/summary_report_tests.README b/tests/summary_report_tests.README deleted file mode 100644 index 3a4079eb..00000000 --- a/tests/summary_report_tests.README +++ /dev/null @@ -1 +0,0 @@ -Tests native HTML generation and single performance-data preparation. From 4ad088656193e83fa946cb581fe18d04672f25f5 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 09:13:20 +0300 Subject: [PATCH 037/153] Match D3 summary charts to rtichoke Plotly styling --- src/rtichoke/summary_report/summary_report.py | 198 +++++++++++------- 1 file changed, 121 insertions(+), 77 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report.py b/src/rtichoke/summary_report/summary_report.py index dabc8a40..d7f0f55f 100644 --- a/src/rtichoke/summary_report/summary_report.py +++ b/src/rtichoke/summary_report/summary_report.py @@ -9,39 +9,49 @@ import numpy as np from rtichoke.performance_data.performance_data import prepare_performance_data +from rtichoke.processing.plotly_helper_functions import _create_rtichoke_curve_list_binary _CURVES = [ - ("roc", "ROC", "false_positive_rate", "sensitivity", "1 - Specificity", "Sensitivity"), - ("precision-recall", "Precision–Recall", "sensitivity", "ppv", "Sensitivity", "PPV"), - ("gains", "Gains", "ppcr", "sensitivity", "Predicted positives", "Sensitivity"), - ("lift", "Lift", "ppcr", "lift", "Predicted positives", "Lift"), - ("decision", "Decision Curve", "chosen_cutoff", "net_benefit", "Probability threshold", "Net benefit"), + ("roc", "ROC Curve"), + ("precision recall", "Precision-Recall Curve"), + ("gains", "Gains Curve"), + ("lift", "Lift Curve"), + ("decision", "Decision Curve"), ] -def _json_rows(performance_data): - columns = { - "reference_group", - "stratified_by", - "chosen_cutoff", - "false_positive_rate", - "sensitivity", - "ppv", - "ppcr", - "lift", - "net_benefit", - } - available = [column for column in performance_data.columns if column in columns] - data = performance_data.select(available) - if "stratified_by" in available: - data = data.filter(data["stratified_by"] == "probability_threshold") - return data.to_dicts() +def _curve_specs(performance_data) -> list[dict]: + """Build D3 specs from the same prepared curve data used by Plotly.""" + specs = [] + for curve, title in _CURVES: + curve_data = _create_rtichoke_curve_list_binary( + performance_data=performance_data, + stratified_by="probability_threshold", + curve=curve, + size=600, + ) + specs.append( + { + "id": curve.replace(" ", "-"), + "title": title, + "x_label": curve_data["x_label"], + "y_label": curve_data["y_label"], + "x_range": curve_data["axes_ranges"]["xaxis"], + "y_range": curve_data["axes_ranges"]["yaxis"], + "groups": curve_data["reference_group_keys"], + "multiple_groups": curve_data["multiple_reference_groups"], + "colors": curve_data["colors_dictionary"], + "cutoffs": curve_data["cutoffs"], + "data": curve_data["performance_data_ready_for_curve"].to_dicts(), + "references": curve_data["reference_data"].to_dicts(), + } + ) + return specs -def _report_html(rows: list[dict]) -> str: - payload = json.dumps(rows, separators=(",", ":"), default=str).replace(" str: + payload = json.dumps(specs, separators=(",", ":"), default=str).replace(" @@ -50,64 +60,98 @@ def _report_html(rows: list[dict]) -> str: rtichoke summary report

Model Performance Summary

-

One shared rtichoke performance-data calculation, rendered as lightweight linked charts.

-
-

D3 proof of concept. The preview currently loads D3 from a CDN; the production implementation will bundle it for a truly self-contained HTML file.

+
+

D3 proof of concept using the same curve data, reference lines, axis ranges, palette, and hover content as rtichoke's Plotly figures.

""" @@ -119,9 +163,9 @@ def create_summary_report( ) -> Path: """Create a native HTML summary report for binary model performance. - Performance data are prepared once and reused by all report panels. - The current proof of concept renders ROC, precision-recall, gains, lift, - and decision curves with D3. + Performance data are prepared once and reused by all report panels. The D3 + renderer consumes the same curve-ready data, reference lines, axis ranges, + colors, and hover text as the existing Plotly implementation. """ performance_data = prepare_performance_data( probs=probs, @@ -130,5 +174,5 @@ def create_summary_report( by=by, ) output_path = Path(output_file) - output_path.write_text(_report_html(_json_rows(performance_data)), encoding="utf-8") + output_path.write_text(_report_html(_curve_specs(performance_data)), encoding="utf-8") return output_path From f172f70328c51054fc4690890fdb7e33666492c9 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 09:19:01 +0300 Subject: [PATCH 038/153] Fix D3 report JavaScript rendering --- src/rtichoke/summary_report/summary_report.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report.py b/src/rtichoke/summary_report/summary_report.py index d7f0f55f..a9e08be1 100644 --- a/src/rtichoke/summary_report/summary_report.py +++ b/src/rtichoke/summary_report/summary_report.py @@ -98,7 +98,6 @@ def _report_html(specs: list[dict]) -> str: const active=new Map(); specs.forEach(s=>s.groups.forEach(g=>{{if(!active.has(g)) active.set(g,true)}})); function finite(v){{return v!==null&&v!==undefined&&Number.isFinite(+v)}} -function stripHtml(s){{return String(s??'').replace(//gi,'').replace(/<[^>]*>/g,'')}} function htmlHover(s){{return String(s??'').replace(/NaN|nan/g,'')}} function draw(spec){{ const card=d3.select('#charts').append('section').attr('class','card'); @@ -125,7 +124,7 @@ def _report_html(specs: list[dict]) -> str: spec.groups.filter(g=>active.get(g)).forEach(g=>{{ const gd=visible.filter(d=>String(d.reference_group)===g); svg.append('path').datum(gd).attr('class','curve-line').attr('stroke',spec.colors[g]||'#000').attr('d',line); - svg.selectAll(`.pt-${{spec.id}}-${{CSS.escape(g)}}`).data(gd).enter().append('circle').attr('class','curve-point').attr('cx',d=>x(+d.x)).attr('cy',d=>y(+d.y)).attr('r',2.3).attr('fill',spec.colors[g]||'#000'); + svg.selectAll('circle.curve-point').data(gd).enter().append('circle').attr('class','curve-point').attr('cx',d=>x(+d.x)).attr('cy',d=>y(+d.y)).attr('r',2.3).attr('fill',spec.colors[g]||'#000'); }}); const markers=svg.append('g'); function showCutoff(cutoff){{ From c84d3cb023f8f4569d0511e2ecad145523faff2f Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 09:19:09 +0300 Subject: [PATCH 039/153] Strengthen summary report HTML smoke test --- tests/test_summary_report.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_summary_report.py b/tests/test_summary_report.py index b8a66366..2ae7e4cf 100644 --- a/tests/test_summary_report.py +++ b/tests/test_summary_report.py @@ -13,7 +13,13 @@ def test_create_summary_report_writes_native_html(tmp_path): html = output.read_text(encoding="utf-8") assert result == output assert "Model Performance Summary" in html - assert "Precision–Recall" in html + assert "ROC Curve" in html + assert "Precision-Recall Curve" in html + assert "Gains Curve" in html + assert "Lift Curve" in html assert "Decision Curve" in html + assert "const specs=" in html + assert "redrawAll();" in html + assert "d3.scaleLinear()" in html assert "send_requests_to_rtichoke_r" not in html assert "quarto" not in html.lower() From f45422397df2b1899f2e91f63740897de8c0621b Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 10:05:09 +0300 Subject: [PATCH 040/153] Match R summary report with tabbed curves --- src/rtichoke/summary_report/summary_report.py | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report.py b/src/rtichoke/summary_report/summary_report.py index a9e08be1..a5bd867d 100644 --- a/src/rtichoke/summary_report/summary_report.py +++ b/src/rtichoke/summary_report/summary_report.py @@ -62,11 +62,14 @@ def _report_html(specs: list[dict]) -> str:

Model Performance Summary

-
+ +

D3 proof of concept using the same curve data, reference lines, axis ranges, palette, and hover content as rtichoke's Plotly figures.

""" From dd2975a652abb62c4c39d6e36347d33f906ee129 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 12:49:07 +0300 Subject: [PATCH 041/153] Add standalone Reactable embedding spike --- scripts/reactable_embed_spike.py | 56 ++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 scripts/reactable_embed_spike.py diff --git a/scripts/reactable_embed_spike.py b/scripts/reactable_embed_spike.py new file mode 100644 index 00000000..915e991f --- /dev/null +++ b/scripts/reactable_embed_spike.py @@ -0,0 +1,56 @@ +"""Spike: export rtichoke's real Reactable performance table to standalone HTML. + +This deliberately avoids Quarto/Jupyter as report assemblers. It uses the +ipywidgets static embed protocol and the existing rtichoke Reactable renderer. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +from ipywidgets.embed import dependency_state, embed_data + +from rtichoke.performance_data.performance_data import prepare_performance_data +from rtichoke.performance_table_reactable import render_performance_table_reactable + + +def main() -> None: + reals = np.array([0, 0, 0, 0, 1, 1, 1, 1, 1, 1]) + probs = { + "Model A": np.array([0.05, 0.10, 0.15, 0.25, 0.35, 0.50, 0.60, 0.72, 0.82, 0.93]), + "Model B": np.array([0.10, 0.20, 0.30, 0.35, 0.40, 0.45, 0.55, 0.65, 0.75, 0.85]), + } + performance_data = prepare_performance_data( + probs=probs, + reals=reals, + stratified_by=("probability_threshold",), + by=0.05, + ) + table = render_performance_table_reactable( + performance_data=performance_data, + probs=probs, + reals=reals, + stratified_by="probability_threshold", + ) + widget = table.to_widget() + data = embed_data(views=[widget], state=dependency_state([widget])) + + html = f""" +Reactable standalone spike + + + + +

rtichoke Reactable standalone spike

+

No Quarto or running Jupyter kernel is used to view this page.

+ +""" + out = Path("reactable-standalone-spike.html") + out.write_text(html, encoding="utf-8") + print(f"Wrote {out} ({out.stat().st_size / 1024:.1f} KiB HTML payload)") + + +if __name__ == "__main__": + main() From ac58102ff2e60990ca96fb87d5d4f9c12e1fc0c7 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 13:39:00 +0300 Subject: [PATCH 042/153] Update summary report test for tabbed renderer --- tests/test_summary_report.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_summary_report.py b/tests/test_summary_report.py index 2ae7e4cf..321cf4ba 100644 --- a/tests/test_summary_report.py +++ b/tests/test_summary_report.py @@ -19,7 +19,8 @@ def test_create_summary_report_writes_native_html(tmp_path): assert "Lift Curve" in html assert "Decision Curve" in html assert "const specs=" in html - assert "redrawAll();" in html + assert "renderTabs();" in html + assert "draw(specs[0]);" in html assert "d3.scaleLinear()" in html assert "send_requests_to_rtichoke_r" not in html assert "quarto" not in html.lower() From 3e0b8371e1702d03fdbe8c640de4a5a2025fc30f Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 14:01:08 +0300 Subject: [PATCH 043/153] Add R report hierarchy and PPCR support --- src/rtichoke/summary_report/summary_report.py | 215 +++--------------- 1 file changed, 35 insertions(+), 180 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report.py b/src/rtichoke/summary_report/summary_report.py index a5bd867d..4e758c6a 100644 --- a/src/rtichoke/summary_report/summary_report.py +++ b/src/rtichoke/summary_report/summary_report.py @@ -1,189 +1,44 @@ """Lightweight HTML summary reports for rtichoke.""" - from __future__ import annotations - import json from pathlib import Path from typing import Dict, Union - import numpy as np - +from ipywidgets.embed import dependency_state, embed_data +from rtichoke.calibration.calibration import _create_calibration_curve_list from rtichoke.performance_data.performance_data import prepare_performance_data +from rtichoke.performance_table_reactable import render_performance_table_reactable from rtichoke.processing.plotly_helper_functions import _create_rtichoke_curve_list_binary - -_CURVES = [ - ("roc", "ROC Curve"), - ("precision recall", "Precision-Recall Curve"), - ("gains", "Gains Curve"), - ("lift", "Lift Curve"), - ("decision", "Decision Curve"), -] - - -def _curve_specs(performance_data) -> list[dict]: - """Build D3 specs from the same prepared curve data used by Plotly.""" - specs = [] - for curve, title in _CURVES: - curve_data = _create_rtichoke_curve_list_binary( - performance_data=performance_data, - stratified_by="probability_threshold", - curve=curve, - size=600, - ) - specs.append( - { - "id": curve.replace(" ", "-"), - "title": title, - "x_label": curve_data["x_label"], - "y_label": curve_data["y_label"], - "x_range": curve_data["axes_ranges"]["xaxis"], - "y_range": curve_data["axes_ranges"]["yaxis"], - "groups": curve_data["reference_group_keys"], - "multiple_groups": curve_data["multiple_reference_groups"], - "colors": curve_data["colors_dictionary"], - "cutoffs": curve_data["cutoffs"], - "data": curve_data["performance_data_ready_for_curve"].to_dicts(), - "references": curve_data["reference_data"].to_dicts(), - } - ) - return specs - - -def _report_html(specs: list[dict]) -> str: - payload = json.dumps(specs, separators=(",", ":"), default=str).replace(" - - - - -rtichoke summary report - - - -
-

Model Performance Summary

- -
-

D3 proof of concept using the same curve data, reference lines, axis ranges, palette, and hover content as rtichoke's Plotly figures.

-
-""" - - -def create_summary_report( - probs: Dict[str, np.ndarray], - reals: Union[np.ndarray, Dict[str, np.ndarray]], - output_file: str | Path = "summary_report.html", - by: float = 0.01, -) -> Path: - """Create a native HTML summary report for binary model performance. - - Performance data are prepared once and reused by all report panels. The D3 - renderer consumes the same curve-ready data, reference lines, axis ranges, - colors, and hover text as the existing Plotly implementation. - """ - performance_data = prepare_performance_data( - probs=probs, - reals=reals, - stratified_by=("probability_threshold",), - by=by, - ) - output_path = Path(output_file) - output_path.write_text(_report_html(_curve_specs(performance_data)), encoding="utf-8") - return output_path +_CURVES=[("roc","ROC"),("lift","Lift"),("precision recall","Precision Recall"),("gains","Gains")] +def _spec(data,strat,curve,label): + d=_create_rtichoke_curve_list_binary(performance_data=data,stratified_by=strat,curve=curve,size=500) + return {"id":f"{strat}-{curve.replace(' ','-')}","label":label,"title":f"{label} Curve","x_label":d["x_label"],"y_label":d["y_label"],"x_range":d["axes_ranges"]["xaxis"],"y_range":d["axes_ranges"]["yaxis"],"groups":d["reference_group_keys"],"multiple_groups":d["multiple_reference_groups"],"colors":d["colors_dictionary"],"cutoffs":d["cutoffs"],"data":d["performance_data_ready_for_curve"].to_dicts(),"references":d["reference_data"].to_dicts()} +def _specs(data,strat): return [_spec(data,strat,c,l) for c,l in _CURVES] +def _calibration(probs,reals): + d=_create_calibration_curve_list(probs,reals,size=550); colors={k:v[0] for k,v in d["colors_dictionary"].items()} + return {"deciles":d["deciles_dat"].to_dicts(),"smooth":d["smooth_dat"].to_dicts(),"reference":d["reference_data"].to_dicts(),"histogram":d["histogram_for_calibration"].to_dicts(),"ranges":d["axes_ranges"],"colors":colors,"groups":[k for k in colors if k!="reference_line"]} +def _auc(y,p): + y=np.asarray(y).ravel().astype(int);p=np.asarray(p).ravel().astype(float);pos=p[y==1];neg=p[y==0] + return float("nan") if not len(pos) or not len(neg) else float(np.mean(pos[:,None]>neg[None,:])+.5*np.mean(pos[:,None]==neg[None,:])) +def _summaries(probs,reals): + if isinstance(reals,dict) and probs.keys()==reals.keys(): return [{"Model":k,"Prevalence":float(np.mean(reals[k])),"AUC":_auc(reals[k],probs[k])} for k in probs] + if not isinstance(reals,dict): return [{"Model":k,"Prevalence":float(np.mean(reals)),"AUC":_auc(reals,p)} for k,p in probs.items()] + return [] +def _widgets(a,b): + ws=[render_performance_table_reactable(a).to_widget(),render_performance_table_reactable(b).to_widget()];d=embed_data(views=ws,state=dependency_state(ws));return d["manager_state"],d["view_specs"] +def _html(payload,sums,state,views): + P=json.dumps(payload,separators=(",",":"),default=str).replace("Summary Report

Summary Report

Performance Metrics Cheat Sheet
Predicted PositivePredicted Negative
Real PositiveTPFN
Real NegativeFPTN
Prevalence = (TP + FN) / N
PPCR (Predicted Positives Condition Rate) = (TP + FP) / N
Sensitivity (Recall, True Positive Rate) = TP / (TP + FN)
Specificity = TN / (TN + FP)
PPV (Precision) = TP / (TP + FP)
NPV = TN / (TN + FN)
Lift = PPV / Prevalence
Net Benefit = TP/N - FP/N × pt/(1-pt)
+

Calibration

+

Discrimination

Performance Metrics Curves

Performance Metrics Curves

+

Utility (Decision Curve)

Performance Table

''' +def create_summary_report(probs:Dict[str,np.ndarray],reals:Union[np.ndarray,Dict[str,np.ndarray]],output_file:str|Path="summary_report.html",by:float=.01)->Path: + threshold=prepare_performance_data(probs=probs,reals=reals,stratified_by=("probability_threshold",),by=by);ppcr=prepare_performance_data(probs=probs,reals=reals,stratified_by=("ppcr",),by=by);state,views=_widgets(threshold,ppcr);payload={"threshold":_specs(threshold,"probability_threshold"),"ppcr":_specs(ppcr,"ppcr"),"decision":_spec(threshold,"probability_threshold","decision","Decision"),"calibration":_calibration(probs,reals)};out=Path(output_file);out.write_text(_html(payload,_summaries(probs,reals),state,views),encoding="utf-8");return out From 551dbf87868717eae53040fe21e6aa3137b355d4 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 14:01:17 +0300 Subject: [PATCH 044/153] Test shared threshold and PPCR preparation --- tests/test_summary_report_shared_data.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_summary_report_shared_data.py b/tests/test_summary_report_shared_data.py index 117d6cb6..29b5c793 100644 --- a/tests/test_summary_report_shared_data.py +++ b/tests/test_summary_report_shared_data.py @@ -3,13 +3,12 @@ import rtichoke.summary_report.summary_report as summary_report -def test_summary_report_prepares_performance_data_once(monkeypatch, tmp_path): +def test_summary_report_prepares_each_stratification_once(monkeypatch, tmp_path): original = summary_report.prepare_performance_data - calls = 0 + calls = [] def counted(*args, **kwargs): - nonlocal calls - calls += 1 + calls.append(tuple(kwargs.get("stratified_by", ()))) return original(*args, **kwargs) monkeypatch.setattr(summary_report, "prepare_performance_data", counted) @@ -20,4 +19,6 @@ def counted(*args, **kwargs): by=0.1, ) - assert calls == 1 + assert calls.count(("probability_threshold",)) == 1 + assert calls.count(("ppcr",)) == 1 + assert len(calls) == 2 From 9e857ee5e0b5249a82821bb18fe3ffeca7504692 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 14:01:28 +0300 Subject: [PATCH 045/153] Test R-parity summary report sections --- tests/test_summary_report.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/test_summary_report.py b/tests/test_summary_report.py index 321cf4ba..4325d6a1 100644 --- a/tests/test_summary_report.py +++ b/tests/test_summary_report.py @@ -12,15 +12,16 @@ def test_create_summary_report_writes_native_html(tmp_path): html = output.read_text(encoding="utf-8") assert result == output - assert "Model Performance Summary" in html - assert "ROC Curve" in html - assert "Precision-Recall Curve" in html - assert "Gains Curve" in html - assert "Lift Curve" in html - assert "Decision Curve" in html - assert "const specs=" in html - assert "renderTabs();" in html - assert "draw(specs[0]);" in html + for text in ( + "Summary Report", "Performance Metrics Cheat Sheet", "Calibration", + "Smooth", "Discrete", "Discrimination", "By Probability Threshold", + "By Predicted Positives Condition Rate (PPCR)", "ROC", "Lift", + "Precision Recall", "Gains", "Utility (Decision Curve)", + "Performance Table", + ): + assert text in html + assert "application/vnd.jupyter.widget-state+json" in html + assert "application/vnd.jupyter.widget-view+json" in html assert "d3.scaleLinear()" in html assert "send_requests_to_rtichoke_r" not in html assert "quarto" not in html.lower() From adcdac6fdcd094be066093c3af0baa0c6d2532a8 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 14:46:53 +0300 Subject: [PATCH 046/153] Render performance tables with lightweight standalone JS --- src/rtichoke/summary_report/summary_report.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report.py b/src/rtichoke/summary_report/summary_report.py index 4e758c6a..f7f127b8 100644 --- a/src/rtichoke/summary_report/summary_report.py +++ b/src/rtichoke/summary_report/summary_report.py @@ -4,10 +4,8 @@ from pathlib import Path from typing import Dict, Union import numpy as np -from ipywidgets.embed import dependency_state, embed_data from rtichoke.calibration.calibration import _create_calibration_curve_list from rtichoke.performance_data.performance_data import prepare_performance_data -from rtichoke.performance_table_reactable import render_performance_table_reactable from rtichoke.processing.plotly_helper_functions import _create_rtichoke_curve_list_binary _CURVES=[("roc","ROC"),("lift","Lift"),("precision recall","Precision Recall"),("gains","Gains")] @@ -25,20 +23,22 @@ def _summaries(probs,reals): if isinstance(reals,dict) and probs.keys()==reals.keys(): return [{"Model":k,"Prevalence":float(np.mean(reals[k])),"AUC":_auc(reals[k],probs[k])} for k in probs] if not isinstance(reals,dict): return [{"Model":k,"Prevalence":float(np.mean(reals)),"AUC":_auc(reals,p)} for k,p in probs.items()] return [] -def _widgets(a,b): - ws=[render_performance_table_reactable(a).to_widget(),render_performance_table_reactable(b).to_widget()];d=embed_data(views=ws,state=dependency_state(ws));return d["manager_state"],d["view_specs"] -def _html(payload,sums,state,views): - P=json.dumps(payload,separators=(",",":"),default=str).replace("Summary Report

Summary Report

Performance Metrics Cheat Sheet
Predicted PositivePredicted Negative
Real PositiveTPFN
Real NegativeFPTN
Prevalence = (TP + FN) / N
PPCR (Predicted Positives Condition Rate) = (TP + FP) / N
Sensitivity (Recall, True Positive Rate) = TP / (TP + FN)
Specificity = TN / (TN + FP)
PPV (Precision) = TP / (TP + FP)
NPV = TN / (TN + FN)
Lift = PPV / Prevalence
Net Benefit = TP/N - FP/N × pt/(1-pt)

Calibration

Discrimination

Performance Metrics Curves

Performance Metrics Curves

-

Utility (Decision Curve)

Performance Table

''' def create_summary_report(probs:Dict[str,np.ndarray],reals:Union[np.ndarray,Dict[str,np.ndarray]],output_file:str|Path="summary_report.html",by:float=.01)->Path: - threshold=prepare_performance_data(probs=probs,reals=reals,stratified_by=("probability_threshold",),by=by);ppcr=prepare_performance_data(probs=probs,reals=reals,stratified_by=("ppcr",),by=by);state,views=_widgets(threshold,ppcr);payload={"threshold":_specs(threshold,"probability_threshold"),"ppcr":_specs(ppcr,"ppcr"),"decision":_spec(threshold,"probability_threshold","decision","Decision"),"calibration":_calibration(probs,reals)};out=Path(output_file);out.write_text(_html(payload,_summaries(probs,reals),state,views),encoding="utf-8");return out + threshold=prepare_performance_data(probs=probs,reals=reals,stratified_by=("probability_threshold",),by=by);ppcr=prepare_performance_data(probs=probs,reals=reals,stratified_by=("ppcr",),by=by);payload={"threshold":_specs(threshold,"probability_threshold"),"ppcr":_specs(ppcr,"ppcr"),"decision":_spec(threshold,"probability_threshold","decision","Decision"),"calibration":_calibration(probs,reals),"tables":{"threshold":_table_data(threshold),"ppcr":_table_data(ppcr)}};out=Path(output_file);out.write_text(_html(payload,_summaries(probs,reals)),encoding="utf-8");return out From 9471031ae6e5c0499acc8f213f30586d83cfd9cf Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 14:47:05 +0300 Subject: [PATCH 047/153] Test standalone performance table rendering --- tests/test_summary_report.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_summary_report.py b/tests/test_summary_report.py index 4325d6a1..8b53e1ff 100644 --- a/tests/test_summary_report.py +++ b/tests/test_summary_report.py @@ -17,11 +17,13 @@ def test_create_summary_report_writes_native_html(tmp_path): "Smooth", "Discrete", "Discrimination", "By Probability Threshold", "By Predicted Positives Condition Rate (PPCR)", "ROC", "Lift", "Precision Recall", "Gains", "Utility (Decision Curve)", - "Performance Table", + "Performance Table", "table-threshold", "table-ppcr", "Confusion Matrix", ): assert text in html - assert "application/vnd.jupyter.widget-state+json" in html - assert "application/vnd.jupyter.widget-view+json" in html + assert "perfTable(R.tables.threshold" in html + assert "application/vnd.jupyter.widget-state+json" not in html + assert "application/vnd.jupyter.widget-view+json" not in html + assert "@jupyter-widgets" not in html assert "d3.scaleLinear()" in html assert "send_requests_to_rtichoke_r" not in html assert "quarto" not in html.lower() From 35c059a6b0c13409d9315917a3d0c4e6f9529778 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 15:09:31 +0300 Subject: [PATCH 048/153] Polish summary report visual parity --- src/rtichoke/summary_report/summary_report.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report.py b/src/rtichoke/summary_report/summary_report.py index f7f127b8..cc550a94 100644 --- a/src/rtichoke/summary_report/summary_report.py +++ b/src/rtichoke/summary_report/summary_report.py @@ -11,7 +11,7 @@ _CURVES=[("roc","ROC"),("lift","Lift"),("precision recall","Precision Recall"),("gains","Gains")] def _spec(data,strat,curve,label): d=_create_rtichoke_curve_list_binary(performance_data=data,stratified_by=strat,curve=curve,size=500) - return {"id":f"{strat}-{curve.replace(' ','-')}","label":label,"title":f"{label} Curve","x_label":d["x_label"],"y_label":d["y_label"],"x_range":d["axes_ranges"]["xaxis"],"y_range":d["axes_ranges"]["yaxis"],"groups":d["reference_group_keys"],"multiple_groups":d["multiple_reference_groups"],"colors":d["colors_dictionary"],"cutoffs":d["cutoffs"],"data":d["performance_data_ready_for_curve"].to_dicts(),"references":d["reference_data"].to_dicts()} + return {"label":label,"title":f"{label} Curve","x_label":d["x_label"],"y_label":d["y_label"],"x_range":d["axes_ranges"]["xaxis"],"y_range":d["axes_ranges"]["yaxis"],"groups":d["reference_group_keys"],"colors":d["colors_dictionary"],"data":d["performance_data_ready_for_curve"].to_dicts(),"references":d["reference_data"].to_dicts()} def _specs(data,strat): return [_spec(data,strat,c,l) for c,l in _CURVES] def _calibration(probs,reals): d=_create_calibration_curve_list(probs,reals,size=550); colors={k:v[0] for k,v in d["colors_dictionary"].items()} @@ -29,16 +29,16 @@ def _table_data(data): def _html(payload,sums): P=json.dumps(payload,separators=(",",":"),default=str).replace("Summary Report

Summary Report

Performance Metrics Cheat Sheet
Predicted PositivePredicted Negative
Real PositiveTPFN
Real NegativeFPTN
Prevalence = (TP + FN) / N
PPCR (Predicted Positives Condition Rate) = (TP + FP) / N
Sensitivity (Recall, True Positive Rate) = TP / (TP + FN)
Specificity = TN / (TN + FP)
PPV (Precision) = TP / (TP + FP)
NPV = TN / (TN + FN)
Lift = PPV / Prevalence
Net Benefit = TP/N - FP/N × pt/(1-pt)

Calibration

-

Discrimination

Performance Metrics Curves

Performance Metrics Curves

+

Discrimination

Performance Metrics Curves

Performance Metrics Curves

Utility (Decision Curve)

Performance Table

''' def create_summary_report(probs:Dict[str,np.ndarray],reals:Union[np.ndarray,Dict[str,np.ndarray]],output_file:str|Path="summary_report.html",by:float=.01)->Path: threshold=prepare_performance_data(probs=probs,reals=reals,stratified_by=("probability_threshold",),by=by);ppcr=prepare_performance_data(probs=probs,reals=reals,stratified_by=("ppcr",),by=by);payload={"threshold":_specs(threshold,"probability_threshold"),"ppcr":_specs(ppcr,"ppcr"),"decision":_spec(threshold,"probability_threshold","decision","Decision"),"calibration":_calibration(probs,reals),"tables":{"threshold":_table_data(threshold),"ppcr":_table_data(ppcr)}};out=Path(output_file);out.write_text(_html(payload,_summaries(probs,reals)),encoding="utf-8");return out From 63ae16a56f5a6a8e9388339f13f57930330d65f4 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 15:14:48 +0300 Subject: [PATCH 049/153] Render R summary report reference in PR preview --- .github/workflows/docs.yml | 52 +++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4c7d8505..757f17cf 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,24 +22,18 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Install uv and Python uses: astral-sh/setup-uv@v5 with: python-version: '3.11' - - name: Set up Quarto uses: quarto-dev/quarto-actions/setup@v2 - - name: Install project and docs dependencies run: uv sync --group docs - - name: Build Great Docs site run: uv run great-docs build - - name: Export Great Tables performance table demo run: uv run marimo export html --no-include-code examples/performance_table_demo.py -o great-docs/_site/performance-table-demo.html - - name: Export Reactable performance table demo run: | uv run quarto render examples/performance_table_reactable.qmd --output performance-table-reactable.html @@ -47,12 +41,10 @@ jobs: grep -q 'Real Positive' performance-table-reactable.html mv performance-table-reactable.html great-docs/_site/performance-table-reactable.html cp -R examples/performance_table_reactable_files great-docs/_site/performance_table_reactable_files - - name: Generate summary report demo run: | uv run python examples/summary_report_demo.py mv summary-report-demo.html great-docs/_site/summary-report-demo.html - - name: Publish documentation uses: JamesIves/github-pages-deploy-action@v4 with: @@ -68,29 +60,23 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Install uv and Python if: github.event.action != 'closed' uses: astral-sh/setup-uv@v5 with: python-version: '3.11' - - name: Set up Quarto if: github.event.action != 'closed' uses: quarto-dev/quarto-actions/setup@v2 - - name: Install project and docs dependencies if: github.event.action != 'closed' run: uv sync --group docs - - name: Build Great Docs preview if: github.event.action != 'closed' run: uv run great-docs build - - name: Export Great Tables performance table demo if: github.event.action != 'closed' run: uv run marimo export html --no-include-code examples/performance_table_demo.py -o great-docs/_site/performance-table-demo.html - - name: Export Reactable performance table demo if: github.event.action != 'closed' run: | @@ -99,14 +85,46 @@ jobs: grep -q 'Real Positive' performance-table-reactable.html mv performance-table-reactable.html great-docs/_site/performance-table-reactable.html cp -R examples/performance_table_reactable_files great-docs/_site/performance_table_reactable_files - - - name: Generate summary report demo + - name: Generate Python summary report demo if: github.event.action != 'closed' run: | uv run python examples/summary_report_demo.py test -s summary-report-demo.html mv summary-report-demo.html great-docs/_site/summary-report-demo.html - + - name: Set up R for reference report + if: github.event.action != 'closed' + uses: r-lib/actions/setup-r@v2 + - name: Set up Pandoc for R Markdown + if: github.event.action != 'closed' + uses: r-lib/actions/setup-pandoc@v2 + - name: Render canonical R summary report + if: github.event.action != 'closed' + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + shell: Rscript {0} + run: | + install.packages("remotes", repos = "https://cloud.r-project.org") + remotes::install_github("uriahf/rtichoke", dependencies = TRUE, upgrade = "never") + set.seed(2026) + n <- 800 + signal <- rnorm(n) + p_true <- plogis(signal) + reals <- rbinom(n, 1, p_true) + probs <- list( + "Model A" = pmin(pmax(plogis(0.9 * signal + rnorm(n, 0, 0.55)), 0.001), 0.999), + "Model B" = pmin(pmax(plogis(0.6 * signal + rnorm(n, 0, 0.85)), 0.001), 0.999) + ) + rtichoke::create_summary_report( + probs = probs, + reals = list(reals), + output_file = "summary-report-r-reference.html", + output_dir = file.path(getwd(), "great-docs", "_site") + ) + stopifnot(file.info(file.path("great-docs", "_site", "summary-report-r-reference.html"))$size > 0) + - name: Record report sizes + if: github.event.action != 'closed' + run: | + wc -c great-docs/_site/summary-report-demo.html great-docs/_site/summary-report-r-reference.html | tee great-docs/_site/summary-report-sizes.txt - name: Deploy PR preview uses: rossjrw/pr-preview-action@v1 with: From 92886ee90707b104d4fc249e978c511251e53e8d Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 15:20:41 +0300 Subject: [PATCH 050/153] Fix R reference report dependency install --- .github/workflows/docs.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 757f17cf..1074c7dc 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -94,6 +94,14 @@ jobs: - name: Set up R for reference report if: github.event.action != 'closed' uses: r-lib/actions/setup-r@v2 + - name: Install R reference report dependencies + if: github.event.action != 'closed' + uses: r-lib/actions/setup-r-dependencies@v2 + with: + packages: | + any::rmarkdown + any::knitr + github::uriahf/rtichoke - name: Set up Pandoc for R Markdown if: github.event.action != 'closed' uses: r-lib/actions/setup-pandoc@v2 @@ -103,8 +111,6 @@ jobs: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} shell: Rscript {0} run: | - install.packages("remotes", repos = "https://cloud.r-project.org") - remotes::install_github("uriahf/rtichoke", dependencies = TRUE, upgrade = "never") set.seed(2026) n <- 800 signal <- rnorm(n) From 44ad8ec08e53fb1a7a0aebb1bb1f60877d7f331e Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 16:26:06 +0300 Subject: [PATCH 051/153] Match lightweight report styling to rendered R reference --- src/rtichoke/summary_report/summary_report.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report.py b/src/rtichoke/summary_report/summary_report.py index cc550a94..be3e6f35 100644 --- a/src/rtichoke/summary_report/summary_report.py +++ b/src/rtichoke/summary_report/summary_report.py @@ -29,16 +29,16 @@ def _table_data(data): def _html(payload,sums): P=json.dumps(payload,separators=(",",":"),default=str).replace("Summary Report

Summary Report

Performance Metrics Cheat Sheet
Predicted PositivePredicted Negative
Real PositiveTPFN
Real NegativeFPTN
Prevalence = (TP + FN) / N
PPCR (Predicted Positives Condition Rate) = (TP + FP) / N
Sensitivity (Recall, True Positive Rate) = TP / (TP + FN)
Specificity = TN / (TN + FP)
PPV (Precision) = TP / (TP + FP)
NPV = TN / (TN + FN)
Lift = PPV / Prevalence
Net Benefit = TP/N - FP/N × pt/(1-pt)
-

Calibration

-

Discrimination

Performance Metrics Curves

Performance Metrics Curves

-

Utility (Decision Curve)

Performance Table

''' def create_summary_report(probs:Dict[str,np.ndarray],reals:Union[np.ndarray,Dict[str,np.ndarray]],output_file:str|Path="summary_report.html",by:float=.01)->Path: threshold=prepare_performance_data(probs=probs,reals=reals,stratified_by=("probability_threshold",),by=by);ppcr=prepare_performance_data(probs=probs,reals=reals,stratified_by=("ppcr",),by=by);payload={"threshold":_specs(threshold,"probability_threshold"),"ppcr":_specs(ppcr,"ppcr"),"decision":_spec(threshold,"probability_threshold","decision","Decision"),"calibration":_calibration(probs,reals),"tables":{"threshold":_table_data(threshold),"ppcr":_table_data(ppcr)}};out=Path(output_file);out.write_text(_html(payload,_summaries(probs,reals)),encoding="utf-8");return out From b4316e01b719f16eb66fb242bab894e93b06bf34 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 16:36:00 +0300 Subject: [PATCH 052/153] Rebuild summary report around R report structure --- src/rtichoke/summary_report/summary_report.py | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report.py b/src/rtichoke/summary_report/summary_report.py index be3e6f35..75b2ed8a 100644 --- a/src/rtichoke/summary_report/summary_report.py +++ b/src/rtichoke/summary_report/summary_report.py @@ -12,33 +12,39 @@ def _spec(data,strat,curve,label): d=_create_rtichoke_curve_list_binary(performance_data=data,stratified_by=strat,curve=curve,size=500) return {"label":label,"title":f"{label} Curve","x_label":d["x_label"],"y_label":d["y_label"],"x_range":d["axes_ranges"]["xaxis"],"y_range":d["axes_ranges"]["yaxis"],"groups":d["reference_group_keys"],"colors":d["colors_dictionary"],"data":d["performance_data_ready_for_curve"].to_dicts(),"references":d["reference_data"].to_dicts()} -def _specs(data,strat): return [_spec(data,strat,c,l) for c,l in _CURVES] +def _specs(d,s): return [_spec(d,s,c,l) for c,l in _CURVES] def _calibration(probs,reals): d=_create_calibration_curve_list(probs,reals,size=550); colors={k:v[0] for k,v in d["colors_dictionary"].items()} return {"deciles":d["deciles_dat"].to_dicts(),"smooth":d["smooth_dat"].to_dicts(),"reference":d["reference_data"].to_dicts(),"histogram":d["histogram_for_calibration"].to_dicts(),"ranges":d["axes_ranges"],"colors":colors,"groups":[k for k in colors if k!="reference_line"]} def _auc(y,p): - y=np.asarray(y).ravel().astype(int);p=np.asarray(p).ravel().astype(float);pos=p[y==1];neg=p[y==0] + y=np.asarray(y).ravel().astype(int); p=np.asarray(p).ravel().astype(float); pos=p[y==1]; neg=p[y==0] return float("nan") if not len(pos) or not len(neg) else float(np.mean(pos[:,None]>neg[None,:])+.5*np.mean(pos[:,None]==neg[None,:])) def _summaries(probs,reals): if isinstance(reals,dict) and probs.keys()==reals.keys(): return [{"Model":k,"Prevalence":float(np.mean(reals[k])),"AUC":_auc(reals[k],probs[k])} for k in probs] if not isinstance(reals,dict): return [{"Model":k,"Prevalence":float(np.mean(reals)),"AUC":_auc(reals,p)} for k,p in probs.items()] return [] -def _table_data(data): +def _table_data(d): cols=["reference_group","chosen_cutoff","ppcr","sensitivity","specificity","ppv","npv","lift","predicted_positives","net_benefit","true_positives","true_negatives","false_positives","false_negatives"] - return [{k:r.get(k) for k in cols if k in r} for r in data.to_dicts()] + return [{k:r.get(k) for k in cols if k in r} for r in d.to_dicts()] def _html(payload,sums): - P=json.dumps(payload,separators=(",",":"),default=str).replace("Summary Report

Summary Report

Performance Metrics Cheat Sheet

Predicted PositivePredicted Negative
Real PositiveTPFN
Real NegativeFPTN
Prevalence = (TP + FN) / N
PPCR (Predicted Positives Condition Rate) = (TP + FP) / N
Sensitivity (Recall, True Positive Rate) = TP / (TP + FN)
Specificity = TN / (TN + FP)
PPV (Precision) = TP / (TP + FP)
NPV = TN / (TN + FN)
Lift = PPV / Prevalence
Net Benefit = TP/N - FP/N × pt/(1-pt)
-

Calibration

-

Discrimination

Performance Metrics Curves

Performance Metrics Curves

-

Utility (Decision Curve)

Performance Table

''' def create_summary_report(probs:Dict[str,np.ndarray],reals:Union[np.ndarray,Dict[str,np.ndarray]],output_file:str|Path="summary_report.html",by:float=.01)->Path: - threshold=prepare_performance_data(probs=probs,reals=reals,stratified_by=("probability_threshold",),by=by);ppcr=prepare_performance_data(probs=probs,reals=reals,stratified_by=("ppcr",),by=by);payload={"threshold":_specs(threshold,"probability_threshold"),"ppcr":_specs(ppcr,"ppcr"),"decision":_spec(threshold,"probability_threshold","decision","Decision"),"calibration":_calibration(probs,reals),"tables":{"threshold":_table_data(threshold),"ppcr":_table_data(ppcr)}};out=Path(output_file);out.write_text(_html(payload,_summaries(probs,reals)),encoding="utf-8");return out + threshold=prepare_performance_data(probs=probs,reals=reals,stratified_by=("probability_threshold",),by=by); ppcr=prepare_performance_data(probs=probs,reals=reals,stratified_by=("ppcr",),by=by) + payload={"threshold":_specs(threshold,"probability_threshold"),"ppcr":_specs(ppcr,"ppcr"),"decision":_spec(threshold,"probability_threshold","decision","Decision"),"calibration":_calibration(probs,reals),"tables":{"threshold":_table_data(threshold),"ppcr":_table_data(ppcr)}} + out=Path(output_file); out.write_text(_html(payload,_summaries(probs,reals)),encoding="utf-8"); return out From 9f0f29f726c27230f40dfad88c207bd9d8e44781 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 16:59:31 +0300 Subject: [PATCH 053/153] Fix summary report native HTML test --- tests/test_summary_report.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_summary_report.py b/tests/test_summary_report.py index 8b53e1ff..9c30c485 100644 --- a/tests/test_summary_report.py +++ b/tests/test_summary_report.py @@ -20,7 +20,7 @@ def test_create_summary_report_writes_native_html(tmp_path): "Performance Table", "table-threshold", "table-ppcr", "Confusion Matrix", ): assert text in html - assert "perfTable(R.tables.threshold" in html + assert "perf(R.tables.threshold" in html assert "application/vnd.jupyter.widget-state+json" not in html assert "application/vnd.jupyter.widget-view+json" not in html assert "@jupyter-widgets" not in html From 11656bd7d620e49e9fa582f0758943192231c768 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 17:33:28 +0300 Subject: [PATCH 054/153] Match calibration layout to R report --- src/rtichoke/summary_report/summary_report.py | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report.py b/src/rtichoke/summary_report/summary_report.py index 75b2ed8a..b93488cf 100644 --- a/src/rtichoke/summary_report/summary_report.py +++ b/src/rtichoke/summary_report/summary_report.py @@ -29,22 +29,46 @@ def _table_data(d): def _html(payload,sums): P=json.dumps(payload,separators=(",",":"),default=str).replace("Summary Report
-

Performance Metrics Cheat Sheet

Predicted PositivePredicted Negative
Real PositiveTPFN
Real NegativeFPTN
Prevalence = (TP + FN) / N
PPCR (Predicted Positives Condition Rate) = (TP + FP) / N
Sensitivity (Recall, True Positive Rate) = TP / (TP + FN)
Specificity = TN / (TN + FP)
PPV (Precision) = TP / (TP + FP)
NPV = TN / (TN + FN)
Lift = PPV / Prevalence
Net Benefit = TP/N - FP/N × pt/(1-pt)
-

Calibration

-

Discrimination

Performance Metrics Curves

Performance Metrics Curves

-

Utility (Decision Curve)

Performance Table

''' def create_summary_report(probs:Dict[str,np.ndarray],reals:Union[np.ndarray,Dict[str,np.ndarray]],output_file:str|Path="summary_report.html",by:float=.01)->Path: threshold=prepare_performance_data(probs=probs,reals=reals,stratified_by=("probability_threshold",),by=by); ppcr=prepare_performance_data(probs=probs,reals=reals,stratified_by=("ppcr",),by=by) payload={"threshold":_specs(threshold,"probability_threshold"),"ppcr":_specs(ppcr,"ppcr"),"decision":_spec(threshold,"probability_threshold","decision","Decision"),"calibration":_calibration(probs,reals),"tables":{"threshold":_table_data(threshold),"ppcr":_table_data(ppcr)}} - out=Path(output_file); out.write_text(_html(payload,_summaries(probs,reals)),encoding="utf-8"); return out + out=Path(output_file); out.write_text(_html(payload,_summaries(probs,reals)),encoding="utf-8"); return out \ No newline at end of file From 78d9aa45b228b727ff9b4ea1ff7789b17d0e6f55 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 17:54:20 +0300 Subject: [PATCH 055/153] Extract calibration renderer for focused parity work --- .../summary_report/calibration_renderer.js | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/rtichoke/summary_report/calibration_renderer.js diff --git a/src/rtichoke/summary_report/calibration_renderer.js b/src/rtichoke/summary_report/calibration_renderer.js new file mode 100644 index 00000000..ea62f404 --- /dev/null +++ b/src/rtichoke/summary_report/calibration_renderer.js @@ -0,0 +1,103 @@ +/* Calibration-only D3 renderer for the lightweight summary report. + * Kept separate so visual parity with the R/Plotly report can be iterated + * without touching the rest of the report template. + */ +function calibration(type, sel) { + const c = R.calibration; + const card = d3.select(sel); + card.selectAll("*").remove(); + + const W = 550, H = 550; + const X0 = 60, X1 = 540; + const MAIN_TOP = 55, MAIN_BOTTOM = 409.9; + const HIST_TOP = 428.1, HIST_BOTTOM = 510; + + const x = d3.scaleLinear().domain(c.ranges.xaxis).range([X0, X1]); + const y = d3.scaleLinear().domain(c.ranges.yaxis).range([MAIN_BOTTOM, MAIN_TOP]); + const histMax = d3.max(c.histogram, d => +d.counts) || 1; + const yHist = d3.scaleLinear().domain([0, histMax]).nice().range([HIST_BOTTOM, HIST_TOP]); + const svg = card.append("svg").attr("viewBox", `0 0 ${W} ${H}`); + + const defs = svg.append("defs"); + defs.append("clipPath").attr("id", `main-${type}`).append("rect") + .attr("x", X0).attr("y", MAIN_TOP) + .attr("width", X1 - X0).attr("height", MAIN_BOTTOM - MAIN_TOP); + defs.append("clipPath").attr("id", `hist-${type}`).append("rect") + .attr("x", X0).attr("y", HIST_TOP) + .attr("width", X1 - X0).attr("height", HIST_BOTTOM - HIST_TOP); + + if (c.groups.length > 1) { + const lg = svg.append("g") + .attr("font-family", "Open Sans, verdana, arial, sans-serif") + .attr("font-size", 12); + const itemW = 93, total = itemW * c.groups.length, start = 300 - total / 2; + c.groups.forEach((g, i) => { + const q = lg.append("g").attr("transform", `translate(${start + i * itemW},24)`); + q.append("line").attr("x1", 5).attr("x2", 35) + .attr("stroke", c.colors[g]).attr("stroke-width", 2); + q.append("text").attr("x", 40).attr("y", 4).attr("fill", "#444").text(g); + }); + } + + svg.append("g").attr("class", "axis").attr("transform", `translate(${X0},0)`).call(d3.axisLeft(y)); + svg.append("g").attr("class", "axis").attr("transform", `translate(${X0},0)`).call(d3.axisLeft(yHist).ticks(5)); + svg.append("g").attr("class", "axis").attr("transform", `translate(0,${HIST_BOTTOM})`).call(d3.axisBottom(x).ticks(5)); + svg.append("text").attr("class", "axis-label").attr("x", (X0 + X1) / 2) + .attr("y", 548).attr("text-anchor", "middle").text("Predicted"); + svg.append("text").attr("class", "axis-label").attr("transform", "rotate(-90)") + .attr("x", -(MAIN_TOP + MAIN_BOTTOM) / 2).attr("y", 18) + .attr("text-anchor", "middle").text("Observed"); + + const line = d3.line().defined(d => isFinite(+d.x) && isFinite(+d.y)) + .x(d => x(+d.x)).y(d => y(+d.y)); + const main = svg.append("g").attr("clip-path", `url(#main-${type})`); + main.append("path").datum(c.reference).attr("fill", "none") + .attr("stroke", "#bebebe").attr("stroke-width", 2) + .attr("stroke-dasharray", "3,3").attr("d", line); + + const dat = type === "smooth" ? c.smooth : c.deciles; + c.groups.forEach(g => { + const a = dat.filter(d => String(d.reference_group) === g); + main.append("path").datum(a).attr("fill", "none") + .attr("stroke", c.colors[g]).attr("stroke-width", 2).attr("d", line); + if (type === "discrete") { + main.selectAll(null).data(a).enter().append("circle") + .attr("cx", d => x(+d.x)).attr("cy", d => y(+d.y)) + .attr("r", 5).attr("fill", c.colors[g]); + } + }); + + const hist = svg.append("g").attr("clip-path", `url(#hist-${type})`); + const opacity = 1 / Math.max(1, c.groups.length); + c.histogram.forEach(d => { + const mid = +d.mids, left = x(mid - .005), right = x(mid + .005); + hist.append("rect") + .attr("x", left).attr("width", Math.max(0, right - left)) + .attr("y", yHist(+d.counts)).attr("height", HIST_BOTTOM - yHist(+d.counts)) + .attr("fill", c.colors[String(d.reference_group)] || "#777") + .attr("opacity", opacity).attr("stroke", "none") + .on("mousemove", ev => tip.style("opacity", 1) + .style("left", (ev.clientX + 10) + "px").style("top", (ev.clientY + 10) + "px") + .html(String(d.text || ""))) + .on("mouseleave", () => tip.style("opacity", 0)); + }); + + const hoverData = dat.concat(c.reference); + svg.append("rect").attr("x", X0).attr("y", MAIN_TOP) + .attr("width", X1 - X0).attr("height", MAIN_BOTTOM - MAIN_TOP) + .attr("fill", "transparent") + .on("mousemove", ev => { + const [mx, my] = d3.pointer(ev); + let best = null, dist = Infinity; + hoverData.forEach(d => { + if (!isFinite(+d.x) || !isFinite(+d.y)) return; + const dd = (x(+d.x) - mx) ** 2 + (y(+d.y) - my) ** 2; + if (dd < dist) { dist = dd; best = d; } + }); + if (best && dist < 625) { + tip.style("opacity", 1).style("left", (ev.clientX + 10) + "px") + .style("top", (ev.clientY + 10) + "px").html(String(best.text || "")); + } else tip.style("opacity", 0); + }) + .on("mouseleave", () => tip.style("opacity", 0)); +} From f2f03058d7333e0e6415ffb250cc33085daeada0 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 17:54:41 +0300 Subject: [PATCH 056/153] Add calibration renderer asset loader --- src/rtichoke/summary_report/calibration_renderer.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/rtichoke/summary_report/calibration_renderer.py diff --git a/src/rtichoke/summary_report/calibration_renderer.py b/src/rtichoke/summary_report/calibration_renderer.py new file mode 100644 index 00000000..8f8dbd2d --- /dev/null +++ b/src/rtichoke/summary_report/calibration_renderer.py @@ -0,0 +1,7 @@ +"""Calibration renderer asset for the lightweight summary report.""" +from pathlib import Path + + +def calibration_renderer_source() -> str: + """Return the calibration-only D3 renderer source.""" + return Path(__file__).with_name("calibration_renderer.js").read_text(encoding="utf-8") From 5990c19406694b3902630825eb9141a96522ac3f Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 17:54:51 +0300 Subject: [PATCH 057/153] Test extracted calibration renderer asset --- tests/test_calibration_renderer_asset.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/test_calibration_renderer_asset.py diff --git a/tests/test_calibration_renderer_asset.py b/tests/test_calibration_renderer_asset.py new file mode 100644 index 00000000..d71cbe19 --- /dev/null +++ b/tests/test_calibration_renderer_asset.py @@ -0,0 +1,10 @@ +from rtichoke.summary_report.calibration_renderer import calibration_renderer_source + + +def test_calibration_renderer_asset_is_available(): + source = calibration_renderer_source() + assert "function calibration(type, sel)" in source + assert "MAIN_BOTTOM = 409.9" in source + assert "HIST_TOP = 428.1" in source + assert 'text("Predicted")' in source + assert 'text("Observed")' in source From fd076986acf079948177ce630018e1c9838c7328 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 17:54:59 +0300 Subject: [PATCH 058/153] Document calibration renderer extraction --- src/rtichoke/summary_report/calibration_renderer.README.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/rtichoke/summary_report/calibration_renderer.README.md diff --git a/src/rtichoke/summary_report/calibration_renderer.README.md b/src/rtichoke/summary_report/calibration_renderer.README.md new file mode 100644 index 00000000..a11ab61d --- /dev/null +++ b/src/rtichoke/summary_report/calibration_renderer.README.md @@ -0,0 +1,7 @@ +# Calibration renderer + +`calibration_renderer.js` is the isolated D3 implementation used for calibration-parity work against the R `create_summary_report()` reference. + +The extraction keeps calibration geometry, histogram behavior, axes, legend, and hover behavior reviewable without mixing changes into discrimination, utility, or performance-table rendering. + +The next wiring step is to inject `calibration_renderer_source()` into the generated self-contained HTML in place of the inline `calibration()` implementation. Once wired, hover and visual parity changes should be made only in this renderer. From 59eb020781587f3fa33ff91710dbbb39bbb61f19 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 18:01:21 +0300 Subject: [PATCH 059/153] Match calibration hover colors to Plotly --- .../summary_report/calibration_renderer.js | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/rtichoke/summary_report/calibration_renderer.js b/src/rtichoke/summary_report/calibration_renderer.js index ea62f404..66ec3055 100644 --- a/src/rtichoke/summary_report/calibration_renderer.js +++ b/src/rtichoke/summary_report/calibration_renderer.js @@ -18,6 +18,19 @@ function calibration(type, sel) { const yHist = d3.scaleLinear().domain([0, histMax]).nice().range([HIST_BOTTOM, HIST_TOP]); const svg = card.append("svg").attr("viewBox", `0 0 ${W} ${H}`); + const showTip = (ev, html, color) => { + tip.style("opacity", 1) + .style("left", (ev.clientX + 10) + "px") + .style("top", (ev.clientY + 10) + "px") + .style("background", color || "#333") + .style("border", "1px solid " + (color || "#333")) + .style("color", "white") + .html(String(html || "")); + }; + const hideTip = () => tip.style("opacity", 0); + const groupOf = d => String(d.reference_group || ""); + const hoverColor = d => groupOf(d) === "reference_line" ? "#bebebe" : (c.colors[groupOf(d)] || "#777"); + const defs = svg.append("defs"); defs.append("clipPath").attr("id", `main-${type}`).append("rect") .attr("x", X0).attr("y", MAIN_TOP) @@ -67,7 +80,7 @@ function calibration(type, sel) { } }); - const hist = svg.append("g").attr("clip-path", `url(#hist-${type})`); + const hist = svg.append("g").attr("clip-path", `url(#hist-${type}})`); const opacity = 1 / Math.max(1, c.groups.length); c.histogram.forEach(d => { const mid = +d.mids, left = x(mid - .005), right = x(mid + .005); @@ -76,10 +89,8 @@ function calibration(type, sel) { .attr("y", yHist(+d.counts)).attr("height", HIST_BOTTOM - yHist(+d.counts)) .attr("fill", c.colors[String(d.reference_group)] || "#777") .attr("opacity", opacity).attr("stroke", "none") - .on("mousemove", ev => tip.style("opacity", 1) - .style("left", (ev.clientX + 10) + "px").style("top", (ev.clientY + 10) + "px") - .html(String(d.text || ""))) - .on("mouseleave", () => tip.style("opacity", 0)); + .on("mousemove", ev => showTip(ev, d.text, hoverColor(d))) + .on("mouseleave", hideTip); }); const hoverData = dat.concat(c.reference); @@ -94,10 +105,8 @@ function calibration(type, sel) { const dd = (x(+d.x) - mx) ** 2 + (y(+d.y) - my) ** 2; if (dd < dist) { dist = dd; best = d; } }); - if (best && dist < 625) { - tip.style("opacity", 1).style("left", (ev.clientX + 10) + "px") - .style("top", (ev.clientY + 10) + "px").html(String(best.text || "")); - } else tip.style("opacity", 0); + if (best && dist < 625) showTip(ev, best.text, hoverColor(best)); + else hideTip(); }) - .on("mouseleave", () => tip.style("opacity", 0)); + .on("mouseleave", hideTip); } From d0245fa04467e1071d7e723298dec7cf71d6cfba Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 18:01:41 +0300 Subject: [PATCH 060/153] Fix calibration histogram clip path --- src/rtichoke/summary_report/calibration_renderer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rtichoke/summary_report/calibration_renderer.js b/src/rtichoke/summary_report/calibration_renderer.js index 66ec3055..1107ad5d 100644 --- a/src/rtichoke/summary_report/calibration_renderer.js +++ b/src/rtichoke/summary_report/calibration_renderer.js @@ -80,7 +80,7 @@ function calibration(type, sel) { } }); - const hist = svg.append("g").attr("clip-path", `url(#hist-${type}})`); + const hist = svg.append("g").attr("clip-path", `url(#hist-${type})`); const opacity = 1 / Math.max(1, c.groups.length); c.histogram.forEach(d => { const mid = +d.mids, left = x(mid - .005), right = x(mid + .005); From 5bd4066f96b23737aa512b581f8f975dece25506 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 18:05:45 +0300 Subject: [PATCH 061/153] Verify calibration renderer can be embedded --- tests/test_calibration_renderer_asset.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_calibration_renderer_asset.py b/tests/test_calibration_renderer_asset.py index d71cbe19..9486209a 100644 --- a/tests/test_calibration_renderer_asset.py +++ b/tests/test_calibration_renderer_asset.py @@ -8,3 +8,12 @@ def test_calibration_renderer_asset_is_available(): assert "HIST_TOP = 428.1" in source assert 'text("Predicted")' in source assert 'text("Observed")' in source + assert "showTip" in source + assert "c.colors[group]" in source + + +def test_calibration_renderer_is_safe_to_inline_in_report_script(): + source = calibration_renderer_source() + assert "" not in source.lower() + assert "calibration('smooth'" not in source + assert "calibration(\"smooth\"" not in source From a41406f06638d4f7620418c92d42aea688c3ef7d Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 18:13:08 +0300 Subject: [PATCH 062/153] Wire extracted calibration renderer into report --- src/rtichoke/summary_report/summary_report.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/rtichoke/summary_report/summary_report.py b/src/rtichoke/summary_report/summary_report.py index b93488cf..947ac198 100644 --- a/src/rtichoke/summary_report/summary_report.py +++ b/src/rtichoke/summary_report/summary_report.py @@ -7,6 +7,7 @@ from rtichoke.calibration.calibration import _create_calibration_curve_list from rtichoke.performance_data.performance_data import prepare_performance_data from rtichoke.processing.plotly_helper_functions import _create_rtichoke_curve_list_binary +from rtichoke.summary_report.calibration_renderer import calibration_renderer_source _CURVES=[("roc","ROC"),("lift","Lift"),("precision recall","Precision Recall"),("gains","Gains")] def _spec(data,strat,curve,label): @@ -27,7 +28,7 @@ def _table_data(d): cols=["reference_group","chosen_cutoff","ppcr","sensitivity","specificity","ppv","npv","lift","predicted_positives","net_benefit","true_positives","true_negatives","false_positives","false_negatives"] return [{k:r.get(k) for k in cols if k in r} for r in d.to_dicts()] def _html(payload,sums): - P=json.dumps(payload,separators=(",",":"),default=str).replace("Summary Report
@@ -65,6 +66,7 @@ def _html(payload,sums): c.histogram.forEach(d=>{{let mid=+d.mids,left=x(mid-.005),right=x(mid+.005);hist.append('rect').attr('x',left).attr('width',Math.max(0,right-left)).attr('y',yHist(+d.counts)).attr('height',HIST_BOTTOM-yHist(+d.counts)).attr('fill',c.colors[String(d.reference_group)]||'#777').attr('opacity',opacity).attr('stroke','none').on('mousemove',ev=>tip.style('opacity',1).style('left',(ev.clientX+10)+'px').style('top',(ev.clientY+10)+'px').html(String(d.text||''))).on('mouseleave',()=>tip.style('opacity',0))}}); const hoverData=dat.concat(c.reference);svg.append('rect').attr('x',X0).attr('y',MAIN_TOP).attr('width',X1-X0).attr('height',MAIN_BOTTOM-MAIN_TOP).attr('fill','transparent').on('mousemove',ev=>{{let [mx,my]=d3.pointer(ev),best=null,dist=Infinity;hoverData.forEach(d=>{{if(!isFinite(+d.x)||!isFinite(+d.y))return;let dd=(x(+d.x)-mx)**2+(y(+d.y)-my)**2;if(ddtip.style('opacity',0)); }} +{CAL} calibration('smooth','#smoothchart');calibration('discrete','#discretechart'); const LABEL={{reference_group:'Model',chosen_cutoff:'Threshold',ppcr:'PPCR',sensitivity:'Sensitivity',specificity:'Specificity',ppv:'PPV',npv:'NPV',lift:'Lift',predicted_positives:'Predicted Positives',net_benefit:'Net Benefit'}},MET=new Set(['sensitivity','specificity','ppv','npv']);function fmt(v){{return typeof v==='number'&&isFinite(v)?v.toFixed(2):(v??'')}}function perf(rows,sel,isP){{let keys=(isP?['reference_group','ppcr']:['reference_group','chosen_cutoff']).concat(['sensitivity','specificity','ppv','npv','lift','predicted_positives','net_benefit']),table=d3.select(sel).append('div').attr('class','perf-wrap').append('table').attr('class','perf'),head=table.append('thead').append('tr');head.append('th');keys.forEach(k=>head.append('th').text(LABEL[k]||k));let body=table.append('tbody');rows.forEach(r=>{{let tr=body.append('tr'),ex=tr.append('td').attr('class','expand').text('›');keys.forEach(k=>tr.append('td').attr('class',k==='reference_group'?'model':'').text(fmt(r[k])));let d=body.append('tr').attr('class','detail').style('display','none'),cell=d.append('td').attr('colspan',keys.length+1);cell.html(`Confusion MatrixTP ${{fmt(r.true_positives)}}FN ${{fmt(r.false_negatives)}}FP ${{fmt(r.false_positives)}}TN ${{fmt(r.true_negatives)}}`);ex.on('click',()=>{{let open=d.style('display')!=='none';d.style('display',open?'none':'table-row');ex.text(open?'›':'⌄')}})}})}}perf(R.tables.threshold,'#table-threshold',false);perf(R.tables.ppcr,'#table-ppcr',true); ''' From 37de426163ef93917f66f72d04ddfdd14e352418 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 18:20:13 +0300 Subject: [PATCH 063/153] Fix calibration renderer asset assertion --- tests/test_calibration_renderer_asset.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_calibration_renderer_asset.py b/tests/test_calibration_renderer_asset.py index 9486209a..c1be29e3 100644 --- a/tests/test_calibration_renderer_asset.py +++ b/tests/test_calibration_renderer_asset.py @@ -9,7 +9,8 @@ def test_calibration_renderer_asset_is_available(): assert 'text("Predicted")' in source assert 'text("Observed")' in source assert "showTip" in source - assert "c.colors[group]" in source + assert "hoverColor" in source + assert "c.colors[groupOf(d)]" in source def test_calibration_renderer_is_safe_to_inline_in_report_script(): From 3365551ff4f8af0fd9d171cd84d1c34c39f5caf0 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 18:29:16 +0300 Subject: [PATCH 064/153] Add fast summary report preview workflow --- .github/workflows/summary-report-preview.yml | 46 ++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/summary-report-preview.yml diff --git a/.github/workflows/summary-report-preview.yml b/.github/workflows/summary-report-preview.yml new file mode 100644 index 00000000..2148e568 --- /dev/null +++ b/.github/workflows/summary-report-preview.yml @@ -0,0 +1,46 @@ +name: Summary report preview + +on: + pull_request: + branches: [main] + types: [opened, reopened, synchronize] + paths: + - 'src/rtichoke/summary_report/**' + - 'examples/summary_report_demo.py' + - 'tests/test_calibration_renderer_asset.py' + - '.github/workflows/summary-report-preview.yml' + +permissions: + contents: read + +concurrency: + group: summary-report-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + preview: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv and Python + uses: astral-sh/setup-uv@v5 + with: + python-version: '3.11' + + - name: Install package + run: uv sync + + - name: Generate summary report demo + run: | + uv run python examples/summary_report_demo.py + test -s summary-report-demo.html + + - name: Upload summary report preview + uses: actions/upload-artifact@v4 + with: + name: summary-report-preview-pr-${{ github.event.pull_request.number }} + path: summary-report-demo.html + if-no-files-found: error + retention-days: 7 From 762cf34bf6ecc333b7ed63c42ca37a879e63aa03 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 18:39:15 +0300 Subject: [PATCH 065/153] Refine calibration hover styling to match Plotly --- .../summary_report/calibration_renderer.js | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/rtichoke/summary_report/calibration_renderer.js b/src/rtichoke/summary_report/calibration_renderer.js index 1107ad5d..f2f4e33b 100644 --- a/src/rtichoke/summary_report/calibration_renderer.js +++ b/src/rtichoke/summary_report/calibration_renderer.js @@ -18,13 +18,26 @@ function calibration(type, sel) { const yHist = d3.scaleLinear().domain([0, histMax]).nice().range([HIST_BOTTOM, HIST_TOP]); const svg = card.append("svg").attr("viewBox", `0 0 ${W} ${H}`); + const contrastText = color => { + const hex = String(color || "#333").replace("#", ""); + if (!/^[0-9a-f]{6}$/i.test(hex)) return "white"; + const r = parseInt(hex.slice(0, 2), 16), g = parseInt(hex.slice(2, 4), 16), b = parseInt(hex.slice(4, 6), 16); + return (0.299 * r + 0.587 * g + 0.114 * b) > 170 ? "#222" : "white"; + }; const showTip = (ev, html, color) => { + const bg = color || "#333"; tip.style("opacity", 1) .style("left", (ev.clientX + 10) + "px") .style("top", (ev.clientY + 10) + "px") - .style("background", color || "#333") - .style("border", "1px solid " + (color || "#333")) - .style("color", "white") + .style("background", bg) + .style("border", "1px solid " + bg) + .style("border-radius", "2px") + .style("box-shadow", "none") + .style("padding", "6px 8px") + .style("font-family", "Open Sans, verdana, arial, sans-serif") + .style("font-size", "12px") + .style("line-height", "15px") + .style("color", contrastText(bg)) .html(String(html || "")); }; const hideTip = () => tip.style("opacity", 0); @@ -89,11 +102,16 @@ function calibration(type, sel) { .attr("y", yHist(+d.counts)).attr("height", HIST_BOTTOM - yHist(+d.counts)) .attr("fill", c.colors[String(d.reference_group)] || "#777") .attr("opacity", opacity).attr("stroke", "none") + .on("mouseenter", function() { d3.select(this).attr("opacity", Math.min(1, opacity + 0.18)); }) .on("mousemove", ev => showTip(ev, d.text, hoverColor(d))) - .on("mouseleave", hideTip); + .on("mouseleave", function() { d3.select(this).attr("opacity", opacity); hideTip(); }); }); const hoverData = dat.concat(c.reference); + const hoverLayer = svg.append("g"); + const marker = hoverLayer.append("circle") + .attr("r", 4).attr("fill", "white").attr("stroke-width", 2) + .style("display", "none").style("pointer-events", "none"); svg.append("rect").attr("x", X0).attr("y", MAIN_TOP) .attr("width", X1 - X0).attr("height", MAIN_BOTTOM - MAIN_TOP) .attr("fill", "transparent") @@ -105,8 +123,14 @@ function calibration(type, sel) { const dd = (x(+d.x) - mx) ** 2 + (y(+d.y) - my) ** 2; if (dd < dist) { dist = dd; best = d; } }); - if (best && dist < 625) showTip(ev, best.text, hoverColor(best)); - else hideTip(); + if (best && dist < 625) { + const color = hoverColor(best); + marker.attr("cx", x(+best.x)).attr("cy", y(+best.y)).attr("stroke", color).style("display", null); + showTip(ev, best.text, color); + } else { + marker.style("display", "none"); + hideTip(); + } }) - .on("mouseleave", hideTip); + .on("mouseleave", () => { marker.style("display", "none"); hideTip(); }); } From c91b01ab6930d50822d60fe3454d54c41efa0ec1 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 19:14:20 +0300 Subject: [PATCH 066/153] Match calibration markers to Plotly scatter styling --- .../summary_report/calibration_renderer.js | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/rtichoke/summary_report/calibration_renderer.js b/src/rtichoke/summary_report/calibration_renderer.js index f2f4e33b..122b1b29 100644 --- a/src/rtichoke/summary_report/calibration_renderer.js +++ b/src/rtichoke/summary_report/calibration_renderer.js @@ -87,9 +87,15 @@ function calibration(type, sel) { main.append("path").datum(a).attr("fill", "none") .attr("stroke", c.colors[g]).attr("stroke-width", 2).attr("d", line); if (type === "discrete") { + // Plotly scatter marker size=10 renders as a 10px diameter filled circle. + // Its default marker line is width 0, so don't add a visible outline. main.selectAll(null).data(a).enter().append("circle") .attr("cx", d => x(+d.x)).attr("cy", d => y(+d.y)) - .attr("r", 5).attr("fill", c.colors[g]); + .attr("r", 5) + .attr("fill", c.colors[g]) + .attr("stroke", c.colors[g]) + .attr("stroke-width", 0) + .attr("shape-rendering", "geometricPrecision"); } }); @@ -102,16 +108,11 @@ function calibration(type, sel) { .attr("y", yHist(+d.counts)).attr("height", HIST_BOTTOM - yHist(+d.counts)) .attr("fill", c.colors[String(d.reference_group)] || "#777") .attr("opacity", opacity).attr("stroke", "none") - .on("mouseenter", function() { d3.select(this).attr("opacity", Math.min(1, opacity + 0.18)); }) .on("mousemove", ev => showTip(ev, d.text, hoverColor(d))) - .on("mouseleave", function() { d3.select(this).attr("opacity", opacity); hideTip(); }); + .on("mouseleave", hideTip); }); const hoverData = dat.concat(c.reference); - const hoverLayer = svg.append("g"); - const marker = hoverLayer.append("circle") - .attr("r", 4).attr("fill", "white").attr("stroke-width", 2) - .style("display", "none").style("pointer-events", "none"); svg.append("rect").attr("x", X0).attr("y", MAIN_TOP) .attr("width", X1 - X0).attr("height", MAIN_BOTTOM - MAIN_TOP) .attr("fill", "transparent") @@ -123,14 +124,8 @@ function calibration(type, sel) { const dd = (x(+d.x) - mx) ** 2 + (y(+d.y) - my) ** 2; if (dd < dist) { dist = dd; best = d; } }); - if (best && dist < 625) { - const color = hoverColor(best); - marker.attr("cx", x(+best.x)).attr("cy", y(+best.y)).attr("stroke", color).style("display", null); - showTip(ev, best.text, color); - } else { - marker.style("display", "none"); - hideTip(); - } + if (best && dist < 625) showTip(ev, best.text, hoverColor(best)); + else hideTip(); }) - .on("mouseleave", () => { marker.style("display", "none"); hideTip(); }); + .on("mouseleave", hideTip); } From edff628f1f90841e530cc3f1ede7be9f95f60fc0 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 20:36:52 +0300 Subject: [PATCH 067/153] Match calibration subplot geometry to Plotly --- .../summary_report/calibration_renderer.js | 46 +++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/rtichoke/summary_report/calibration_renderer.js b/src/rtichoke/summary_report/calibration_renderer.js index 122b1b29..4d1aa1cc 100644 --- a/src/rtichoke/summary_report/calibration_renderer.js +++ b/src/rtichoke/summary_report/calibration_renderer.js @@ -7,10 +7,13 @@ function calibration(type, sel) { const card = d3.select(sel); card.selectAll("*").remove(); - const W = 550, H = 550; - const X0 = 60, X1 = 540; - const MAIN_TOP = 55, MAIN_BOTTOM = 409.9; - const HIST_TOP = 428.1, HIST_BOTTOM = 510; + // Match the 600px square Plotly calibration figure and its two-row subplot. + // Plotly's default margins leave an inner plotting width of about 500px; + // the 0.8/0.2 row heights are represented directly below. + const W = 600, H = 600; + const X0 = 80, X1 = 580; + const MAIN_TOP = 100, MAIN_BOTTOM = 424; + const HIST_TOP = 444, HIST_BOTTOM = 525; const x = d3.scaleLinear().domain(c.ranges.xaxis).range([X0, X1]); const y = d3.scaleLinear().domain(c.ranges.yaxis).range([MAIN_BOTTOM, MAIN_TOP]); @@ -56,22 +59,31 @@ function calibration(type, sel) { const lg = svg.append("g") .attr("font-family", "Open Sans, verdana, arial, sans-serif") .attr("font-size", 12); - const itemW = 93, total = itemW * c.groups.length, start = 300 - total / 2; + const itemW = 100, total = itemW * c.groups.length, start = W / 2 - total / 2; c.groups.forEach((g, i) => { - const q = lg.append("g").attr("transform", `translate(${start + i * itemW},24)`); + const q = lg.append("g").attr("transform", `translate(${start + i * itemW},62)`); q.append("line").attr("x1", 5).attr("x2", 35) .attr("stroke", c.colors[g]).attr("stroke-width", 2); q.append("text").attr("x", 40).attr("y", 4).attr("fill", "#444").text(g); }); } - svg.append("g").attr("class", "axis").attr("transform", `translate(${X0},0)`).call(d3.axisLeft(y)); - svg.append("g").attr("class", "axis").attr("transform", `translate(${X0},0)`).call(d3.axisLeft(yHist).ticks(5)); - svg.append("g").attr("class", "axis").attr("transform", `translate(0,${HIST_BOTTOM})`).call(d3.axisBottom(x).ticks(5)); - svg.append("text").attr("class", "axis-label").attr("x", (X0 + X1) / 2) - .attr("y", 548).attr("text-anchor", "middle").text("Predicted"); - svg.append("text").attr("class", "axis-label").attr("transform", "rotate(-90)") - .attr("x", -(MAIN_TOP + MAIN_BOTTOM) / 2).attr("y", 18) + const styleAxis = axis => { + axis.attr("font-family", "Open Sans, verdana, arial, sans-serif").attr("font-size", 12).attr("color", "#444"); + axis.select(".domain").attr("stroke", "#444").attr("stroke-width", 1); + axis.selectAll(".tick line").attr("stroke", "#444"); + }; + const mainYAxis = svg.append("g").attr("class", "axis").attr("transform", `translate(${X0},0)`).call(d3.axisLeft(y).ticks(5)); + const histYAxis = svg.append("g").attr("class", "axis").attr("transform", `translate(${X0},0)`).call(d3.axisLeft(yHist).ticks(4)); + const xAxis = svg.append("g").attr("class", "axis").attr("transform", `translate(0,${HIST_BOTTOM})`).call(d3.axisBottom(x).ticks(5)); + styleAxis(mainYAxis); styleAxis(histYAxis); styleAxis(xAxis); + + svg.append("text").attr("class", "axis-label") + .attr("font-family", "Open Sans, verdana, arial, sans-serif").attr("font-size", 14).attr("fill", "#444") + .attr("x", (X0 + X1) / 2).attr("y", 580).attr("text-anchor", "middle").text("Predicted"); + svg.append("text").attr("class", "axis-label") + .attr("font-family", "Open Sans, verdana, arial, sans-serif").attr("font-size", 14).attr("fill", "#444") + .attr("transform", "rotate(-90)").attr("x", -(MAIN_TOP + MAIN_BOTTOM) / 2).attr("y", 24) .attr("text-anchor", "middle").text("Observed"); const line = d3.line().defined(d => isFinite(+d.x) && isFinite(+d.y)) @@ -87,14 +99,10 @@ function calibration(type, sel) { main.append("path").datum(a).attr("fill", "none") .attr("stroke", c.colors[g]).attr("stroke-width", 2).attr("d", line); if (type === "discrete") { - // Plotly scatter marker size=10 renders as a 10px diameter filled circle. - // Its default marker line is width 0, so don't add a visible outline. main.selectAll(null).data(a).enter().append("circle") .attr("cx", d => x(+d.x)).attr("cy", d => y(+d.y)) - .attr("r", 5) - .attr("fill", c.colors[g]) - .attr("stroke", c.colors[g]) - .attr("stroke-width", 0) + .attr("r", 5).attr("fill", c.colors[g]) + .attr("stroke", c.colors[g]).attr("stroke-width", 0) .attr("shape-rendering", "geometricPrecision"); } }); From 5c67a7af2f0c3c187b606646fbdde9d753d3501a Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 20:40:41 +0300 Subject: [PATCH 068/153] Match calibration legend to R report --- .../summary_report/calibration_renderer.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/rtichoke/summary_report/calibration_renderer.js b/src/rtichoke/summary_report/calibration_renderer.js index 4d1aa1cc..4816285c 100644 --- a/src/rtichoke/summary_report/calibration_renderer.js +++ b/src/rtichoke/summary_report/calibration_renderer.js @@ -8,8 +8,6 @@ function calibration(type, sel) { card.selectAll("*").remove(); // Match the 600px square Plotly calibration figure and its two-row subplot. - // Plotly's default margins leave an inner plotting width of about 500px; - // the 0.8/0.2 row heights are represented directly below. const W = 600, H = 600; const X0 = 80, X1 = 580; const MAIN_TOP = 100, MAIN_BOTTOM = 424; @@ -55,16 +53,22 @@ function calibration(type, sel) { .attr("x", X0).attr("y", HIST_TOP) .attr("width", X1 - X0).attr("height", HIST_BOTTOM - HIST_TOP); + // R Plotly: horizontal, centered legend at x=.5/y=1.1. Discrete traces + // show markers+lines; smooth traces show lines only. if (c.groups.length > 1) { const lg = svg.append("g") .attr("font-family", "Open Sans, verdana, arial, sans-serif") .attr("font-size", 12); - const itemW = 100, total = itemW * c.groups.length, start = W / 2 - total / 2; + const itemW = 110, total = itemW * c.groups.length, start = W / 2 - total / 2; c.groups.forEach((g, i) => { - const q = lg.append("g").attr("transform", `translate(${start + i * itemW},62)`); + const q = lg.append("g").attr("transform", `translate(${start + i * itemW},72)`); q.append("line").attr("x1", 5).attr("x2", 35) .attr("stroke", c.colors[g]).attr("stroke-width", 2); - q.append("text").attr("x", 40).attr("y", 4).attr("fill", "#444").text(g); + if (type === "discrete") { + q.append("circle").attr("cx", 20).attr("cy", 0).attr("r", 5) + .attr("fill", c.colors[g]).attr("stroke", c.colors[g]); + } + q.append("text").attr("x", 42).attr("y", 4).attr("fill", "#444").text(g); }); } @@ -107,6 +111,7 @@ function calibration(type, sel) { } }); + // R uses 0.01-wide overlaid bars and opacity 1 / number of groups. const hist = svg.append("g").attr("clip-path", `url(#hist-${type})`); const opacity = 1 / Math.max(1, c.groups.length); c.histogram.forEach(d => { From eafdb74825694e6dc739e092f5a04b9d40239271 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 21:03:47 +0300 Subject: [PATCH 069/153] Match lightweight performance tables to R report --- .../performance_table_renderer.js | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/rtichoke/summary_report/performance_table_renderer.js diff --git a/src/rtichoke/summary_report/performance_table_renderer.js b/src/rtichoke/summary_report/performance_table_renderer.js new file mode 100644 index 00000000..33cddffb --- /dev/null +++ b/src/rtichoke/summary_report/performance_table_renderer.js @@ -0,0 +1,92 @@ +/* R/Reactable-parity renderer for lightweight summary-report performance tables. */ +(function () { + const COLORS = ["#1b9e77", "#d95f02", "#7570b3", "#e7298a", "#07004D", "#E6AB02", "#FE5F55", "#54494B", "#006E90", "#BC96E6", "#52050A", "#1F271B", "#BE7C4D", "#63768D", "#08A045", "#320A28", "#82FF9E", "#2176FF", "#D1603D", "#585123"]; + const fmt = v => typeof v === "number" && isFinite(v) ? v.toFixed(2) : (v ?? ""); + const pct = v => typeof v === "number" && isFinite(v) ? `${(100 * v).toFixed(2)}%` : ""; + const num = v => typeof v === "number" && isFinite(v) ? v : 0; + const esc = v => String(v ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c])); + + function injectStyles() { + if (document.getElementById("rtichoke-perf-parity-css")) return; + const style = document.createElement("style"); + style.id = "rtichoke-perf-parity-css"; + style.textContent = ` + .rt-perf-wrap{overflow:auto;border:1px solid #e5e5e5;border-radius:3px;max-height:620px;background:#fff} + .rt-perf{width:100%;border-collapse:separate;border-spacing:0;margin:0;font-size:14px} + .rt-perf th,.rt-perf td{padding:8px 10px;text-align:left;border-bottom:1px solid #eee;white-space:nowrap;position:relative} + .rt-perf thead th{position:sticky;top:0;z-index:3;background:#fff;font-weight:600;color:#333} + .rt-perf .metric-group{text-align:center;border-bottom:1px solid #ddd} + .rt-perf .model-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:8px;vertical-align:1px} + .rt-perf .expand{width:28px;text-align:center;color:#777;cursor:pointer;font-size:18px;padding-left:6px;padding-right:6px} + .rt-perf .bar-cell{background-repeat:no-repeat;background-position:center;background-size:98% 88%} + .rt-perf .detail td{background:#fafafa;padding:16px} + .rt-conf{display:inline-table;border-collapse:collapse;margin:4px 0 4px 8px;vertical-align:middle} + .rt-conf th,.rt-conf td{padding:6px 10px;border:1px solid #eee;text-align:left;min-width:105px} + .rt-conf th{position:static;background:#fff;font-weight:600} + .rt-conf .outcome{font-weight:600} + `; + document.head.appendChild(style); + } + + function metricBackground(value, maxValue=1, color="lightgreen") { + if (!isFinite(+value) || maxValue <= 0) return ""; + const width = Math.min(Math.abs(+value) / maxValue, 1) * 100; + return `linear-gradient(90deg, ${color} ${width}%, transparent ${width}%)`; + } + + function nbBackground(value, maximum) { + if (!isFinite(+value) || maximum <= 0) return ""; + const width = Math.max(-1, Math.min(+value / maximum, 1)); + const position = (0.5 + width / 2) * 100; + return width >= 0 + ? `linear-gradient(90deg, transparent 50%, lightgreen 50%, lightgreen ${position}%, transparent ${position}%)` + : `linear-gradient(90deg, transparent ${position}%, pink ${position}%, pink 50%, transparent 50%)`; + } + + function confusionMatrix(r) { + const tp=num(r.true_positives), tn=num(r.true_negatives), fp=num(r.false_positives), fn=num(r.false_negatives); + const total=tp+tn+fp+fn || 1; + const rows=[ + ["Predicted Positive",tp,fp,"lightgreen","pink"], + ["Predicted Negative",fn,tn,"pink","lightgreen"], + [" ",tp+fn,fp+tn,"lightgrey","lightgrey"] + ]; + const value=(x)=>`${fmt(x)} (${(100*x/total).toFixed(2)}%)`; + return `${rows.map((q,i)=>``).join("")}
Real PositiveReal Negative
${q[0]}${value(q[1])}${value(q[2])}${value(q[1]+q[2])}
`; + } + + function render(rows, selector, isPpcr) { + const host=document.querySelector(selector); if(!host) return; + host.innerHTML=""; + const models=[...new Set(rows.map(r=>String(r.reference_group ?? "")))]; + const colors=Object.fromEntries(models.map((m,i)=>[m,COLORS[i%COLORS.length]])); + const liftMax=Math.max(1e-12,...rows.map(r=>Math.abs(num(r.lift)))); + const nbMax=Math.max(1e-12,...rows.map(r=>Math.abs(num(r.net_benefit)))); + const sorted=[...rows].sort((a,b)=> isPpcr ? num(a.ppcr)-num(b.ppcr) : num(a.chosen_cutoff)-num(b.chosen_cutoff)); + const wrap=document.createElement("div"); wrap.className="rt-perf-wrap"; + const table=document.createElement("table"); table.className="rt-perf"; + const metricCount=isPpcr?5:6; + table.innerHTML=`Model${isPpcr?"":"Probability Threshold"}Predicted PositivesPerformance MetricsSensSpecPPVNPVLift${isPpcr?"":"Net Benefit"}`; + const body=document.createElement("tbody"); + sorted.forEach(r=>{ + const tr=document.createElement("tr"); + const model=String(r.reference_group ?? ""); + const ppcrText=`${fmt(r.predicted_positives)} (${pct(r.ppcr)})`; + const metrics=[["sensitivity",1],["specificity",1],["ppv",1],["npv",1],["lift",liftMax]]; + tr.innerHTML=`›${esc(model)}${isPpcr?"":`${fmt(r.chosen_cutoff)}`}${ppcrText}${metrics.map(([k,m])=>`${fmt(r[k])}`).join("")}${isPpcr?"":`${fmt(r.net_benefit)}`}`; + const detail=document.createElement("tr"); detail.className="detail"; detail.style.display="none"; + const td=document.createElement("td"); td.colSpan=isPpcr?9:11; td.innerHTML=confusionMatrix(r); detail.appendChild(td); + tr.querySelector(".expand").addEventListener("click",e=>{const open=detail.style.display!=="none"; detail.style.display=open?"none":"table-row"; e.currentTarget.textContent=open?"›":"⌄";}); + body.appendChild(tr); body.appendChild(detail); + }); + table.appendChild(body); wrap.appendChild(table); host.appendChild(wrap); + } + + window.addEventListener("load",()=>{ + injectStyles(); + if (window.R && R.tables) { + render(R.tables.threshold,"#table-threshold",false); + render(R.tables.ppcr,"#table-ppcr",true); + } + }); +})(); From af45f56772e7913e0ead29ab45977261a7baafba Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 21:03:57 +0300 Subject: [PATCH 070/153] Load performance table parity renderer in summary report --- src/rtichoke/summary_report/calibration_renderer.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/rtichoke/summary_report/calibration_renderer.py b/src/rtichoke/summary_report/calibration_renderer.py index 8f8dbd2d..edad2ab6 100644 --- a/src/rtichoke/summary_report/calibration_renderer.py +++ b/src/rtichoke/summary_report/calibration_renderer.py @@ -1,7 +1,12 @@ -"""Calibration renderer asset for the lightweight summary report.""" +"""Renderer assets for the lightweight summary report.""" from pathlib import Path def calibration_renderer_source() -> str: - """Return the calibration-only D3 renderer source.""" - return Path(__file__).with_name("calibration_renderer.js").read_text(encoding="utf-8") + """Return the lightweight D3 renderer sources embedded in the report.""" + root = Path(__file__).parent + assets = ( + root / "calibration_renderer.js", + root / "performance_table_renderer.js", + ) + return "\n".join(path.read_text(encoding="utf-8") for path in assets) From 40b5636f9f8284fc42021c74399c7f908034eb85 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 21:07:47 +0300 Subject: [PATCH 071/153] Add Plotly-parity performance curve renderer --- src/rtichoke/summary_report/curve_renderer.js | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/rtichoke/summary_report/curve_renderer.js diff --git a/src/rtichoke/summary_report/curve_renderer.js b/src/rtichoke/summary_report/curve_renderer.js new file mode 100644 index 00000000..f1c9dc71 --- /dev/null +++ b/src/rtichoke/summary_report/curve_renderer.js @@ -0,0 +1,60 @@ +/* D3 renderer for performance and decision curves in the lightweight report. + * Mirrors rtichoke's Plotly conventions: 600px figure, solid model traces, + * dotted references, Plotly-like hover labels, and no grid lines. + */ +function drawRtichokeCurve(s, sel) { + const card = d3.select(sel); + card.selectAll("*").remove(); + card.append("div").attr("class", "plot-title").text(s.title); + + if (s.groups.length > 1) { + const lg = card.append("div").attr("class", "legend"); + s.groups.forEach(g => lg.append("span").html(`${g}`)); + } + + const W = 600, H = 600, m = {top: 45, right: 35, bottom: 75, left: 75}; + const svg = card.append("svg").attr("viewBox", `0 0 ${W} ${H}`); + const x = d3.scaleLinear().domain(s.x_range).range([m.left, W - m.right]); + const y = d3.scaleLinear().domain(s.y_range).range([H - m.bottom, m.top]); + const line = d3.line().defined(d => isFinite(+d.x) && isFinite(+d.y)).x(d => x(+d.x)).y(d => y(+d.y)); + + const styleAxis = axis => { + axis.attr("font-family", "Open Sans, verdana, arial, sans-serif").attr("font-size", 12).attr("color", "#444"); + axis.select(".domain").attr("stroke", "#444"); + axis.selectAll(".tick line").attr("stroke", "#444"); + }; + const xa = svg.append("g").attr("class", "axis").attr("transform", `translate(0,${H-m.bottom})`).call(d3.axisBottom(x).ticks(6)); + const ya = svg.append("g").attr("class", "axis").attr("transform", `translate(${m.left},0)`).call(d3.axisLeft(y).ticks(6)); + styleAxis(xa); styleAxis(ya); + svg.append("text").attr("class", "axis-label").attr("x", (m.left + W-m.right)/2).attr("y", H-20).attr("text-anchor", "middle").text(s.x_label); + svg.append("text").attr("class", "axis-label").attr("transform", "rotate(-90)").attr("x", -(m.top+H-m.bottom)/2).attr("y", 22).attr("text-anchor", "middle").text(s.y_label); + + const traces = []; + d3.group(s.references, d => String(d.reference_group)).forEach((a,g) => { + const color = s.colors[g] || "#999"; + svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("stroke-dasharray","3,3").attr("d",line); + traces.push(...a.map(d => ({...d, _color: color}))); + }); + s.groups.forEach(g => { + const a = s.data.filter(d => String(d.reference_group) === g); + const color = s.colors[g] || "#000"; + svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("d",line); + traces.push(...a.map(d => ({...d, _color: color}))); + }); + + const contrast = color => { + const h=String(color||"#333").replace("#",""); if(!/^[0-9a-f]{6}$/i.test(h)) return "white"; + const r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16); + return (.299*r+.587*g+.114*b)>170 ? "#222" : "white"; + }; + const show = (ev,d) => tip.style("opacity",1).style("left",(ev.clientX+10)+"px").style("top",(ev.clientY+10)+"px") + .style("background",d._color).style("border","1px solid "+d._color).style("color",contrast(d._color)) + .style("padding","6px 8px").style("border-radius","2px").html(String(d.text||"")); + const hide = () => tip.style("opacity",0); + svg.append("rect").attr("x",m.left).attr("y",m.top).attr("width",W-m.left-m.right).attr("height",H-m.top-m.bottom).attr("fill","transparent") + .on("mousemove", ev => { + const [mx,my]=d3.pointer(ev); let best=null,dist=Infinity; + traces.forEach(d=>{if(!isFinite(+d.x)||!isFinite(+d.y))return;const dd=(x(+d.x)-mx)**2+(y(+d.y)-my)**2;if(dd Date: Wed, 19 Aug 2026 21:07:54 +0300 Subject: [PATCH 072/153] Expose performance curve renderer asset --- src/rtichoke/summary_report/curve_renderer.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/rtichoke/summary_report/curve_renderer.py diff --git a/src/rtichoke/summary_report/curve_renderer.py b/src/rtichoke/summary_report/curve_renderer.py new file mode 100644 index 00000000..07eabd64 --- /dev/null +++ b/src/rtichoke/summary_report/curve_renderer.py @@ -0,0 +1,7 @@ +"""Performance/decision curve renderer asset for the lightweight summary report.""" +from pathlib import Path + + +def curve_renderer_source() -> str: + """Return the D3 performance/decision curve renderer source.""" + return Path(__file__).with_name("curve_renderer.js").read_text(encoding="utf-8") From 960350db61b87dd41add49dddebe7b522a7ca03b Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 21:17:59 +0300 Subject: [PATCH 073/153] Wire Plotly-parity curve renderer into summary report --- .../summary_report/summary_report_v2.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/rtichoke/summary_report/summary_report_v2.py diff --git a/src/rtichoke/summary_report/summary_report_v2.py b/src/rtichoke/summary_report/summary_report_v2.py new file mode 100644 index 00000000..8702042e --- /dev/null +++ b/src/rtichoke/summary_report/summary_report_v2.py @@ -0,0 +1,51 @@ +"""Readable integration layer for the lightweight summary report renderers. + +This module keeps the legacy report generator stable while the large inline +HTML template is being split into maintainable assets. It post-processes the +legacy HTML to route performance and decision curves through the dedicated +Plotly-parity D3 renderer. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Dict, Union + +import numpy as np + +from rtichoke.summary_report.curve_renderer import curve_renderer_source +from rtichoke.summary_report.summary_report import create_summary_report as _legacy_create_summary_report + + +def _wire_curve_renderer(html: str) -> str: + """Replace the legacy curve drawing calls with the dedicated renderer.""" + renderer = curve_renderer_source() + marker = "function curveTabs(specs,nav,chart,strat)" + if marker not in html: + raise RuntimeError("Could not locate summary-report curve integration point") + + html = html.replace(marker, renderer + "\n" + marker, 1) + html = html.replace( + "draw(s,chart,strat)}}));draw(specs[0],chart,strat)", + "drawRtichokeCurve(s,chart)}}));drawRtichokeCurve(specs[0],chart)", + 1, + ) + html = html.replace( + "draw(R.decision,'#decision','probability_threshold');", + "drawRtichokeCurve(R.decision,'#decision');", + 1, + ) + return html + + +def create_summary_report( + probs: Dict[str, np.ndarray], + reals: Union[np.ndarray, Dict[str, np.ndarray]], + output_file: str | Path = "summary_report.html", + by: float = 0.01, +) -> Path: + """Create the lightweight report using the dedicated curve renderer.""" + out = Path(output_file) + _legacy_create_summary_report(probs=probs, reals=reals, output_file=out, by=by) + html = _wire_curve_renderer(out.read_text(encoding="utf-8")) + out.write_text(html, encoding="utf-8") + return out From da047dc6310efc8b063c7f689edac0289c1bfad8 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 21:18:08 +0300 Subject: [PATCH 074/153] Use modular summary report renderer --- src/rtichoke/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rtichoke/__init__.py b/src/rtichoke/__init__.py index 5e8b0855..71a70a74 100644 --- a/src/rtichoke/__init__.py +++ b/src/rtichoke/__init__.py @@ -57,7 +57,7 @@ render_performance_table as render_performance_table, ) -from rtichoke.summary_report.summary_report import ( +from rtichoke.summary_report.summary_report_v2 import ( create_summary_report as create_summary_report, ) From eed76c49dcfabdcb70d31e6c1d36b00fca494e3f Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 21:22:18 +0300 Subject: [PATCH 075/153] Update calibration renderer geometry assertions --- tests/test_calibration_renderer_asset.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_calibration_renderer_asset.py b/tests/test_calibration_renderer_asset.py index c1be29e3..35bdf5a7 100644 --- a/tests/test_calibration_renderer_asset.py +++ b/tests/test_calibration_renderer_asset.py @@ -4,8 +4,11 @@ def test_calibration_renderer_asset_is_available(): source = calibration_renderer_source() assert "function calibration(type, sel)" in source - assert "MAIN_BOTTOM = 409.9" in source - assert "HIST_TOP = 428.1" in source + # Keep this test aligned with the canonical 600px Plotly geometry used by + # the parity renderer rather than the earlier hand-tuned 550px prototype. + assert "const W = 600, H = 600" in source + assert "MAIN_TOP = 100, MAIN_BOTTOM = 424" in source + assert "HIST_TOP = 444, HIST_BOTTOM = 525" in source assert 'text("Predicted")' in source assert 'text("Observed")' in source assert "showTip" in source @@ -17,4 +20,4 @@ def test_calibration_renderer_is_safe_to_inline_in_report_script(): source = calibration_renderer_source() assert "" not in source.lower() assert "calibration('smooth'" not in source - assert "calibration(\"smooth\"" not in source + assert 'calibration("smooth"' not in source From b4cf93846bda319ed12064ef2987089a456f1244 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 21:44:33 +0300 Subject: [PATCH 076/153] Match performance curves to R Plotly markers --- src/rtichoke/summary_report/curve_renderer.js | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/rtichoke/summary_report/curve_renderer.js b/src/rtichoke/summary_report/curve_renderer.js index f1c9dc71..16305560 100644 --- a/src/rtichoke/summary_report/curve_renderer.js +++ b/src/rtichoke/summary_report/curve_renderer.js @@ -1,17 +1,12 @@ /* D3 renderer for performance and decision curves in the lightweight report. - * Mirrors rtichoke's Plotly conventions: 600px figure, solid model traces, - * dotted references, Plotly-like hover labels, and no grid lines. + * Mirrors rtichoke's Plotly conventions: 600px figure, markers+lines for + * performance data, dotted references, Plotly-like hover labels, no grid. */ function drawRtichokeCurve(s, sel) { const card = d3.select(sel); card.selectAll("*").remove(); card.append("div").attr("class", "plot-title").text(s.title); - if (s.groups.length > 1) { - const lg = card.append("div").attr("class", "legend"); - s.groups.forEach(g => lg.append("span").html(`${g}`)); - } - const W = 600, H = 600, m = {top: 45, right: 35, bottom: 75, left: 75}; const svg = card.append("svg").attr("viewBox", `0 0 ${W} ${H}`); const x = d3.scaleLinear().domain(s.x_range).range([m.left, W - m.right]); @@ -35,13 +30,31 @@ function drawRtichokeCurve(s, sel) { svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("stroke-dasharray","3,3").attr("d",line); traces.push(...a.map(d => ({...d, _color: color}))); }); + + // The canonical R Plotly performance curves use mode="markers+lines". + // Decision/reference traces remain line-only. For a single model the R + // implementation forces black; grouped reports use the supplied palette. + const isDecision = String(s.title || "").toLowerCase().includes("decision"); + const singleGroup = s.groups.length === 1; s.groups.forEach(g => { const a = s.data.filter(d => String(d.reference_group) === g); - const color = s.colors[g] || "#000"; + const color = singleGroup ? "black" : (s.colors[g] || "#000"); svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("d",line); + if (!isDecision) { + svg.append("g").selectAll("circle").data(a.filter(d => isFinite(+d.x) && isFinite(+d.y))).enter().append("circle") + .attr("cx", d => x(+d.x)).attr("cy", d => y(+d.y)).attr("r", 3) + .attr("fill", color).attr("stroke", color).attr("stroke-width", 0); + } traces.push(...a.map(d => ({...d, _color: color}))); }); + // Plotly normally suppresses the legend for these performance curves. Keep + // it available only when the report payload explicitly asks for it. + if (s.show_legend && s.groups.length > 1) { + const lg = card.append("div").attr("class", "legend"); + s.groups.forEach(g => lg.append("span").html(`${g}`)); + } + const contrast = color => { const h=String(color||"#333").replace("#",""); if(!/^[0-9a-f]{6}$/i.test(h)) return "white"; const r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16); @@ -49,7 +62,9 @@ function drawRtichokeCurve(s, sel) { }; const show = (ev,d) => tip.style("opacity",1).style("left",(ev.clientX+10)+"px").style("top",(ev.clientY+10)+"px") .style("background",d._color).style("border","1px solid "+d._color).style("color",contrast(d._color)) - .style("padding","6px 8px").style("border-radius","2px").html(String(d.text||"")); + .style("padding","6px 8px").style("border-radius","2px") + .style("font-family","Open Sans, verdana, arial, sans-serif").style("font-size","12px").style("line-height","15px") + .html(String(d.text||"")); const hide = () => tip.style("opacity",0); svg.append("rect").attr("x",m.left).attr("y",m.top).attr("width",W-m.left-m.right).attr("height",H-m.top-m.bottom).attr("fill","transparent") .on("mousemove", ev => { From 59bcea64dcb29e16a7ee074c8df8257a0404c481 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 21:46:59 +0300 Subject: [PATCH 077/153] Refine decision curve reference parity --- src/rtichoke/summary_report/curve_renderer.js | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/rtichoke/summary_report/curve_renderer.js b/src/rtichoke/summary_report/curve_renderer.js index 16305560..9ad65654 100644 --- a/src/rtichoke/summary_report/curve_renderer.js +++ b/src/rtichoke/summary_report/curve_renderer.js @@ -1,6 +1,6 @@ /* D3 renderer for performance and decision curves in the lightweight report. * Mirrors rtichoke's Plotly conventions: 600px figure, markers+lines for - * performance data, dotted references, Plotly-like hover labels, no grid. + * performance data, line-only strategy references, Plotly-like hover labels. */ function drawRtichokeCurve(s, sel) { const card = d3.select(sel); @@ -24,23 +24,34 @@ function drawRtichokeCurve(s, sel) { svg.append("text").attr("class", "axis-label").attr("x", (m.left + W-m.right)/2).attr("y", H-20).attr("text-anchor", "middle").text(s.x_label); svg.append("text").attr("class", "axis-label").attr("transform", "rotate(-90)").attr("x", -(m.top+H-m.bottom)/2).attr("y", 22).attr("text-anchor", "middle").text(s.y_label); + const isDecision = String(s.title || "").toLowerCase().includes("decision") || String(s.y_label || "").toLowerCase() === "net benefit"; + const isInterventions = String(s.y_label || "").toLowerCase().includes("interventions avoided"); + const strategyColor = g => { + const key = String(g || "").toLowerCase(); + if (key === "treat_none") return "#808080"; + if (key.startsWith("treat_all")) return s.colors[g] || "#BEBEBE"; + if (key.startsWith("random_guess")) return s.colors[g] || "#BEBEBE"; + if (key.startsWith("perfect_model")) return s.colors[g] || "#BEBEBE"; + return s.colors[g] || "#999"; + }; + const traces = []; d3.group(s.references, d => String(d.reference_group)).forEach((a,g) => { - const color = s.colors[g] || "#999"; - svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("stroke-dasharray","3,3").attr("d",line); + const color = strategyColor(g); + // R/Plotly strategy references are ordinary line traces. Do not impose a + // D3-only dotted style; the payload/color identity carries the strategy. + svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("d",line); traces.push(...a.map(d => ({...d, _color: color}))); }); - // The canonical R Plotly performance curves use mode="markers+lines". - // Decision/reference traces remain line-only. For a single model the R - // implementation forces black; grouped reports use the supplied palette. - const isDecision = String(s.title || "").toLowerCase().includes("decision"); const singleGroup = s.groups.length === 1; s.groups.forEach(g => { const a = s.data.filter(d => String(d.reference_group) === g); const color = singleGroup ? "black" : (s.colors[g] || "#000"); svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("d",line); - if (!isDecision) { + // Canonical performance curves are markers+lines. Decision and + // interventions-avoided utility curves are line traces in the report. + if (!isDecision && !isInterventions) { svg.append("g").selectAll("circle").data(a.filter(d => isFinite(+d.x) && isFinite(+d.y))).enter().append("circle") .attr("cx", d => x(+d.x)).attr("cy", d => y(+d.y)).attr("r", 3) .attr("fill", color).attr("stroke", color).attr("stroke-width", 0); @@ -48,8 +59,6 @@ function drawRtichokeCurve(s, sel) { traces.push(...a.map(d => ({...d, _color: color}))); }); - // Plotly normally suppresses the legend for these performance curves. Keep - // it available only when the report payload explicitly asks for it. if (s.show_legend && s.groups.length > 1) { const lg = card.append("div").attr("class", "legend"); s.groups.forEach(g => lg.append("span").html(`${g}`)); From 266f313d49433a0d340a6206d40729e54a8ab91b Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 21:54:44 +0300 Subject: [PATCH 078/153] Add R-style summary report layout polish --- src/rtichoke/summary_report/report_style.css | 130 +++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 src/rtichoke/summary_report/report_style.css diff --git a/src/rtichoke/summary_report/report_style.css b/src/rtichoke/summary_report/report_style.css new file mode 100644 index 00000000..04de065a --- /dev/null +++ b/src/rtichoke/summary_report/report_style.css @@ -0,0 +1,130 @@ +/* Final layout/typography layer for the lightweight summary report. + * Intentionally conservative: preserve the report structure while bringing + * spacing, tabs, cards, plots and tables closer to the R flexdashboard report. + */ +:root { + --rt-text: #333; + --rt-muted: #666; + --rt-border: #d9d9d9; + --rt-panel: #fff; + --rt-tab: #f7f7f7; +} + +html, body { + background: #fff; + color: var(--rt-text); + font-family: "Open Sans", verdana, arial, sans-serif; + font-size: 14px; + line-height: 1.45; +} + +body { margin: 0; } + +/* Keep the report visually dense like the original dashboard rather than a + * documentation page with oversized whitespace. */ +.container, .report, main { + max-width: 1280px; + margin-left: auto; + margin-right: auto; +} + +h1, h2, h3, h4, .plot-title { + font-family: "Open Sans", verdana, arial, sans-serif; + color: var(--rt-text); + font-weight: 400; +} + +h1 { font-size: 24px; margin: 14px 0 10px; } +h2 { font-size: 20px; margin: 12px 0 8px; } +h3, .plot-title { font-size: 16px; margin: 8px 0 6px; } + +/* Bootstrap/flexdashboard-like tabs. */ +.tabs, .tab-nav, [class*="tabs-nav"] { + border-bottom: 1px solid var(--rt-border); + margin: 0 0 10px; + padding-left: 0; +} +.tabs button, .tab-nav button, [class*="tabs-nav"] button { + background: transparent; + border: 1px solid transparent; + border-radius: 4px 4px 0 0; + color: #337ab7; + font: inherit; + padding: 8px 12px; + margin-bottom: -1px; +} +.tabs button.active, .tab-nav button.active, [class*="tabs-nav"] button.active { + background: #fff; + border-color: var(--rt-border) var(--rt-border) #fff; + color: #555; +} + +/* Plot panels should read as dashboard content, not floating cards. */ +.plot-card, .chart-card, .panel, .card { + background: var(--rt-panel); + border: 0; + border-radius: 0; + box-shadow: none; +} +.plot-card svg, .chart-card svg, .panel svg { + display: block; + width: min(100%, 600px); + height: auto; + margin: 0 auto; +} + +.axis text { fill: #444; font-size: 12px; } +.axis-label { fill: #444; font-size: 14px; } +.plot-title { text-align: center; } +.legend { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: 14px; + min-height: 24px; + color: #444; + font-size: 12px; +} +.legend span { display: inline-flex; align-items: center; gap: 5px; } +.legend i { width: 24px; height: 2px; display: inline-block; } + +/* Reactable-like table density. */ +table { + width: 100%; + border-collapse: collapse; + background: #fff; + font-size: 13px; +} +thead th { + background: #fafafa; + color: #333; + font-weight: 600; + border-bottom: 1px solid #cfcfcf; + padding: 8px 7px; + vertical-align: bottom; +} +tbody td { + border-bottom: 1px solid #e8e8e8; + padding: 7px; + vertical-align: middle; +} +tbody tr:hover { background: #f8f8f8; } + +/* Plotly-style hover label shared by all D3 renderers. */ +.tooltip, #tooltip { + position: fixed; + pointer-events: none; + z-index: 9999; + border-radius: 2px; + box-shadow: none; + font-family: "Open Sans", verdana, arial, sans-serif; + font-size: 12px; + line-height: 15px; +} + +@media (max-width: 760px) { + h1 { font-size: 21px; } + .tabs button, .tab-nav button, [class*="tabs-nav"] button { padding: 7px 9px; } + table { font-size: 12px; } +} From e5e67836de52bb610ed2ae39cba2e0876c9b7d34 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 21:55:00 +0300 Subject: [PATCH 079/153] Apply R-style summary report layout polish --- .../summary_report/summary_report_v2.py | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report_v2.py b/src/rtichoke/summary_report/summary_report_v2.py index 8702042e..67dbb835 100644 --- a/src/rtichoke/summary_report/summary_report_v2.py +++ b/src/rtichoke/summary_report/summary_report_v2.py @@ -1,9 +1,9 @@ """Readable integration layer for the lightweight summary report renderers. This module keeps the legacy report generator stable while the large inline -HTML template is being split into maintainable assets. It post-processes the +HTML template is being split into maintainable assets. It post-processes the legacy HTML to route performance and decision curves through the dedicated -Plotly-parity D3 renderer. +Plotly-parity D3 renderer and applies the shared R-report visual polish layer. """ from __future__ import annotations @@ -16,6 +16,10 @@ from rtichoke.summary_report.summary_report import create_summary_report as _legacy_create_summary_report +def _style_source() -> str: + return Path(__file__).with_name("report_style.css").read_text(encoding="utf-8") + + def _wire_curve_renderer(html: str) -> str: """Replace the legacy curve drawing calls with the dedicated renderer.""" renderer = curve_renderer_source() @@ -37,15 +41,26 @@ def _wire_curve_renderer(html: str) -> str: return html +def _wire_report_style(html: str) -> str: + """Append the shared visual layer without disturbing legacy CSS.""" + css = _style_source() + marker = "" + if marker not in html: + raise RuntimeError("Could not locate summary-report head element") + return html.replace(marker, f"\n{marker}", 1) + + def create_summary_report( probs: Dict[str, np.ndarray], reals: Union[np.ndarray, Dict[str, np.ndarray]], output_file: str | Path = "summary_report.html", by: float = 0.01, ) -> Path: - """Create the lightweight report using the dedicated curve renderer.""" + """Create the lightweight report using the modular parity renderers.""" out = Path(output_file) _legacy_create_summary_report(probs=probs, reals=reals, output_file=out, by=by) - html = _wire_curve_renderer(out.read_text(encoding="utf-8")) + html = out.read_text(encoding="utf-8") + html = _wire_curve_renderer(html) + html = _wire_report_style(html) out.write_text(html, encoding="utf-8") return out From 7943499ab19fb26ec0c2dc5476bb7b063ef1e6d9 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 22:01:04 +0300 Subject: [PATCH 080/153] Match calibration size used by R summary report --- .../summary_report/calibration_renderer.js | 143 +++++------------- 1 file changed, 39 insertions(+), 104 deletions(-) diff --git a/src/rtichoke/summary_report/calibration_renderer.js b/src/rtichoke/summary_report/calibration_renderer.js index 4816285c..797783bc 100644 --- a/src/rtichoke/summary_report/calibration_renderer.js +++ b/src/rtichoke/summary_report/calibration_renderer.js @@ -1,18 +1,15 @@ /* Calibration-only D3 renderer for the lightweight summary report. - * Kept separate so visual parity with the R/Plotly report can be iterated - * without touching the rest of the report template. + * Geometry follows the actual R summary-report call: size = 550. */ function calibration(type, sel) { const c = R.calibration; const card = d3.select(sel); card.selectAll("*").remove(); - // Match the 600px square Plotly calibration figure and its two-row subplot. - const W = 600, H = 600; - const X0 = 80, X1 = 580; - const MAIN_TOP = 100, MAIN_BOTTOM = 424; - const HIST_TOP = 444, HIST_BOTTOM = 525; - + const W = 550, H = 550; + const X0 = 60, X1 = 540; + const MAIN_TOP = 55, MAIN_BOTTOM = 409.9; + const HIST_TOP = 428.1, HIST_BOTTOM = 510; const x = d3.scaleLinear().domain(c.ranges.xaxis).range([X0, X1]); const y = d3.scaleLinear().domain(c.ranges.yaxis).range([MAIN_BOTTOM, MAIN_TOP]); const histMax = d3.max(c.histogram, d => +d.counts) || 1; @@ -27,118 +24,56 @@ function calibration(type, sel) { }; const showTip = (ev, html, color) => { const bg = color || "#333"; - tip.style("opacity", 1) - .style("left", (ev.clientX + 10) + "px") - .style("top", (ev.clientY + 10) + "px") - .style("background", bg) - .style("border", "1px solid " + bg) - .style("border-radius", "2px") - .style("box-shadow", "none") - .style("padding", "6px 8px") - .style("font-family", "Open Sans, verdana, arial, sans-serif") - .style("font-size", "12px") - .style("line-height", "15px") - .style("color", contrastText(bg)) - .html(String(html || "")); + tip.style("opacity", 1).style("left", (ev.clientX + 10) + "px").style("top", (ev.clientY + 10) + "px") + .style("background", bg).style("border", "1px solid " + bg).style("border-radius", "2px") + .style("box-shadow", "none").style("padding", "6px 8px") + .style("font-family", "Open Sans, verdana, arial, sans-serif").style("font-size", "12px") + .style("line-height", "15px").style("color", contrastText(bg)).html(String(html || "")); }; const hideTip = () => tip.style("opacity", 0); const groupOf = d => String(d.reference_group || ""); const hoverColor = d => groupOf(d) === "reference_line" ? "#bebebe" : (c.colors[groupOf(d)] || "#777"); const defs = svg.append("defs"); - defs.append("clipPath").attr("id", `main-${type}`).append("rect") - .attr("x", X0).attr("y", MAIN_TOP) - .attr("width", X1 - X0).attr("height", MAIN_BOTTOM - MAIN_TOP); - defs.append("clipPath").attr("id", `hist-${type}`).append("rect") - .attr("x", X0).attr("y", HIST_TOP) - .attr("width", X1 - X0).attr("height", HIST_BOTTOM - HIST_TOP); + defs.append("clipPath").attr("id", `main-${type}`).append("rect").attr("x", X0).attr("y", MAIN_TOP).attr("width", X1-X0).attr("height", MAIN_BOTTOM-MAIN_TOP); + defs.append("clipPath").attr("id", `hist-${type}`).append("rect").attr("x", X0).attr("y", HIST_TOP).attr("width", X1-X0).attr("height", HIST_BOTTOM-HIST_TOP); - // R Plotly: horizontal, centered legend at x=.5/y=1.1. Discrete traces - // show markers+lines; smooth traces show lines only. if (c.groups.length > 1) { - const lg = svg.append("g") - .attr("font-family", "Open Sans, verdana, arial, sans-serif") - .attr("font-size", 12); - const itemW = 110, total = itemW * c.groups.length, start = W / 2 - total / 2; - c.groups.forEach((g, i) => { - const q = lg.append("g").attr("transform", `translate(${start + i * itemW},72)`); - q.append("line").attr("x1", 5).attr("x2", 35) - .attr("stroke", c.colors[g]).attr("stroke-width", 2); - if (type === "discrete") { - q.append("circle").attr("cx", 20).attr("cy", 0).attr("r", 5) - .attr("fill", c.colors[g]).attr("stroke", c.colors[g]); - } - q.append("text").attr("x", 42).attr("y", 4).attr("fill", "#444").text(g); + const lg = svg.append("g").attr("font-family", "Open Sans, verdana, arial, sans-serif").attr("font-size", 12); + const itemW = 93, total = itemW*c.groups.length, start = 300-total/2; + c.groups.forEach((g,i) => { + const q=lg.append("g").attr("transform",`translate(${start+i*itemW},24)`); + q.append("line").attr("x1",5).attr("x2",35).attr("stroke",c.colors[g]).attr("stroke-width",2); + if(type === "discrete") q.append("circle").attr("cx",20).attr("cy",0).attr("r",5).attr("fill",c.colors[g]).attr("stroke-width",0); + q.append("text").attr("x",40).attr("y",4).attr("fill","#444").text(g); }); } - const styleAxis = axis => { - axis.attr("font-family", "Open Sans, verdana, arial, sans-serif").attr("font-size", 12).attr("color", "#444"); - axis.select(".domain").attr("stroke", "#444").attr("stroke-width", 1); - axis.selectAll(".tick line").attr("stroke", "#444"); - }; - const mainYAxis = svg.append("g").attr("class", "axis").attr("transform", `translate(${X0},0)`).call(d3.axisLeft(y).ticks(5)); - const histYAxis = svg.append("g").attr("class", "axis").attr("transform", `translate(${X0},0)`).call(d3.axisLeft(yHist).ticks(4)); - const xAxis = svg.append("g").attr("class", "axis").attr("transform", `translate(0,${HIST_BOTTOM})`).call(d3.axisBottom(x).ticks(5)); - styleAxis(mainYAxis); styleAxis(histYAxis); styleAxis(xAxis); - - svg.append("text").attr("class", "axis-label") - .attr("font-family", "Open Sans, verdana, arial, sans-serif").attr("font-size", 14).attr("fill", "#444") - .attr("x", (X0 + X1) / 2).attr("y", 580).attr("text-anchor", "middle").text("Predicted"); - svg.append("text").attr("class", "axis-label") - .attr("font-family", "Open Sans, verdana, arial, sans-serif").attr("font-size", 14).attr("fill", "#444") - .attr("transform", "rotate(-90)").attr("x", -(MAIN_TOP + MAIN_BOTTOM) / 2).attr("y", 24) - .attr("text-anchor", "middle").text("Observed"); - - const line = d3.line().defined(d => isFinite(+d.x) && isFinite(+d.y)) - .x(d => x(+d.x)).y(d => y(+d.y)); - const main = svg.append("g").attr("clip-path", `url(#main-${type})`); - main.append("path").datum(c.reference).attr("fill", "none") - .attr("stroke", "#bebebe").attr("stroke-width", 2) - .attr("stroke-dasharray", "3,3").attr("d", line); + const styleAxis = axis => { axis.attr("font-family","Open Sans, verdana, arial, sans-serif").attr("font-size",12).attr("color","#444"); axis.select(".domain").attr("stroke","#444"); axis.selectAll(".tick line").attr("stroke","#444"); }; + const ay=svg.append("g").attr("class","axis").attr("transform",`translate(${X0},0)`).call(d3.axisLeft(y)); + const ah=svg.append("g").attr("class","axis").attr("transform",`translate(${X0},0)`).call(d3.axisLeft(yHist).ticks(5)); + const ax=svg.append("g").attr("class","axis").attr("transform",`translate(0,${HIST_BOTTOM})`).call(d3.axisBottom(x).ticks(5)); + styleAxis(ay); styleAxis(ah); styleAxis(ax); + svg.append("text").attr("class","axis-label").attr("x",(X0+X1)/2).attr("y",548).attr("text-anchor","middle").text("Predicted"); + svg.append("text").attr("class","axis-label").attr("transform","rotate(-90)").attr("x",-(MAIN_TOP+MAIN_BOTTOM)/2).attr("y",18).attr("text-anchor","middle").text("Observed"); - const dat = type === "smooth" ? c.smooth : c.deciles; + const line=d3.line().defined(d=>isFinite(+d.x)&&isFinite(+d.y)).x(d=>x(+d.x)).y(d=>y(+d.y)); + const main=svg.append("g").attr("clip-path",`url(#main-${type})`); + main.append("path").datum(c.reference).attr("fill","none").attr("stroke","#bebebe").attr("stroke-width",2).attr("stroke-dasharray","3,3").attr("d",line); + const dat=type === "smooth" ? c.smooth : c.deciles; c.groups.forEach(g => { - const a = dat.filter(d => String(d.reference_group) === g); - main.append("path").datum(a).attr("fill", "none") - .attr("stroke", c.colors[g]).attr("stroke-width", 2).attr("d", line); - if (type === "discrete") { - main.selectAll(null).data(a).enter().append("circle") - .attr("cx", d => x(+d.x)).attr("cy", d => y(+d.y)) - .attr("r", 5).attr("fill", c.colors[g]) - .attr("stroke", c.colors[g]).attr("stroke-width", 0) - .attr("shape-rendering", "geometricPrecision"); - } + const a=dat.filter(d=>String(d.reference_group)===g); + main.append("path").datum(a).attr("fill","none").attr("stroke",c.colors[g]).attr("stroke-width",2).attr("d",line); + if(type === "discrete") main.selectAll(null).data(a).enter().append("circle").attr("cx",d=>x(+d.x)).attr("cy",d=>y(+d.y)).attr("r",5).attr("fill",c.colors[g]).attr("stroke-width",0).attr("shape-rendering","geometricPrecision"); }); - // R uses 0.01-wide overlaid bars and opacity 1 / number of groups. - const hist = svg.append("g").attr("clip-path", `url(#hist-${type})`); - const opacity = 1 / Math.max(1, c.groups.length); + const hist=svg.append("g").attr("clip-path",`url(#hist-${type})`), opacity=1/Math.max(1,c.groups.length); c.histogram.forEach(d => { - const mid = +d.mids, left = x(mid - .005), right = x(mid + .005); - hist.append("rect") - .attr("x", left).attr("width", Math.max(0, right - left)) - .attr("y", yHist(+d.counts)).attr("height", HIST_BOTTOM - yHist(+d.counts)) - .attr("fill", c.colors[String(d.reference_group)] || "#777") - .attr("opacity", opacity).attr("stroke", "none") - .on("mousemove", ev => showTip(ev, d.text, hoverColor(d))) - .on("mouseleave", hideTip); + const mid=+d.mids,left=x(mid-.005),right=x(mid+.005); + hist.append("rect").attr("x",left).attr("width",Math.max(0,right-left)).attr("y",yHist(+d.counts)).attr("height",HIST_BOTTOM-yHist(+d.counts)).attr("fill",c.colors[String(d.reference_group)]||"#777").attr("opacity",opacity).attr("stroke","none").on("mousemove",ev=>showTip(ev,d.text,hoverColor(d))).on("mouseleave",hideTip); }); - const hoverData = dat.concat(c.reference); - svg.append("rect").attr("x", X0).attr("y", MAIN_TOP) - .attr("width", X1 - X0).attr("height", MAIN_BOTTOM - MAIN_TOP) - .attr("fill", "transparent") - .on("mousemove", ev => { - const [mx, my] = d3.pointer(ev); - let best = null, dist = Infinity; - hoverData.forEach(d => { - if (!isFinite(+d.x) || !isFinite(+d.y)) return; - const dd = (x(+d.x) - mx) ** 2 + (y(+d.y) - my) ** 2; - if (dd < dist) { dist = dd; best = d; } - }); - if (best && dist < 625) showTip(ev, best.text, hoverColor(best)); - else hideTip(); - }) - .on("mouseleave", hideTip); + const hoverData=dat.concat(c.reference); + svg.append("rect").attr("x",X0).attr("y",MAIN_TOP).attr("width",X1-X0).attr("height",MAIN_BOTTOM-MAIN_TOP).attr("fill","transparent") + .on("mousemove",ev=>{const [mx,my]=d3.pointer(ev);let best=null,dist=Infinity;hoverData.forEach(d=>{if(!isFinite(+d.x)||!isFinite(+d.y))return;const dd=(x(+d.x)-mx)**2+(y(+d.y)-my)**2;if(dd Date: Wed, 19 Aug 2026 22:01:25 +0300 Subject: [PATCH 081/153] Match curve size used by R summary report --- src/rtichoke/summary_report/curve_renderer.js | 98 ++++--------------- 1 file changed, 18 insertions(+), 80 deletions(-) diff --git a/src/rtichoke/summary_report/curve_renderer.js b/src/rtichoke/summary_report/curve_renderer.js index 9ad65654..80c67001 100644 --- a/src/rtichoke/summary_report/curve_renderer.js +++ b/src/rtichoke/summary_report/curve_renderer.js @@ -1,84 +1,22 @@ /* D3 renderer for performance and decision curves in the lightweight report. - * Mirrors rtichoke's Plotly conventions: 600px figure, markers+lines for - * performance data, line-only strategy references, Plotly-like hover labels. + * The R summary-report template explicitly renders these figures at size=500. */ function drawRtichokeCurve(s, sel) { - const card = d3.select(sel); - card.selectAll("*").remove(); - card.append("div").attr("class", "plot-title").text(s.title); - - const W = 600, H = 600, m = {top: 45, right: 35, bottom: 75, left: 75}; - const svg = card.append("svg").attr("viewBox", `0 0 ${W} ${H}`); - const x = d3.scaleLinear().domain(s.x_range).range([m.left, W - m.right]); - const y = d3.scaleLinear().domain(s.y_range).range([H - m.bottom, m.top]); - const line = d3.line().defined(d => isFinite(+d.x) && isFinite(+d.y)).x(d => x(+d.x)).y(d => y(+d.y)); - - const styleAxis = axis => { - axis.attr("font-family", "Open Sans, verdana, arial, sans-serif").attr("font-size", 12).attr("color", "#444"); - axis.select(".domain").attr("stroke", "#444"); - axis.selectAll(".tick line").attr("stroke", "#444"); - }; - const xa = svg.append("g").attr("class", "axis").attr("transform", `translate(0,${H-m.bottom})`).call(d3.axisBottom(x).ticks(6)); - const ya = svg.append("g").attr("class", "axis").attr("transform", `translate(${m.left},0)`).call(d3.axisLeft(y).ticks(6)); - styleAxis(xa); styleAxis(ya); - svg.append("text").attr("class", "axis-label").attr("x", (m.left + W-m.right)/2).attr("y", H-20).attr("text-anchor", "middle").text(s.x_label); - svg.append("text").attr("class", "axis-label").attr("transform", "rotate(-90)").attr("x", -(m.top+H-m.bottom)/2).attr("y", 22).attr("text-anchor", "middle").text(s.y_label); - - const isDecision = String(s.title || "").toLowerCase().includes("decision") || String(s.y_label || "").toLowerCase() === "net benefit"; - const isInterventions = String(s.y_label || "").toLowerCase().includes("interventions avoided"); - const strategyColor = g => { - const key = String(g || "").toLowerCase(); - if (key === "treat_none") return "#808080"; - if (key.startsWith("treat_all")) return s.colors[g] || "#BEBEBE"; - if (key.startsWith("random_guess")) return s.colors[g] || "#BEBEBE"; - if (key.startsWith("perfect_model")) return s.colors[g] || "#BEBEBE"; - return s.colors[g] || "#999"; - }; - - const traces = []; - d3.group(s.references, d => String(d.reference_group)).forEach((a,g) => { - const color = strategyColor(g); - // R/Plotly strategy references are ordinary line traces. Do not impose a - // D3-only dotted style; the payload/color identity carries the strategy. - svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("d",line); - traces.push(...a.map(d => ({...d, _color: color}))); - }); - - const singleGroup = s.groups.length === 1; - s.groups.forEach(g => { - const a = s.data.filter(d => String(d.reference_group) === g); - const color = singleGroup ? "black" : (s.colors[g] || "#000"); - svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("d",line); - // Canonical performance curves are markers+lines. Decision and - // interventions-avoided utility curves are line traces in the report. - if (!isDecision && !isInterventions) { - svg.append("g").selectAll("circle").data(a.filter(d => isFinite(+d.x) && isFinite(+d.y))).enter().append("circle") - .attr("cx", d => x(+d.x)).attr("cy", d => y(+d.y)).attr("r", 3) - .attr("fill", color).attr("stroke", color).attr("stroke-width", 0); - } - traces.push(...a.map(d => ({...d, _color: color}))); - }); - - if (s.show_legend && s.groups.length > 1) { - const lg = card.append("div").attr("class", "legend"); - s.groups.forEach(g => lg.append("span").html(`${g}`)); - } - - const contrast = color => { - const h=String(color||"#333").replace("#",""); if(!/^[0-9a-f]{6}$/i.test(h)) return "white"; - const r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16); - return (.299*r+.587*g+.114*b)>170 ? "#222" : "white"; - }; - const show = (ev,d) => tip.style("opacity",1).style("left",(ev.clientX+10)+"px").style("top",(ev.clientY+10)+"px") - .style("background",d._color).style("border","1px solid "+d._color).style("color",contrast(d._color)) - .style("padding","6px 8px").style("border-radius","2px") - .style("font-family","Open Sans, verdana, arial, sans-serif").style("font-size","12px").style("line-height","15px") - .html(String(d.text||"")); - const hide = () => tip.style("opacity",0); - svg.append("rect").attr("x",m.left).attr("y",m.top).attr("width",W-m.left-m.right).attr("height",H-m.top-m.bottom).attr("fill","transparent") - .on("mousemove", ev => { - const [mx,my]=d3.pointer(ev); let best=null,dist=Infinity; - traces.forEach(d=>{if(!isFinite(+d.x)||!isFinite(+d.y))return;const dd=(x(+d.x)-mx)**2+(y(+d.y)-my)**2;if(ddisFinite(+d.x)&&isFinite(+d.y)).x(d=>x(+d.x)).y(d=>y(+d.y)); + const styleAxis=a=>{a.attr("font-family","Open Sans, verdana, arial, sans-serif").attr("font-size",12).attr("color","#444");a.select(".domain").attr("stroke","#444");a.selectAll(".tick line").attr("stroke","#444")}; + const xa=svg.append("g").attr("class","axis").attr("transform",`translate(0,${H-m.bottom})`).call(d3.axisBottom(x).ticks(6)),ya=svg.append("g").attr("class","axis").attr("transform",`translate(${m.left},0)`).call(d3.axisLeft(y).ticks(6)); styleAxis(xa);styleAxis(ya); + svg.append("text").attr("class","axis-label").attr("x",(m.left+W-m.right)/2).attr("y",H-18).attr("text-anchor","middle").text(s.x_label); + svg.append("text").attr("class","axis-label").attr("transform","rotate(-90)").attr("x",-(m.top+H-m.bottom)/2).attr("y",20).attr("text-anchor","middle").text(s.y_label); + const isDecision=String(s.title||"").toLowerCase().includes("decision")||String(s.y_label||"").toLowerCase()==="net benefit",isInterventions=String(s.y_label||"").toLowerCase().includes("interventions avoided"); + const strategyColor=g=>{const k=String(g||"").toLowerCase();if(k==="treat_none")return "#808080";return s.colors[g]||"#BEBEBE"}; + const traces=[]; + d3.group(s.references,d=>String(d.reference_group)).forEach((a,g)=>{const color=strategyColor(g);svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("d",line);traces.push(...a.map(d=>({...d,_color:color})))}); + const single=s.groups.length===1; + s.groups.forEach(g=>{const a=s.data.filter(d=>String(d.reference_group)===g),color=single?"black":(s.colors[g]||"#000");svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("d",line);if(!isDecision&&!isInterventions)svg.append("g").selectAll("circle").data(a.filter(d=>isFinite(+d.x)&&isFinite(+d.y))).enter().append("circle").attr("cx",d=>x(+d.x)).attr("cy",d=>y(+d.y)).attr("r",3).attr("fill",color).attr("stroke-width",0);traces.push(...a.map(d=>({...d,_color:color})))}); + const contrast=color=>{const h=String(color||"#333").replace("#","");if(!/^[0-9a-f]{6}$/i.test(h))return "white";const r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16);return(.299*r+.587*g+.114*b)>170?"#222":"white"}; + const show=(ev,d)=>tip.style("opacity",1).style("left",(ev.clientX+10)+"px").style("top",(ev.clientY+10)+"px").style("background",d._color).style("border","1px solid "+d._color).style("color",contrast(d._color)).style("padding","6px 8px").style("border-radius","2px").style("font-family","Open Sans, verdana, arial, sans-serif").style("font-size","12px").style("line-height","15px").html(String(d.text||"")),hide=()=>tip.style("opacity",0); + svg.append("rect").attr("x",m.left).attr("y",m.top).attr("width",W-m.left-m.right).attr("height",H-m.top-m.bottom).attr("fill","transparent").on("mousemove",ev=>{const[mx,my]=d3.pointer(ev);let best=null,dist=Infinity;traces.forEach(d=>{if(!isFinite(+d.x)||!isFinite(+d.y))return;const dd=(x(+d.x)-mx)**2+(y(+d.y)-my)**2;if(dd Date: Wed, 19 Aug 2026 22:08:59 +0300 Subject: [PATCH 082/153] Align calibration test with R report geometry --- tests/test_calibration_renderer_asset.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_calibration_renderer_asset.py b/tests/test_calibration_renderer_asset.py index 35bdf5a7..9d20f2ad 100644 --- a/tests/test_calibration_renderer_asset.py +++ b/tests/test_calibration_renderer_asset.py @@ -4,11 +4,10 @@ def test_calibration_renderer_asset_is_available(): source = calibration_renderer_source() assert "function calibration(type, sel)" in source - # Keep this test aligned with the canonical 600px Plotly geometry used by - # the parity renderer rather than the earlier hand-tuned 550px prototype. - assert "const W = 600, H = 600" in source - assert "MAIN_TOP = 100, MAIN_BOTTOM = 424" in source - assert "HIST_TOP = 444, HIST_BOTTOM = 525" in source + # Match the geometry used by the actual R summary-report calibration call. + assert "const W = 550, H = 550" in source + assert "MAIN_TOP = 55, MAIN_BOTTOM = 409.9" in source + assert "HIST_TOP = 428.1, HIST_BOTTOM = 510" in source assert 'text("Predicted")' in source assert 'text("Observed")' in source assert "showTip" in source From 9a1bc4f3bace1eedfc8b448c030180587e853b08 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 22:12:56 +0300 Subject: [PATCH 083/153] Match summary report structure to R reference --- src/rtichoke/summary_report/report_style.css | 106 +++++++++++-------- 1 file changed, 62 insertions(+), 44 deletions(-) diff --git a/src/rtichoke/summary_report/report_style.css b/src/rtichoke/summary_report/report_style.css index 04de065a..eb2ca50e 100644 --- a/src/rtichoke/summary_report/report_style.css +++ b/src/rtichoke/summary_report/report_style.css @@ -1,81 +1,96 @@ /* Final layout/typography layer for the lightweight summary report. - * Intentionally conservative: preserve the report structure while bringing - * spacing, tabs, cards, plots and tables closer to the R flexdashboard report. + * Match the actual R summary-report HTML structure, not standalone widgets. */ :root { --rt-text: #333; --rt-muted: #666; - --rt-border: #d9d9d9; + --rt-border: #ddd; --rt-panel: #fff; - --rt-tab: #f7f7f7; } html, body { background: #fff; color: var(--rt-text); - font-family: "Open Sans", verdana, arial, sans-serif; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 14px; - line-height: 1.45; + line-height: 1.42857143; } - body { margin: 0; } -/* Keep the report visually dense like the original dashboard rather than a - * documentation page with oversized whitespace. */ -.container, .report, main { - max-width: 1280px; +/* R markdown reference explicitly uses a 1040px main container. */ +.main-container, .container, .report, main { + max-width: 1040px; margin-left: auto; margin-right: auto; } h1, h2, h3, h4, .plot-title { - font-family: "Open Sans", verdana, arial, sans-serif; color: var(--rt-text); + font-family: inherit; font-weight: 400; + line-height: 1.1; } +h1 { font-size: 36px; margin-top: 20px; margin-bottom: 10px; } +h2 { font-size: 30px; margin-top: 20px; margin-bottom: 10px; } +h3, .plot-title { font-size: 24px; margin-top: 20px; margin-bottom: 10px; } -h1 { font-size: 24px; margin: 14px 0 10px; } -h2 { font-size: 20px; margin: 12px 0 8px; } -h3, .plot-title { font-size: 16px; margin: 8px 0 6px; } +/* Section rhythm follows the R Markdown/bootstrap output. */ +#calibration, #discrimination, #utility-decision-curve, #performance-table { + margin-top: 20px; + margin-bottom: 30px; +} -/* Bootstrap/flexdashboard-like tabs. */ -.tabs, .tab-nav, [class*="tabs-nav"] { - border-bottom: 1px solid var(--rt-border); - margin: 0 0 10px; +/* Bootstrap 3 nav-tabs, matching R's tabset output. */ +.nav-tabs { + display: flex; + flex-wrap: wrap; + gap: 0; + list-style: none; padding-left: 0; + margin: 0 0 20px; + border-bottom: 1px solid var(--rt-border); } -.tabs button, .tab-nav button, [class*="tabs-nav"] button { +.nav-tabs button { + position: relative; + display: block; + padding: 10px 15px; + margin: 0 2px -1px 0; + color: #337ab7; background: transparent; border: 1px solid transparent; border-radius: 4px 4px 0 0; - color: #337ab7; font: inherit; - padding: 8px 12px; - margin-bottom: -1px; + line-height: 1.42857143; + cursor: pointer; } -.tabs button.active, .tab-nav button.active, [class*="tabs-nav"] button.active { - background: #fff; - border-color: var(--rt-border) var(--rt-border) #fff; +.nav-tabs button:hover { + background: #eee; + border-color: #eee #eee var(--rt-border); +} +.nav-tabs button.active { color: #555; + background: #fff; + border: 1px solid var(--rt-border); + border-bottom-color: transparent; + cursor: default; } -/* Plot panels should read as dashboard content, not floating cards. */ -.plot-card, .chart-card, .panel, .card { - background: var(--rt-panel); - border: 0; - border-radius: 0; - box-shadow: none; -} -.plot-card svg, .chart-card svg, .panel svg { +.panel { background: var(--rt-panel); border: 0; border-radius: 0; box-shadow: none; } +.panel:not(.active) { display: none; } +.chart, .panel { min-width: 0; } + +/* Respect the actual report-call sizes: calibration 550, other curves 500. */ +#calibration svg { display: block; width: min(100%, 550px); height: auto; margin: 0 auto; } +#discrimination svg, #utility-decision-curve svg { display: block; - width: min(100%, 600px); + width: min(100%, 500px); height: auto; margin: 0 auto; } .axis text { fill: #444; font-size: 12px; } .axis-label { fill: #444; font-size: 14px; } -.plot-title { text-align: center; } +.plot-title { text-align: left; } .legend { display: flex; justify-content: center; @@ -89,7 +104,7 @@ h3, .plot-title { font-size: 16px; margin: 8px 0 6px; } .legend span { display: inline-flex; align-items: center; gap: 5px; } .legend i { width: 24px; height: 2px; display: inline-block; } -/* Reactable-like table density. */ +/* Reactable-like density for the lightweight table replacement. */ table { width: 100%; border-collapse: collapse; @@ -97,19 +112,19 @@ table { font-size: 13px; } thead th { - background: #fafafa; + background: #fff; color: #333; font-weight: 600; - border-bottom: 1px solid #cfcfcf; - padding: 8px 7px; + border-bottom: 1px solid #ddd; + padding: 7px; vertical-align: bottom; } tbody td { - border-bottom: 1px solid #e8e8e8; + border-bottom: 1px solid #eee; padding: 7px; vertical-align: middle; } -tbody tr:hover { background: #f8f8f8; } +tbody tr:hover { background: #f5f5f5; } /* Plotly-style hover label shared by all D3 renderers. */ .tooltip, #tooltip { @@ -124,7 +139,10 @@ tbody tr:hover { background: #f8f8f8; } } @media (max-width: 760px) { - h1 { font-size: 21px; } - .tabs button, .tab-nav button, [class*="tabs-nav"] button { padding: 7px 9px; } + .main-container { padding-left: 15px; padding-right: 15px; } + h1 { font-size: 30px; } + h2 { font-size: 26px; } + h3, .plot-title { font-size: 20px; } + .nav-tabs button { padding: 8px 10px; } table { font-size: 12px; } } From 145cefdaf03106fcd23b4bb91f30e554140c55a2 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 23:22:32 +0300 Subject: [PATCH 084/153] Publish summary report as direct PR preview --- .github/workflows/summary-report-preview.yml | 35 ++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/workflows/summary-report-preview.yml b/.github/workflows/summary-report-preview.yml index 2148e568..b891a3d2 100644 --- a/.github/workflows/summary-report-preview.yml +++ b/.github/workflows/summary-report-preview.yml @@ -11,7 +11,7 @@ on: - '.github/workflows/summary-report-preview.yml' permissions: - contents: read + contents: write concurrency: group: summary-report-preview-${{ github.event.pull_request.number }} @@ -37,7 +37,38 @@ jobs: uv run python examples/summary_report_demo.py test -s summary-report-demo.html - - name: Upload summary report preview + - name: Publish direct PR preview + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin gh-pages || true + if git show-ref --verify --quiet refs/remotes/origin/gh-pages; then + git worktree add /tmp/gh-pages origin/gh-pages + else + git worktree add --detach /tmp/gh-pages + cd /tmp/gh-pages + git checkout --orphan gh-pages + git rm -rf . || true + fi + cd /tmp/gh-pages + mkdir -p "pr-preview/pr-${PR_NUMBER}" + cp "${GITHUB_WORKSPACE}/summary-report-demo.html" "pr-preview/pr-${PR_NUMBER}/summary-report.html" + touch .nojekyll + git add .nojekyll "pr-preview/pr-${PR_NUMBER}/summary-report.html" + if git diff --cached --quiet; then + echo "Preview unchanged" + else + git commit -m "Update summary report preview for PR #${PR_NUMBER}" + git push origin HEAD:gh-pages + fi + echo "### Summary report preview" >> "$GITHUB_STEP_SUMMARY" + echo "https://${GITHUB_REPOSITORY_OWNER}.github.io/${GITHUB_REPOSITORY#*/}/pr-preview/pr-${PR_NUMBER}/summary-report.html" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload fallback artifact uses: actions/upload-artifact@v4 with: name: summary-report-preview-pr-${{ github.event.pull_request.number }} From 2410cf475be25c2a37eb169214f23ec91fcf666c Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 23:31:58 +0300 Subject: [PATCH 085/153] Add R-style report navigation and cheat sheet --- .../summary_report/summary_report_v2.py | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report_v2.py b/src/rtichoke/summary_report/summary_report_v2.py index 67dbb835..b673b712 100644 --- a/src/rtichoke/summary_report/summary_report_v2.py +++ b/src/rtichoke/summary_report/summary_report_v2.py @@ -26,7 +26,6 @@ def _wire_curve_renderer(html: str) -> str: marker = "function curveTabs(specs,nav,chart,strat)" if marker not in html: raise RuntimeError("Could not locate summary-report curve integration point") - html = html.replace(marker, renderer + "\n" + marker, 1) html = html.replace( "draw(s,chart,strat)}}));draw(specs[0],chart,strat)", @@ -42,7 +41,6 @@ def _wire_curve_renderer(html: str) -> str: def _wire_report_style(html: str) -> str: - """Append the shared visual layer without disturbing legacy CSS.""" css = _style_source() marker = "" if marker not in html: @@ -50,6 +48,42 @@ def _wire_report_style(html: str) -> str: return html.replace(marker, f"\n{marker}", 1) +def _wire_r_report_chrome(html: str) -> str: + """Add the lightweight equivalents of the R Markdown TOC and cheat sheet.""" + # The R reference starts with a compact TOC and a collapsed metric cheat + # sheet. Keep these semantic and dependency-free rather than importing the + # Bootstrap/Reactable runtime that accounts for much of the R file weight. + toc = """ +
Performance Metrics Cheat Sheet + + +
Predicted PositivePredicted Negative
Real PositiveTPFN
Real NegativeFPTN
+
""" + # Insert immediately before the first report section when possible. + for marker in ('
Calibration<": ' id="calibration">Calibration<', + ">Discrimination<": ' id="discrimination">Discrimination<', + ">Utility<": ' id="utility">Utility<', + ">Performance Table<": ' id="performance-table">Performance Table<', + } + for old, new in replacements.items(): + if old in html and f'id="{new.split("id=\"")[1].split("\"")[0]}"' not in html: + html = html.replace(old, new, 1) + return html + + def create_summary_report( probs: Dict[str, np.ndarray], reals: Union[np.ndarray, Dict[str, np.ndarray]], @@ -61,6 +95,7 @@ def create_summary_report( _legacy_create_summary_report(probs=probs, reals=reals, output_file=out, by=by) html = out.read_text(encoding="utf-8") html = _wire_curve_renderer(html) + html = _wire_r_report_chrome(html) html = _wire_report_style(html) out.write_text(html, encoding="utf-8") return out From 1b48ade516421a4eeaf5dc02fb8500dba911e100 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 23:32:20 +0300 Subject: [PATCH 086/153] Style R report TOC and metric cheat sheet --- src/rtichoke/summary_report/report_style.css | 182 +++++-------------- 1 file changed, 41 insertions(+), 141 deletions(-) diff --git a/src/rtichoke/summary_report/report_style.css b/src/rtichoke/summary_report/report_style.css index eb2ca50e..8a2eac51 100644 --- a/src/rtichoke/summary_report/report_style.css +++ b/src/rtichoke/summary_report/report_style.css @@ -1,148 +1,48 @@ /* Final layout/typography layer for the lightweight summary report. * Match the actual R summary-report HTML structure, not standalone widgets. */ -:root { - --rt-text: #333; - --rt-muted: #666; - --rt-border: #ddd; - --rt-panel: #fff; -} - -html, body { - background: #fff; - color: var(--rt-text); - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 14px; - line-height: 1.42857143; -} -body { margin: 0; } - -/* R markdown reference explicitly uses a 1040px main container. */ -.main-container, .container, .report, main { - max-width: 1040px; - margin-left: auto; - margin-right: auto; -} - -h1, h2, h3, h4, .plot-title { - color: var(--rt-text); - font-family: inherit; - font-weight: 400; - line-height: 1.1; -} -h1 { font-size: 36px; margin-top: 20px; margin-bottom: 10px; } -h2 { font-size: 30px; margin-top: 20px; margin-bottom: 10px; } -h3, .plot-title { font-size: 24px; margin-top: 20px; margin-bottom: 10px; } - -/* Section rhythm follows the R Markdown/bootstrap output. */ -#calibration, #discrimination, #utility-decision-curve, #performance-table { - margin-top: 20px; - margin-bottom: 30px; -} +:root { --rt-text:#333; --rt-muted:#666; --rt-border:#ddd; --rt-panel:#fff; } +html,body { background:#fff; color:var(--rt-text); font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; font-size:14px; line-height:1.42857143; } +body { margin:0; } +.main-container,.container,.report,main { max-width:1040px; margin-left:auto; margin-right:auto; } +h1,h2,h3,h4,.plot-title { color:var(--rt-text); font-family:inherit; font-weight:400; line-height:1.1; } +h1 { font-size:36px; margin:20px 0 10px; } h2 { font-size:30px; margin:20px 0 10px; } h3,.plot-title { font-size:24px; margin:20px 0 10px; } +#calibration,#discrimination,#utility,#utility-decision-curve,#performance-table { margin-top:20px; margin-bottom:30px; } + +/* R Markdown-style contents block. */ +.rt-toc { margin:0 0 20px; } +.rt-toc ul { margin-top:0; margin-bottom:10px; padding-left:20px; } +.rt-toc a { color:#337ab7; text-decoration:none; } +.rt-toc a:hover { color:#23527c; text-decoration:underline; } + +/* Dependency-free equivalent of the R report's collapsed Reactable cheat sheet. */ +.metric-cheat-sheet { margin:0 0 20px; } +.metric-cheat-sheet > summary { cursor:pointer; display:list-item; font-size:14px; } +.metric-cheat-sheet > summary::marker { color:#555; } +.metric-cheat-sheet table { width:310px; margin-top:18px; table-layout:fixed; } +.metric-cheat-sheet th,.metric-cheat-sheet td { text-align:center; border-bottom:0; padding:7px; } +.metric-cheat-sheet th:first-child { width:110px; text-align:left; font-weight:400; } +.metric-cheat-sheet thead th { font-weight:400; background:#fff; } +.metric-cheat-sheet td { width:100px; font-weight:600; } +.metric-cheat-sheet .cm-tp,.metric-cheat-sheet .cm-tn { background:lightgreen; } +.metric-cheat-sheet .cm-fp,.metric-cheat-sheet .cm-fn { background:pink; } /* Bootstrap 3 nav-tabs, matching R's tabset output. */ -.nav-tabs { - display: flex; - flex-wrap: wrap; - gap: 0; - list-style: none; - padding-left: 0; - margin: 0 0 20px; - border-bottom: 1px solid var(--rt-border); -} -.nav-tabs button { - position: relative; - display: block; - padding: 10px 15px; - margin: 0 2px -1px 0; - color: #337ab7; - background: transparent; - border: 1px solid transparent; - border-radius: 4px 4px 0 0; - font: inherit; - line-height: 1.42857143; - cursor: pointer; -} -.nav-tabs button:hover { - background: #eee; - border-color: #eee #eee var(--rt-border); -} -.nav-tabs button.active { - color: #555; - background: #fff; - border: 1px solid var(--rt-border); - border-bottom-color: transparent; - cursor: default; -} - -.panel { background: var(--rt-panel); border: 0; border-radius: 0; box-shadow: none; } -.panel:not(.active) { display: none; } -.chart, .panel { min-width: 0; } +.nav-tabs { display:flex; flex-wrap:wrap; gap:0; list-style:none; padding-left:0; margin:0 0 20px; border-bottom:1px solid var(--rt-border); } +.nav-tabs button { position:relative; display:block; padding:10px 15px; margin:0 2px -1px 0; color:#337ab7; background:transparent; border:1px solid transparent; border-radius:4px 4px 0 0; font:inherit; line-height:1.42857143; cursor:pointer; } +.nav-tabs button:hover { background:#eee; border-color:#eee #eee var(--rt-border); } +.nav-tabs button.active { color:#555; background:#fff; border:1px solid var(--rt-border); border-bottom-color:transparent; cursor:default; } +.panel { background:var(--rt-panel); border:0; border-radius:0; box-shadow:none; } .panel:not(.active) { display:none; } .chart,.panel { min-width:0; } /* Respect the actual report-call sizes: calibration 550, other curves 500. */ -#calibration svg { display: block; width: min(100%, 550px); height: auto; margin: 0 auto; } -#discrimination svg, #utility-decision-curve svg { - display: block; - width: min(100%, 500px); - height: auto; - margin: 0 auto; -} - -.axis text { fill: #444; font-size: 12px; } -.axis-label { fill: #444; font-size: 14px; } -.plot-title { text-align: left; } -.legend { - display: flex; - justify-content: center; - align-items: center; - flex-wrap: wrap; - gap: 14px; - min-height: 24px; - color: #444; - font-size: 12px; -} -.legend span { display: inline-flex; align-items: center; gap: 5px; } -.legend i { width: 24px; height: 2px; display: inline-block; } - -/* Reactable-like density for the lightweight table replacement. */ -table { - width: 100%; - border-collapse: collapse; - background: #fff; - font-size: 13px; -} -thead th { - background: #fff; - color: #333; - font-weight: 600; - border-bottom: 1px solid #ddd; - padding: 7px; - vertical-align: bottom; -} -tbody td { - border-bottom: 1px solid #eee; - padding: 7px; - vertical-align: middle; -} -tbody tr:hover { background: #f5f5f5; } - -/* Plotly-style hover label shared by all D3 renderers. */ -.tooltip, #tooltip { - position: fixed; - pointer-events: none; - z-index: 9999; - border-radius: 2px; - box-shadow: none; - font-family: "Open Sans", verdana, arial, sans-serif; - font-size: 12px; - line-height: 15px; -} - -@media (max-width: 760px) { - .main-container { padding-left: 15px; padding-right: 15px; } - h1 { font-size: 30px; } - h2 { font-size: 26px; } - h3, .plot-title { font-size: 20px; } - .nav-tabs button { padding: 8px 10px; } - table { font-size: 12px; } -} +#calibration svg { display:block; width:min(100%,550px); height:auto; margin:0 auto; } +#discrimination svg,#utility svg,#utility-decision-curve svg { display:block; width:min(100%,500px); height:auto; margin:0 auto; } +.axis text { fill:#444; font-size:12px; } .axis-label { fill:#444; font-size:14px; } .plot-title { text-align:left; } +.legend { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:14px; min-height:24px; color:#444; font-size:12px; } +.legend span { display:inline-flex; align-items:center; gap:5px; } .legend i { width:24px; height:2px; display:inline-block; } + +table { width:100%; border-collapse:collapse; background:#fff; font-size:13px; } +thead th { background:#fff; color:#333; font-weight:600; border-bottom:1px solid #ddd; padding:7px; vertical-align:bottom; } +tbody td { border-bottom:1px solid #eee; padding:7px; vertical-align:middle; } tbody tr:hover { background:#f5f5f5; } +.tooltip,#tooltip { position:fixed; pointer-events:none; z-index:9999; border-radius:2px; box-shadow:none; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; line-height:15px; } +@media(max-width:760px){.main-container{padding-left:15px;padding-right:15px}h1{font-size:30px}h2{font-size:26px}h3,.plot-title{font-size:20px}.nav-tabs button{padding:8px 10px}table{font-size:12px}} From d4aad3b2d06d984ba458d908275b43124a81a356 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Wed, 19 Aug 2026 23:35:42 +0300 Subject: [PATCH 087/153] Fix summary report chrome compatibility --- .../summary_report/summary_report_v2.py | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report_v2.py b/src/rtichoke/summary_report/summary_report_v2.py index b673b712..655c1959 100644 --- a/src/rtichoke/summary_report/summary_report_v2.py +++ b/src/rtichoke/summary_report/summary_report_v2.py @@ -49,10 +49,7 @@ def _wire_report_style(html: str) -> str: def _wire_r_report_chrome(html: str) -> str: - """Add the lightweight equivalents of the R Markdown TOC and cheat sheet.""" - # The R reference starts with a compact TOC and a collapsed metric cheat - # sheet. Keep these semantic and dependency-free rather than importing the - # Bootstrap/Reactable runtime that accounts for much of the R file weight. + """Add lightweight equivalents of the R Markdown TOC and cheat sheet.""" toc = """
", f"{formulas}
", 1) + + # The R report's prevalence widget is a compact expandable Reactable with a + # grey proportional bar, not a generic Model/Prevalence summary table. + script = """""" + return html.replace("", script + "", 1) + + def create_summary_report( probs: Dict[str, np.ndarray], reals: Union[np.ndarray, Dict[str, np.ndarray]], output_file: str | Path = "summary_report.html", by: float = 0.01, ) -> Path: - """Create a lightweight, self-contained report using parity renderers. - - The base renderer already embeds the bundled visualization runtime. This - layer only wires the modular curve renderer and shared report styling. - """ out = Path(output_file) _legacy_create_summary_report(probs=probs, reals=reals, output_file=out, by=by) html = out.read_text(encoding="utf-8") html = _wire_curve_renderer(html) + html = _wire_r_report_content(html) html = _wire_report_style(html) out.write_text(html, encoding="utf-8") return out From cbcc09c60ab4eca7ebf723594eb7a1068b7524f4 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 12:38:36 +0300 Subject: [PATCH 105/153] Style R reference metric formulas and prevalence widget --- src/rtichoke/summary_report/report_style.css | 50 ++++---------------- 1 file changed, 9 insertions(+), 41 deletions(-) diff --git a/src/rtichoke/summary_report/report_style.css b/src/rtichoke/summary_report/report_style.css index 8a2eac51..2bc76b51 100644 --- a/src/rtichoke/summary_report/report_style.css +++ b/src/rtichoke/summary_report/report_style.css @@ -1,6 +1,4 @@ -/* Final layout/typography layer for the lightweight summary report. - * Match the actual R summary-report HTML structure, not standalone widgets. - */ +/* Final layout/typography layer for the lightweight summary report. */ :root { --rt-text:#333; --rt-muted:#666; --rt-border:#ddd; --rt-panel:#fff; } html,body { background:#fff; color:var(--rt-text); font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; font-size:14px; line-height:1.42857143; } body { margin:0; } @@ -8,41 +6,11 @@ body { margin:0; } h1,h2,h3,h4,.plot-title { color:var(--rt-text); font-family:inherit; font-weight:400; line-height:1.1; } h1 { font-size:36px; margin:20px 0 10px; } h2 { font-size:30px; margin:20px 0 10px; } h3,.plot-title { font-size:24px; margin:20px 0 10px; } #calibration,#discrimination,#utility,#utility-decision-curve,#performance-table { margin-top:20px; margin-bottom:30px; } - -/* R Markdown-style contents block. */ -.rt-toc { margin:0 0 20px; } -.rt-toc ul { margin-top:0; margin-bottom:10px; padding-left:20px; } -.rt-toc a { color:#337ab7; text-decoration:none; } -.rt-toc a:hover { color:#23527c; text-decoration:underline; } - -/* Dependency-free equivalent of the R report's collapsed Reactable cheat sheet. */ -.metric-cheat-sheet { margin:0 0 20px; } -.metric-cheat-sheet > summary { cursor:pointer; display:list-item; font-size:14px; } -.metric-cheat-sheet > summary::marker { color:#555; } -.metric-cheat-sheet table { width:310px; margin-top:18px; table-layout:fixed; } -.metric-cheat-sheet th,.metric-cheat-sheet td { text-align:center; border-bottom:0; padding:7px; } -.metric-cheat-sheet th:first-child { width:110px; text-align:left; font-weight:400; } -.metric-cheat-sheet thead th { font-weight:400; background:#fff; } -.metric-cheat-sheet td { width:100px; font-weight:600; } -.metric-cheat-sheet .cm-tp,.metric-cheat-sheet .cm-tn { background:lightgreen; } -.metric-cheat-sheet .cm-fp,.metric-cheat-sheet .cm-fn { background:pink; } - -/* Bootstrap 3 nav-tabs, matching R's tabset output. */ -.nav-tabs { display:flex; flex-wrap:wrap; gap:0; list-style:none; padding-left:0; margin:0 0 20px; border-bottom:1px solid var(--rt-border); } -.nav-tabs button { position:relative; display:block; padding:10px 15px; margin:0 2px -1px 0; color:#337ab7; background:transparent; border:1px solid transparent; border-radius:4px 4px 0 0; font:inherit; line-height:1.42857143; cursor:pointer; } -.nav-tabs button:hover { background:#eee; border-color:#eee #eee var(--rt-border); } -.nav-tabs button.active { color:#555; background:#fff; border:1px solid var(--rt-border); border-bottom-color:transparent; cursor:default; } -.panel { background:var(--rt-panel); border:0; border-radius:0; box-shadow:none; } .panel:not(.active) { display:none; } .chart,.panel { min-width:0; } - -/* Respect the actual report-call sizes: calibration 550, other curves 500. */ -#calibration svg { display:block; width:min(100%,550px); height:auto; margin:0 auto; } -#discrimination svg,#utility svg,#utility-decision-curve svg { display:block; width:min(100%,500px); height:auto; margin:0 auto; } -.axis text { fill:#444; font-size:12px; } .axis-label { fill:#444; font-size:14px; } .plot-title { text-align:left; } -.legend { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:14px; min-height:24px; color:#444; font-size:12px; } -.legend span { display:inline-flex; align-items:center; gap:5px; } .legend i { width:24px; height:2px; display:inline-block; } - -table { width:100%; border-collapse:collapse; background:#fff; font-size:13px; } -thead th { background:#fff; color:#333; font-weight:600; border-bottom:1px solid #ddd; padding:7px; vertical-align:bottom; } -tbody td { border-bottom:1px solid #eee; padding:7px; vertical-align:middle; } tbody tr:hover { background:#f5f5f5; } -.tooltip,#tooltip { position:fixed; pointer-events:none; z-index:9999; border-radius:2px; box-shadow:none; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; line-height:15px; } -@media(max-width:760px){.main-container{padding-left:15px;padding-right:15px}h1{font-size:30px}h2{font-size:26px}h3,.plot-title{font-size:20px}.nav-tabs button{padding:8px 10px}table{font-size:12px}} +.rt-toc { margin:0 0 20px; }.rt-toc ul { margin-top:0; margin-bottom:10px; padding-left:20px; }.rt-toc a { color:#337ab7; text-decoration:none; }.rt-toc a:hover { color:#23527c; text-decoration:underline; } +.metric-cheat-sheet { margin:0 0 20px; }.metric-cheat-sheet > summary { cursor:pointer; display:list-item; font-size:14px; }.metric-cheat-sheet > summary::marker { color:#555; }.metric-cheat-sheet table { width:310px; margin-top:18px; table-layout:fixed; }.metric-cheat-sheet th,.metric-cheat-sheet td { text-align:center; border-bottom:0; padding:7px; }.metric-cheat-sheet th:first-child { width:110px; text-align:left; font-weight:400; }.metric-cheat-sheet thead th { font-weight:400; background:#fff; }.metric-cheat-sheet td { width:100px; font-weight:600; }.metric-cheat-sheet .cm-tp,.metric-cheat-sheet .cm-tn { background:lightgreen; }.metric-cheat-sheet .cm-fp,.metric-cheat-sheet .cm-fn { background:pink; } +.metric-formulas { margin:28px 0 18px; font-family:"STIXGeneral-Regular","Times New Roman",serif; font-size:16px; }.metric-formulas>div { margin:23px 0; white-space:normal; }.frac { display:inline-flex; vertical-align:middle; flex-direction:column; text-align:center; line-height:1.15; margin:0 .22em; }.frac>span:first-child { border-bottom:1px solid #333; padding:0 .18em .08em; }.frac>span:last-child { padding:.08em .18em 0; } +#prev { width:345px; max-width:100%; margin:28px 0 20px; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.prevalence-row { display:grid; grid-template-columns:45px 300px; border-bottom:1px solid #eee; position:relative; }.prevalence-expander { grid-row:1; width:45px; border:0; background:#fff; color:#777; font-size:18px; cursor:pointer; }.prevalence-cell { grid-row:1; padding:7px 10px; }.prevalence-cell>strong { display:block; border-bottom:1px solid #ddd; padding-bottom:7px; margin-bottom:7px; font-weight:600; }.prevalence-value { display:flex; align-items:center; }.prevalence-track { flex:1; margin-left:8px; background:#e1e1e1; height:16px; }.prevalence-track>span { display:block; background:grey; height:16px; }.prevalence-detail { grid-column:1/3; padding:12px 45px; border-top:1px solid #eee; } +.nav-tabs { display:flex; flex-wrap:wrap; gap:0; list-style:none; padding-left:0; margin:0 0 20px; border-bottom:1px solid var(--rt-border); }.nav-tabs button { position:relative; display:block; padding:10px 15px; margin:0 2px -1px 0; color:#337ab7; background:transparent; border:1px solid transparent; border-radius:4px 4px 0 0; font:inherit; line-height:1.42857143; cursor:pointer; }.nav-tabs button:hover { background:#eee; border-color:#eee #eee var(--rt-border); }.nav-tabs button.active { color:#555; background:#fff; border:1px solid var(--rt-border); border-bottom-color:transparent; cursor:default; }.panel { background:var(--rt-panel); border:0; border-radius:0; box-shadow:none; }.panel:not(.active) { display:none; }.chart,.panel { min-width:0; } +#calibration svg { display:block; width:min(100%,550px); height:auto; margin:0 auto; }#discrimination svg,#utility svg,#utility-decision-curve svg { display:block; width:min(100%,500px); height:auto; margin:0 auto; }.axis text { fill:#444; font-size:12px; }.axis-label { fill:#444; font-size:14px; }.plot-title { text-align:left; }.legend { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:14px; min-height:24px; color:#444; font-size:12px; }.legend span { display:inline-flex; align-items:center; gap:5px; }.legend i { width:24px; height:2px; display:inline-block; } +table { width:100%; border-collapse:collapse; background:#fff; font-size:13px; }thead th { background:#fff; color:#333; font-weight:600; border-bottom:1px solid #ddd; padding:7px; vertical-align:bottom; }tbody td { border-bottom:1px solid #eee; padding:7px; vertical-align:middle; }tbody tr:hover { background:#f5f5f5; }.tooltip,#tooltip { position:fixed; pointer-events:none; z-index:9999; border-radius:2px; box-shadow:none; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; line-height:15px; } +@media(max-width:760px){.main-container{padding-left:15px;padding-right:15px}h1{font-size:30px}h2{font-size:26px}h3,.plot-title{font-size:20px}.nav-tabs button{padding:8px 10px}table{font-size:12px}.metric-formulas{font-size:14px}} From 30dc3f63322387140e1535c32cda116c349a685a Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 12:39:03 +0300 Subject: [PATCH 106/153] Populate prevalence details from report inputs --- .../summary_report/summary_report_v2.py | 60 ++++++++----------- 1 file changed, 25 insertions(+), 35 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report_v2.py b/src/rtichoke/summary_report/summary_report_v2.py index cbb97c7a..5e242c6b 100644 --- a/src/rtichoke/summary_report/summary_report_v2.py +++ b/src/rtichoke/summary_report/summary_report_v2.py @@ -1,6 +1,7 @@ """Readable integration layer for the lightweight summary report renderers.""" from __future__ import annotations +import json from pathlib import Path from typing import Dict, Union @@ -24,29 +25,19 @@ def _wire_curve_renderer(html: str) -> str: if marker not in html: raise RuntimeError("Could not locate summary-report curve integration point") html = html.replace(marker, renderer + "\n" + marker, 1) - html = html.replace( - "draw(s,chart,strat)}}));draw(specs[0],chart,strat)", - "drawRtichokeCurve(s,chart)}}));drawRtichokeCurve(specs[0],chart)", - 1, - ) - html = html.replace( - "draw(R.decision,'#decision','probability_threshold');", - "drawRtichokeCurve(R.decision,'#decision');", - 1, - ) + html = html.replace("draw(s,chart,strat)}}));draw(specs[0],chart,strat)", "drawRtichokeCurve(s,chart)}}));drawRtichokeCurve(specs[0],chart)", 1) + html = html.replace("draw(R.decision,'#decision','probability_threshold');", "drawRtichokeCurve(R.decision,'#decision');", 1) return html def _wire_report_style(html: str) -> str: css = _style_source() - marker = "" - if marker not in html: + if "" not in html: raise RuntimeError("Could not locate summary-report head element") - return html.replace(marker, f"\n{marker}", 1) + return html.replace("", f"\n", 1) -def _wire_r_report_content(html: str) -> str: - """Restore visible content present in the R Markdown reference report.""" +def _wire_r_report_content(html: str, probs: Dict[str, np.ndarray], reals: Union[np.ndarray, Dict[str, np.ndarray]]) -> str: formulas = """
Prevalence = TP + FNTP + FP + TN + FN
PPCR (Predicted Positives Condition Rate) = TP + FPTP + FP + TN + FN
@@ -59,40 +50,39 @@ def _wire_r_report_content(html: str) -> str:
""" needle = "
" if needle in html: - html = html.replace("
", f"{formulas}
", 1) - - # The R report's prevalence widget is a compact expandable Reactable with a - # grey proportional bar, not a generic Model/Prevalence summary table. - script = """""" return html.replace("", script + "", 1) -def create_summary_report( - probs: Dict[str, np.ndarray], - reals: Union[np.ndarray, Dict[str, np.ndarray]], - output_file: str | Path = "summary_report.html", - by: float = 0.01, -) -> Path: +def create_summary_report(probs: Dict[str, np.ndarray], reals: Union[np.ndarray, Dict[str, np.ndarray]], output_file: str | Path = "summary_report.html", by: float = 0.01) -> Path: out = Path(output_file) _legacy_create_summary_report(probs=probs, reals=reals, output_file=out, by=by) html = out.read_text(encoding="utf-8") html = _wire_curve_renderer(html) - html = _wire_r_report_content(html) + html = _wire_r_report_content(html, probs, reals) html = _wire_report_style(html) out.write_text(html, encoding="utf-8") return out From 96dc733e64d217c8fa08a1389d02d0d25002a8b1 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 12:48:47 +0300 Subject: [PATCH 107/153] Fix prevalence parity widget initialization --- src/rtichoke/summary_report/summary_report_v2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rtichoke/summary_report/summary_report_v2.py b/src/rtichoke/summary_report/summary_report_v2.py index 5e242c6b..03ebd048 100644 --- a/src/rtichoke/summary_report/summary_report_v2.py +++ b/src/rtichoke/summary_report/summary_report_v2.py @@ -60,7 +60,7 @@ def _wire_r_report_content(html: str, probs: Dict[str, np.ndarray], reals: Union sizes_json = json.dumps(sizes).replace(" (function(){{ - const host=document.getElementById('prev'); if(!host||!window.SUM)return; const sizes={sizes_json}; + const host=document.getElementById('prev'); if(!host||typeof SUM==='undefined')return; const sizes={sizes_json}; host.innerHTML=''; SUM.forEach((r,i)=>{{ const p=Number(r.Prevalence), n=sizes[r.Model]||0, row=document.createElement('div'); row.className='prevalence-row'; From ebc9eda41a321f1426117c838a8eb06b4cee1aec Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 12:59:58 +0300 Subject: [PATCH 108/153] Match R report plot title sizing --- src/rtichoke/summary_report/report_style.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/rtichoke/summary_report/report_style.css b/src/rtichoke/summary_report/report_style.css index 2bc76b51..1fa7f121 100644 --- a/src/rtichoke/summary_report/report_style.css +++ b/src/rtichoke/summary_report/report_style.css @@ -3,14 +3,14 @@ html,body { background:#fff; color:var(--rt-text); font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; font-size:14px; line-height:1.42857143; } body { margin:0; } .main-container,.container,.report,main { max-width:1040px; margin-left:auto; margin-right:auto; } -h1,h2,h3,h4,.plot-title { color:var(--rt-text); font-family:inherit; font-weight:400; line-height:1.1; } -h1 { font-size:36px; margin:20px 0 10px; } h2 { font-size:30px; margin:20px 0 10px; } h3,.plot-title { font-size:24px; margin:20px 0 10px; } +h1,h2,h3,h4 { color:var(--rt-text); font-family:inherit; font-weight:400; line-height:1.1; } +h1 { font-size:36px; margin:20px 0 10px; } h2 { font-size:30px; margin:20px 0 10px; } h3 { font-size:24px; margin:20px 0 10px; } #calibration,#discrimination,#utility,#utility-decision-curve,#performance-table { margin-top:20px; margin-bottom:30px; } .rt-toc { margin:0 0 20px; }.rt-toc ul { margin-top:0; margin-bottom:10px; padding-left:20px; }.rt-toc a { color:#337ab7; text-decoration:none; }.rt-toc a:hover { color:#23527c; text-decoration:underline; } .metric-cheat-sheet { margin:0 0 20px; }.metric-cheat-sheet > summary { cursor:pointer; display:list-item; font-size:14px; }.metric-cheat-sheet > summary::marker { color:#555; }.metric-cheat-sheet table { width:310px; margin-top:18px; table-layout:fixed; }.metric-cheat-sheet th,.metric-cheat-sheet td { text-align:center; border-bottom:0; padding:7px; }.metric-cheat-sheet th:first-child { width:110px; text-align:left; font-weight:400; }.metric-cheat-sheet thead th { font-weight:400; background:#fff; }.metric-cheat-sheet td { width:100px; font-weight:600; }.metric-cheat-sheet .cm-tp,.metric-cheat-sheet .cm-tn { background:lightgreen; }.metric-cheat-sheet .cm-fp,.metric-cheat-sheet .cm-fn { background:pink; } .metric-formulas { margin:28px 0 18px; font-family:"STIXGeneral-Regular","Times New Roman",serif; font-size:16px; }.metric-formulas>div { margin:23px 0; white-space:normal; }.frac { display:inline-flex; vertical-align:middle; flex-direction:column; text-align:center; line-height:1.15; margin:0 .22em; }.frac>span:first-child { border-bottom:1px solid #333; padding:0 .18em .08em; }.frac>span:last-child { padding:.08em .18em 0; } #prev { width:345px; max-width:100%; margin:28px 0 20px; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.prevalence-row { display:grid; grid-template-columns:45px 300px; border-bottom:1px solid #eee; position:relative; }.prevalence-expander { grid-row:1; width:45px; border:0; background:#fff; color:#777; font-size:18px; cursor:pointer; }.prevalence-cell { grid-row:1; padding:7px 10px; }.prevalence-cell>strong { display:block; border-bottom:1px solid #ddd; padding-bottom:7px; margin-bottom:7px; font-weight:600; }.prevalence-value { display:flex; align-items:center; }.prevalence-track { flex:1; margin-left:8px; background:#e1e1e1; height:16px; }.prevalence-track>span { display:block; background:grey; height:16px; }.prevalence-detail { grid-column:1/3; padding:12px 45px; border-top:1px solid #eee; } .nav-tabs { display:flex; flex-wrap:wrap; gap:0; list-style:none; padding-left:0; margin:0 0 20px; border-bottom:1px solid var(--rt-border); }.nav-tabs button { position:relative; display:block; padding:10px 15px; margin:0 2px -1px 0; color:#337ab7; background:transparent; border:1px solid transparent; border-radius:4px 4px 0 0; font:inherit; line-height:1.42857143; cursor:pointer; }.nav-tabs button:hover { background:#eee; border-color:#eee #eee var(--rt-border); }.nav-tabs button.active { color:#555; background:#fff; border:1px solid var(--rt-border); border-bottom-color:transparent; cursor:default; }.panel { background:var(--rt-panel); border:0; border-radius:0; box-shadow:none; }.panel:not(.active) { display:none; }.chart,.panel { min-width:0; } -#calibration svg { display:block; width:min(100%,550px); height:auto; margin:0 auto; }#discrimination svg,#utility svg,#utility-decision-curve svg { display:block; width:min(100%,500px); height:auto; margin:0 auto; }.axis text { fill:#444; font-size:12px; }.axis-label { fill:#444; font-size:14px; }.plot-title { text-align:left; }.legend { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:14px; min-height:24px; color:#444; font-size:12px; }.legend span { display:inline-flex; align-items:center; gap:5px; }.legend i { width:24px; height:2px; display:inline-block; } +#calibration svg { display:block; width:min(100%,550px); height:auto; margin:0 auto; }#discrimination svg,#utility svg,#utility-decision-curve svg { display:block; width:min(100%,500px); height:auto; margin:0 auto; }.axis text { fill:#444; font-size:12px; }.axis-label { fill:#444; font-size:14px; }.plot-title { color:#444; font-family:"Open Sans",verdana,arial,sans-serif; font-size:17px; font-weight:400; line-height:1.2; text-align:center; margin:0 0 2px; }.legend { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:14px; min-height:24px; color:#444; font-size:12px; }.legend span { display:inline-flex; align-items:center; gap:5px; }.legend i { width:24px; height:2px; display:inline-block; } table { width:100%; border-collapse:collapse; background:#fff; font-size:13px; }thead th { background:#fff; color:#333; font-weight:600; border-bottom:1px solid #ddd; padding:7px; vertical-align:bottom; }tbody td { border-bottom:1px solid #eee; padding:7px; vertical-align:middle; }tbody tr:hover { background:#f5f5f5; }.tooltip,#tooltip { position:fixed; pointer-events:none; z-index:9999; border-radius:2px; box-shadow:none; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; line-height:15px; } -@media(max-width:760px){.main-container{padding-left:15px;padding-right:15px}h1{font-size:30px}h2{font-size:26px}h3,.plot-title{font-size:20px}.nav-tabs button{padding:8px 10px}table{font-size:12px}.metric-formulas{font-size:14px}} +@media(max-width:760px){.main-container{padding-left:15px;padding-right:15px}h1{font-size:30px}h2{font-size:26px}h3{font-size:20px}.nav-tabs button{padding:8px 10px}table{font-size:12px}.metric-formulas{font-size:14px}} From ffe0666a179a879d8db101e5bf19a7977e02f981 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 13:05:40 +0300 Subject: [PATCH 109/153] Consolidate summary report styling layer --- src/rtichoke/summary_report/summary_report_v2.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report_v2.py b/src/rtichoke/summary_report/summary_report_v2.py index 03ebd048..66062147 100644 --- a/src/rtichoke/summary_report/summary_report_v2.py +++ b/src/rtichoke/summary_report/summary_report_v2.py @@ -3,6 +3,7 @@ import json from pathlib import Path +import re from typing import Dict, Union import numpy as np @@ -31,10 +32,12 @@ def _wire_curve_renderer(html: str) -> str: def _wire_report_style(html: str) -> str: + """Replace the legacy inline CSS with the single parity stylesheet.""" css = _style_source() - if "" not in html: - raise RuntimeError("Could not locate summary-report head element") - return html.replace("", f"\n", 1) + styled, count = re.subn(r"", f"", html, count=1, flags=re.DOTALL) + if count != 1: + raise RuntimeError("Could not locate summary-report style element") + return styled def _wire_r_report_content(html: str, probs: Dict[str, np.ndarray], reals: Union[np.ndarray, Dict[str, np.ndarray]]) -> str: From c0c2b3525fef701d663ce27620c8c5a71706777b Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 13:06:04 +0300 Subject: [PATCH 110/153] Complete authoritative report stylesheet --- src/rtichoke/summary_report/report_style.css | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/rtichoke/summary_report/report_style.css b/src/rtichoke/summary_report/report_style.css index 1fa7f121..4a217748 100644 --- a/src/rtichoke/summary_report/report_style.css +++ b/src/rtichoke/summary_report/report_style.css @@ -1,16 +1,23 @@ -/* Final layout/typography layer for the lightweight summary report. */ +/* Authoritative R-parity stylesheet for the lightweight summary report. */ :root { --rt-text:#333; --rt-muted:#666; --rt-border:#ddd; --rt-panel:#fff; } +* { box-sizing:border-box; } html,body { background:#fff; color:var(--rt-text); font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; font-size:14px; line-height:1.42857143; } body { margin:0; } .main-container,.container,.report,main { max-width:1040px; margin-left:auto; margin-right:auto; } +.main-container { padding:20px 15px 60px; } h1,h2,h3,h4 { color:var(--rt-text); font-family:inherit; font-weight:400; line-height:1.1; } h1 { font-size:36px; margin:20px 0 10px; } h2 { font-size:30px; margin:20px 0 10px; } h3 { font-size:24px; margin:20px 0 10px; } #calibration,#discrimination,#utility,#utility-decision-curve,#performance-table { margin-top:20px; margin-bottom:30px; } -.rt-toc { margin:0 0 20px; }.rt-toc ul { margin-top:0; margin-bottom:10px; padding-left:20px; }.rt-toc a { color:#337ab7; text-decoration:none; }.rt-toc a:hover { color:#23527c; text-decoration:underline; } -.metric-cheat-sheet { margin:0 0 20px; }.metric-cheat-sheet > summary { cursor:pointer; display:list-item; font-size:14px; }.metric-cheat-sheet > summary::marker { color:#555; }.metric-cheat-sheet table { width:310px; margin-top:18px; table-layout:fixed; }.metric-cheat-sheet th,.metric-cheat-sheet td { text-align:center; border-bottom:0; padding:7px; }.metric-cheat-sheet th:first-child { width:110px; text-align:left; font-weight:400; }.metric-cheat-sheet thead th { font-weight:400; background:#fff; }.metric-cheat-sheet td { width:100px; font-weight:600; }.metric-cheat-sheet .cm-tp,.metric-cheat-sheet .cm-tn { background:lightgreen; }.metric-cheat-sheet .cm-fp,.metric-cheat-sheet .cm-fn { background:pink; } +#TOC,.rt-toc { margin:15px 0 28px; } #TOC ul,.rt-toc ul { margin:0; padding-left:20px; } #TOC>ul { padding-left:0; list-style:none; } #TOC a,.rt-toc a { color:#337ab7; text-decoration:none; } #TOC a:hover,.rt-toc a:hover { color:#23527c; text-decoration:underline; } +details { margin:0 0 20px; } summary { cursor:pointer; } summary p { display:inline; } .cheat { padding-top:15px; line-height:1.75; } +.metric-cheat-sheet { margin:0 0 20px; }.metric-cheat-sheet > summary { cursor:pointer; display:list-item; font-size:14px; }.metric-cheat-sheet > summary::marker { color:#555; }.metric-cheat-sheet table { width:310px; margin-top:18px; table-layout:fixed; }.metric-cheat-sheet th,.metric-cheat-sheet td { text-align:center; border-bottom:0; padding:7px; }.metric-cheat-sheet th:first-child { width:110px; text-align:left; font-weight:400; }.metric-cheat-sheet thead th { font-weight:400; background:#fff; }.metric-cheat-sheet td { width:100px; font-weight:600; }.metric-cheat-sheet .cm-tp,.metric-cheat-sheet .cm-tn,.good { background:lightgreen; font-weight:600; }.metric-cheat-sheet .cm-fp,.metric-cheat-sheet .cm-fn,.bad { background:pink; font-weight:600; } .metric-formulas { margin:28px 0 18px; font-family:"STIXGeneral-Regular","Times New Roman",serif; font-size:16px; }.metric-formulas>div { margin:23px 0; white-space:normal; }.frac { display:inline-flex; vertical-align:middle; flex-direction:column; text-align:center; line-height:1.15; margin:0 .22em; }.frac>span:first-child { border-bottom:1px solid #333; padding:0 .18em .08em; }.frac>span:last-child { padding:.08em .18em 0; } #prev { width:345px; max-width:100%; margin:28px 0 20px; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.prevalence-row { display:grid; grid-template-columns:45px 300px; border-bottom:1px solid #eee; position:relative; }.prevalence-expander { grid-row:1; width:45px; border:0; background:#fff; color:#777; font-size:18px; cursor:pointer; }.prevalence-cell { grid-row:1; padding:7px 10px; }.prevalence-cell>strong { display:block; border-bottom:1px solid #ddd; padding-bottom:7px; margin-bottom:7px; font-weight:600; }.prevalence-value { display:flex; align-items:center; }.prevalence-track { flex:1; margin-left:8px; background:#e1e1e1; height:16px; }.prevalence-track>span { display:block; background:grey; height:16px; }.prevalence-detail { grid-column:1/3; padding:12px 45px; border-top:1px solid #eee; } -.nav-tabs { display:flex; flex-wrap:wrap; gap:0; list-style:none; padding-left:0; margin:0 0 20px; border-bottom:1px solid var(--rt-border); }.nav-tabs button { position:relative; display:block; padding:10px 15px; margin:0 2px -1px 0; color:#337ab7; background:transparent; border:1px solid transparent; border-radius:4px 4px 0 0; font:inherit; line-height:1.42857143; cursor:pointer; }.nav-tabs button:hover { background:#eee; border-color:#eee #eee var(--rt-border); }.nav-tabs button.active { color:#555; background:#fff; border:1px solid var(--rt-border); border-bottom-color:transparent; cursor:default; }.panel { background:var(--rt-panel); border:0; border-radius:0; box-shadow:none; }.panel:not(.active) { display:none; }.chart,.panel { min-width:0; } -#calibration svg { display:block; width:min(100%,550px); height:auto; margin:0 auto; }#discrimination svg,#utility svg,#utility-decision-curve svg { display:block; width:min(100%,500px); height:auto; margin:0 auto; }.axis text { fill:#444; font-size:12px; }.axis-label { fill:#444; font-size:14px; }.plot-title { color:#444; font-family:"Open Sans",verdana,arial,sans-serif; font-size:17px; font-weight:400; line-height:1.2; text-align:center; margin:0 0 2px; }.legend { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:14px; min-height:24px; color:#444; font-size:12px; }.legend span { display:inline-flex; align-items:center; gap:5px; }.legend i { width:24px; height:2px; display:inline-block; } -table { width:100%; border-collapse:collapse; background:#fff; font-size:13px; }thead th { background:#fff; color:#333; font-weight:600; border-bottom:1px solid #ddd; padding:7px; vertical-align:bottom; }tbody td { border-bottom:1px solid #eee; padding:7px; vertical-align:middle; }tbody tr:hover { background:#f5f5f5; }.tooltip,#tooltip { position:fixed; pointer-events:none; z-index:9999; border-radius:2px; box-shadow:none; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; line-height:15px; } +.summary-table { min-width:310px; border:1px solid #eee; }.summary-table th,.summary-table td { border-bottom:1px solid #eee; } +.nav-tabs { display:flex; flex-wrap:wrap; gap:0; list-style:none; padding-left:0; margin:0 0 20px; border-bottom:1px solid var(--rt-border); }.nav-tabs button { position:relative; display:block; padding:10px 15px; margin:0 2px -1px 0; color:#337ab7; background:transparent; border:1px solid transparent; border-radius:4px 4px 0 0; font:inherit; line-height:1.42857143; cursor:pointer; }.nav-tabs button:hover { background:#eee; border-color:#eee #eee var(--rt-border); }.nav-tabs button.active { color:#555; background:#fff; border:1px solid var(--rt-border); border-bottom-color:transparent; cursor:default; }.panel { background:var(--rt-panel); border:0; border-radius:0; box-shadow:none; }.panel:not(.active) { display:none; }.chart,.panel { min-width:0; }.chart { width:550px; max-width:100%; margin:0 auto 20px; } +svg { display:block; width:100%; height:auto; } #calibration svg { width:min(100%,550px); margin:0 auto; } #discrimination svg,#utility svg,#utility-decision-curve svg { width:min(100%,500px); margin:0 auto; }.axis text { fill:#444; font-size:12px; }.axis path,.axis line { stroke:#444; }.axis-label { fill:#444; font-size:14px; }.plot-title { color:#444; fill:#444; font-family:"Open Sans",verdana,arial,sans-serif; font-size:17px; font-weight:400; line-height:1.2; text-align:center; margin:0 0 2px; }.legend { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:14px; min-height:24px; color:#444; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; }.legend span { display:inline-flex; align-items:center; gap:5px; }.legend i { width:24px; height:2px; display:inline-block; }.line { fill:none; stroke-width:2; }.ref { fill:none; stroke-width:2; stroke-dasharray:3 3; } +.slider-wrap { width:430px; max-width:calc(100% - 80px); margin:-54px auto 28px; }.slider-label { font:16px "Open Sans",verdana,arial,sans-serif; color:#444; }input[type=range] { width:100%; } +.tip,.tooltip,#tooltip { position:fixed; pointer-events:none; z-index:9999; padding:8px 10px; background:#333; color:white; opacity:0; border-radius:2px; box-shadow:none; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; line-height:15px; } +table { width:100%; border-collapse:collapse; background:#fff; font-size:13px; margin-bottom:20px; }th,td { padding:7px 10px; text-align:center; }thead th { background:#fff; color:#333; font-weight:600; border-bottom:1px solid #ddd; vertical-align:bottom; }tbody td { border-bottom:1px solid #eee; vertical-align:middle; }tbody tr:hover { background:#f5f5f5; } +.perf-wrap { overflow:auto; max-height:620px; border:1px solid #ddd; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.perf { width:100%; margin:0; border-collapse:separate; border-spacing:0; font-size:14px; }.perf th { position:sticky; top:0; background:#fff; z-index:2; white-space:nowrap; border-bottom:1px solid #ddd; font-weight:600; text-align:left; padding:8px 10px; }.perf .group-head th { text-align:center; }.perf .column-head th { top:35px; }.perf td { border-bottom:1px solid #eee; text-align:left; padding:8px 10px; white-space:nowrap; }.perf tbody tr.data-row:hover { background:#f5f5f5; }.perf .model { text-align:left; }.model-badge { display:inline-block; margin-right:8px; width:9px; height:9px; border-radius:50%; vertical-align:1px; }.metric-cell { position:relative; isolation:isolate; min-width:72px; }.metric-cell::before { content:""; position:absolute; z-index:-1; left:0; top:0; bottom:0; width:var(--bar,0%); background:var(--bar-color,lightgreen); }.expand { width:30px; cursor:pointer; font-size:18px; color:#777; text-align:center!important; }.detail td { text-align:left; background:#fff; padding:16px; }.cm-title { font-weight:600; margin-bottom:8px; }.cm { display:inline-grid; grid-template-columns:auto auto; gap:3px; margin-left:10px; }.cm span { padding:5px 10px; min-width:72px; text-align:center; }.pos { background:lightgreen; }.neg { background:pink; } @media(max-width:760px){.main-container{padding-left:15px;padding-right:15px}h1{font-size:30px}h2{font-size:26px}h3{font-size:20px}.nav-tabs button{padding:8px 10px}table{font-size:12px}.metric-formulas{font-size:14px}} From a092a78b84d737fed00667122ef89f9206ccd449 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 13:23:07 +0300 Subject: [PATCH 111/153] Match R prevalence rows for shared outcomes --- .../summary_report/summary_report_v2.py | 6 ++++-- tests/test_summary_report_prevalence.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 tests/test_summary_report_prevalence.py diff --git a/src/rtichoke/summary_report/summary_report_v2.py b/src/rtichoke/summary_report/summary_report_v2.py index 66062147..3f22e995 100644 --- a/src/rtichoke/summary_report/summary_report_v2.py +++ b/src/rtichoke/summary_report/summary_report_v2.py @@ -57,15 +57,17 @@ def _wire_r_report_content(html: str, probs: Dict[str, np.ndarray], reals: Union if isinstance(reals, dict): sizes = {k: int(np.asarray(reals[k]).size) for k in probs if k in reals} + prevalence_rows = "SUM" else: n = int(np.asarray(reals).size) sizes = {k: n for k in probs} + prevalence_rows = "SUM.slice(0,1)" sizes_json = json.dumps(sizes).replace(" (function(){{ - const host=document.getElementById('prev'); if(!host||typeof SUM==='undefined')return; const sizes={sizes_json}; + const host=document.getElementById('prev'); if(!host||typeof SUM==='undefined')return; const sizes={sizes_json}; const prevalenceRows={prevalence_rows}; host.innerHTML=''; - SUM.forEach((r,i)=>{{ + prevalenceRows.forEach((r,i)=>{{ const p=Number(r.Prevalence), n=sizes[r.Model]||0, row=document.createElement('div'); row.className='prevalence-row'; const exp=document.createElement('button'); exp.className='prevalence-expander'; exp.textContent='›'; exp.setAttribute('aria-label','Toggle details'); const cell=document.createElement('div'); cell.className='prevalence-cell'; diff --git a/tests/test_summary_report_prevalence.py b/tests/test_summary_report_prevalence.py new file mode 100644 index 00000000..7d79383b --- /dev/null +++ b/tests/test_summary_report_prevalence.py @@ -0,0 +1,18 @@ +import numpy as np + +from rtichoke.summary_report.summary_report_v2 import create_summary_report + + +def test_shared_outcomes_render_one_prevalence_population(tmp_path): + probs = { + "Model A": np.array([0.1, 0.3, 0.6, 0.8]), + "Model B": np.array([0.2, 0.4, 0.5, 0.7]), + } + reals = np.array([0, 0, 1, 1]) + output = tmp_path / "report.html" + + create_summary_report(probs, reals, output_file=output, by=0.1) + + html = output.read_text(encoding="utf-8") + assert "const prevalenceRows=SUM.slice(0,1);" in html + assert "prevalenceRows.forEach" in html From c33a2e7f1f8e6b62a5c7738e70539bbd521be3b9 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 13:27:07 +0300 Subject: [PATCH 112/153] Match R report heading weight --- src/rtichoke/summary_report/report_style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rtichoke/summary_report/report_style.css b/src/rtichoke/summary_report/report_style.css index 4a217748..f6dfaf6d 100644 --- a/src/rtichoke/summary_report/report_style.css +++ b/src/rtichoke/summary_report/report_style.css @@ -5,7 +5,7 @@ html,body { background:#fff; color:var(--rt-text); font-family:"Helvetica Neue", body { margin:0; } .main-container,.container,.report,main { max-width:1040px; margin-left:auto; margin-right:auto; } .main-container { padding:20px 15px 60px; } -h1,h2,h3,h4 { color:var(--rt-text); font-family:inherit; font-weight:400; line-height:1.1; } +h1,h2,h3,h4 { color:var(--rt-text); font-family:inherit; font-weight:500; line-height:1.1; } h1 { font-size:36px; margin:20px 0 10px; } h2 { font-size:30px; margin:20px 0 10px; } h3 { font-size:24px; margin:20px 0 10px; } #calibration,#discrimination,#utility,#utility-decision-curve,#performance-table { margin-top:20px; margin-bottom:30px; } #TOC,.rt-toc { margin:15px 0 28px; } #TOC ul,.rt-toc ul { margin:0; padding-left:20px; } #TOC>ul { padding-left:0; list-style:none; } #TOC a,.rt-toc a { color:#337ab7; text-decoration:none; } #TOC a:hover,.rt-toc a:hover { color:#23527c; text-decoration:underline; } From 57136a4fcf4e626a387e9c3ad848c2b9025eaa2e Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 13:31:01 +0300 Subject: [PATCH 113/153] Match R performance table pagination --- .../performance_table_renderer.js | 58 ++++++++++++++----- .../summary_report/summary_report_v2.py | 10 ++++ tests/test_summary_report_prevalence.py | 14 +++++ 3 files changed, 66 insertions(+), 16 deletions(-) diff --git a/src/rtichoke/summary_report/performance_table_renderer.js b/src/rtichoke/summary_report/performance_table_renderer.js index 33cddffb..7292f2c9 100644 --- a/src/rtichoke/summary_report/performance_table_renderer.js +++ b/src/rtichoke/summary_report/performance_table_renderer.js @@ -1,6 +1,7 @@ /* R/Reactable-parity renderer for lightweight summary-report performance tables. */ (function () { const COLORS = ["#1b9e77", "#d95f02", "#7570b3", "#e7298a", "#07004D", "#E6AB02", "#FE5F55", "#54494B", "#006E90", "#BC96E6", "#52050A", "#1F271B", "#BE7C4D", "#63768D", "#08A045", "#320A28", "#82FF9E", "#2176FF", "#D1603D", "#585123"]; + const PAGE_SIZE = 10; const fmt = v => typeof v === "number" && isFinite(v) ? v.toFixed(2) : (v ?? ""); const pct = v => typeof v === "number" && isFinite(v) ? `${(100 * v).toFixed(2)}%` : ""; const num = v => typeof v === "number" && isFinite(v) ? v : 0; @@ -11,10 +12,10 @@ const style = document.createElement("style"); style.id = "rtichoke-perf-parity-css"; style.textContent = ` - .rt-perf-wrap{overflow:auto;border:1px solid #e5e5e5;border-radius:3px;max-height:620px;background:#fff} + .rt-perf-wrap{overflow:auto;border:1px solid #e5e5e5;border-radius:3px;background:#fff} .rt-perf{width:100%;border-collapse:separate;border-spacing:0;margin:0;font-size:14px} .rt-perf th,.rt-perf td{padding:8px 10px;text-align:left;border-bottom:1px solid #eee;white-space:nowrap;position:relative} - .rt-perf thead th{position:sticky;top:0;z-index:3;background:#fff;font-weight:600;color:#333} + .rt-perf thead th{background:#fff;font-weight:600;color:#333} .rt-perf .metric-group{text-align:center;border-bottom:1px solid #ddd} .rt-perf .model-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:8px;vertical-align:1px} .rt-perf .expand{width:28px;text-align:center;color:#777;cursor:pointer;font-size:18px;padding-left:6px;padding-right:6px} @@ -24,6 +25,12 @@ .rt-conf th,.rt-conf td{padding:6px 10px;border:1px solid #eee;text-align:left;min-width:105px} .rt-conf th{position:static;background:#fff;font-weight:600} .rt-conf .outcome{font-weight:600} + .rt-pager{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:8px 0;font-size:13px;color:#555} + .rt-page-controls{display:flex;gap:4px;align-items:center} + .rt-page-controls button{border:1px solid transparent;background:#fff;color:#337ab7;padding:5px 9px;border-radius:3px;font:inherit;cursor:pointer} + .rt-page-controls button:hover:not(:disabled){background:#eee} + .rt-page-controls button.active{background:#337ab7;color:#fff} + .rt-page-controls button:disabled{color:#aaa;cursor:default} `; document.head.appendChild(style); } @@ -52,7 +59,7 @@ [" ",tp+fn,fp+tn,"lightgrey","lightgrey"] ]; const value=(x)=>`${fmt(x)} (${(100*x/total).toFixed(2)}%)`; - return `${rows.map((q,i)=>``).join("")}
Real PositiveReal Negative
${q[0]}${value(q[1])}${value(q[2])}${value(q[1]+q[2])}
`; + return `${rows.map(q=>``).join("")}
Real PositiveReal Negative
${q[0]}${value(q[1])}${value(q[2])}${value(q[1]+q[2])}
`; } function render(rows, selector, isPpcr) { @@ -67,19 +74,38 @@ const table=document.createElement("table"); table.className="rt-perf"; const metricCount=isPpcr?5:6; table.innerHTML=`Model${isPpcr?"":"Probability Threshold"}Predicted PositivesPerformance MetricsSensSpecPPVNPVLift${isPpcr?"":"Net Benefit"}`; - const body=document.createElement("tbody"); - sorted.forEach(r=>{ - const tr=document.createElement("tr"); - const model=String(r.reference_group ?? ""); - const ppcrText=`${fmt(r.predicted_positives)} (${pct(r.ppcr)})`; - const metrics=[["sensitivity",1],["specificity",1],["ppv",1],["npv",1],["lift",liftMax]]; - tr.innerHTML=`›${esc(model)}${isPpcr?"":`${fmt(r.chosen_cutoff)}`}${ppcrText}${metrics.map(([k,m])=>`${fmt(r[k])}`).join("")}${isPpcr?"":`${fmt(r.net_benefit)}`}`; - const detail=document.createElement("tr"); detail.className="detail"; detail.style.display="none"; - const td=document.createElement("td"); td.colSpan=isPpcr?9:11; td.innerHTML=confusionMatrix(r); detail.appendChild(td); - tr.querySelector(".expand").addEventListener("click",e=>{const open=detail.style.display!=="none"; detail.style.display=open?"none":"table-row"; e.currentTarget.textContent=open?"›":"⌄";}); - body.appendChild(tr); body.appendChild(detail); - }); - table.appendChild(body); wrap.appendChild(table); host.appendChild(wrap); + const body=document.createElement("tbody"); table.appendChild(body); wrap.appendChild(table); host.appendChild(wrap); + const pager=document.createElement("div"); pager.className="rt-pager"; host.appendChild(pager); + let page=0; + + function drawPage() { + body.innerHTML=""; + const start=page*PAGE_SIZE, pageRows=sorted.slice(start,start+PAGE_SIZE); + pageRows.forEach(r=>{ + const tr=document.createElement("tr"); + const model=String(r.reference_group ?? ""); + const ppcrText=`${fmt(r.predicted_positives)} (${pct(r.ppcr)})`; + const metrics=[["sensitivity",1],["specificity",1],["ppv",1],["npv",1],["lift",liftMax]]; + tr.innerHTML=`›${esc(model)}${isPpcr?"":`${fmt(r.chosen_cutoff)}`}${ppcrText}${metrics.map(([k,m])=>`${fmt(r[k])}`).join("")}${isPpcr?"":`${fmt(r.net_benefit)}`}`; + const detail=document.createElement("tr"); detail.className="detail"; detail.style.display="none"; + const td=document.createElement("td"); td.colSpan=isPpcr?9:10; td.innerHTML=confusionMatrix(r); detail.appendChild(td); + tr.querySelector(".expand").addEventListener("click",e=>{const open=detail.style.display!=="none"; detail.style.display=open?"none":"table-row"; e.currentTarget.textContent=open?"›":"⌄";}); + body.appendChild(tr); body.appendChild(detail); + }); + + const pages=Math.ceil(sorted.length/PAGE_SIZE); + if (pages <= 1) { pager.hidden=true; return; } + pager.hidden=false; pager.innerHTML=""; + const info=document.createElement("span"); info.className="rt-page-info"; + info.textContent=`${start+1}–${Math.min(start+PAGE_SIZE,sorted.length)} of ${sorted.length} rows`; + const controls=document.createElement("span"); controls.className="rt-page-controls"; + const button=(label,target,disabled,current=false)=>{const b=document.createElement("button");b.textContent=label;b.disabled=disabled;b.className=current?"active":"";b.addEventListener("click",()=>{page=target;drawPage();});return b;}; + controls.appendChild(button("Previous",Math.max(0,page-1),page===0)); + for(let i=0;i{ diff --git a/src/rtichoke/summary_report/summary_report_v2.py b/src/rtichoke/summary_report/summary_report_v2.py index 3f22e995..84e60253 100644 --- a/src/rtichoke/summary_report/summary_report_v2.py +++ b/src/rtichoke/summary_report/summary_report_v2.py @@ -31,6 +31,15 @@ def _wire_curve_renderer(html: str) -> str: return html +def _wire_performance_table_renderer(html: str) -> str: + """Use the modular Reactable-like table renderer instead of legacy inline calls.""" + renderer = _asset_source("performance_table_renderer.js") + marker = "perf(R.tables.threshold,'#table-threshold',false);perf(R.tables.ppcr,'#table-ppcr',true);" + if marker not in html: + raise RuntimeError("Could not locate summary-report performance-table integration point") + return html.replace(marker, renderer, 1) + + def _wire_report_style(html: str) -> str: """Replace the legacy inline CSS with the single parity stylesheet.""" css = _style_source() @@ -87,6 +96,7 @@ def create_summary_report(probs: Dict[str, np.ndarray], reals: Union[np.ndarray, _legacy_create_summary_report(probs=probs, reals=reals, output_file=out, by=by) html = out.read_text(encoding="utf-8") html = _wire_curve_renderer(html) + html = _wire_performance_table_renderer(html) html = _wire_r_report_content(html, probs, reals) html = _wire_report_style(html) out.write_text(html, encoding="utf-8") diff --git a/tests/test_summary_report_prevalence.py b/tests/test_summary_report_prevalence.py index 7d79383b..79a8b780 100644 --- a/tests/test_summary_report_prevalence.py +++ b/tests/test_summary_report_prevalence.py @@ -16,3 +16,17 @@ def test_shared_outcomes_render_one_prevalence_population(tmp_path): html = output.read_text(encoding="utf-8") assert "const prevalenceRows=SUM.slice(0,1);" in html assert "prevalenceRows.forEach" in html + + +def test_summary_report_uses_paginated_modular_performance_table(tmp_path): + probs = {"Model A": np.linspace(0.01, 0.99, 20)} + reals = np.array([0, 1] * 10) + output = tmp_path / "report.html" + + create_summary_report(probs, reals, output_file=output, by=0.05) + + html = output.read_text(encoding="utf-8") + assert "const PAGE_SIZE = 10;" in html + assert "rt-page-info" in html + assert "of ${sorted.length} rows" in html + assert "perf(R.tables.threshold,'#table-threshold',false)" not in html From cf8dc4609b9ddb54906493b5f579293f2b9fa582 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 13:34:24 +0300 Subject: [PATCH 114/153] Fix PPCR table detail span --- src/rtichoke/summary_report/performance_table_renderer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rtichoke/summary_report/performance_table_renderer.js b/src/rtichoke/summary_report/performance_table_renderer.js index 7292f2c9..94836e91 100644 --- a/src/rtichoke/summary_report/performance_table_renderer.js +++ b/src/rtichoke/summary_report/performance_table_renderer.js @@ -88,7 +88,7 @@ const metrics=[["sensitivity",1],["specificity",1],["ppv",1],["npv",1],["lift",liftMax]]; tr.innerHTML=`›${esc(model)}${isPpcr?"":`${fmt(r.chosen_cutoff)}`}${ppcrText}${metrics.map(([k,m])=>`${fmt(r[k])}`).join("")}${isPpcr?"":`${fmt(r.net_benefit)}`}`; const detail=document.createElement("tr"); detail.className="detail"; detail.style.display="none"; - const td=document.createElement("td"); td.colSpan=isPpcr?9:10; td.innerHTML=confusionMatrix(r); detail.appendChild(td); + const td=document.createElement("td"); td.colSpan=isPpcr?8:10; td.innerHTML=confusionMatrix(r); detail.appendChild(td); tr.querySelector(".expand").addEventListener("click",e=>{const open=detail.style.display!=="none"; detail.style.display=open?"none":"table-row"; e.currentTarget.textContent=open?"›":"⌄";}); body.appendChild(tr); body.appendChild(detail); }); From d2e11d1aa29b9e9c374bbfdd909b930deb657344 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 13:36:01 +0300 Subject: [PATCH 115/153] Restore TP in summary table details --- .../summary_report/performance_table_renderer.js | 3 ++- tests/test_summary_report_prevalence.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/rtichoke/summary_report/performance_table_renderer.js b/src/rtichoke/summary_report/performance_table_renderer.js index 94836e91..ebfc4fa7 100644 --- a/src/rtichoke/summary_report/performance_table_renderer.js +++ b/src/rtichoke/summary_report/performance_table_renderer.js @@ -51,7 +51,8 @@ } function confusionMatrix(r) { - const tp=num(r.true_positives), tn=num(r.true_negatives), fp=num(r.false_positives), fn=num(r.false_negatives); + const fp=num(r.false_positives), tn=num(r.true_negatives), fn=num(r.false_negatives); + const tp=r.true_positives == null ? Math.max(0, num(r.predicted_positives)-fp) : num(r.true_positives); const total=tp+tn+fp+fn || 1; const rows=[ ["Predicted Positive",tp,fp,"lightgreen","pink"], diff --git a/tests/test_summary_report_prevalence.py b/tests/test_summary_report_prevalence.py index 79a8b780..5c48fdfd 100644 --- a/tests/test_summary_report_prevalence.py +++ b/tests/test_summary_report_prevalence.py @@ -30,3 +30,15 @@ def test_summary_report_uses_paginated_modular_performance_table(tmp_path): assert "rt-page-info" in html assert "of ${sorted.length} rows" in html assert "perf(R.tables.threshold,'#table-threshold',false)" not in html + + +def test_performance_table_recovers_tp_when_legacy_payload_omits_it(tmp_path): + probs = {"Model A": np.array([0.1, 0.4, 0.7, 0.9])} + reals = np.array([0, 1, 0, 1]) + output = tmp_path / "report.html" + + create_summary_report(probs, reals, output_file=output, by=0.1) + + html = output.read_text(encoding="utf-8") + assert "r.true_positives == null" in html + assert "num(r.predicted_positives)-fp" in html From 0da9d113cdb1adefb0ea8ca90553ea381a8d8739 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 13:36:20 +0300 Subject: [PATCH 116/153] Match R AUROC summary widget --- src/rtichoke/summary_report/summary_report_v2.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/rtichoke/summary_report/summary_report_v2.py b/src/rtichoke/summary_report/summary_report_v2.py index 84e60253..1f7bce47 100644 --- a/src/rtichoke/summary_report/summary_report_v2.py +++ b/src/rtichoke/summary_report/summary_report_v2.py @@ -74,6 +74,16 @@ def _wire_r_report_content(html: str, probs: Dict[str, np.ndarray], reals: Union sizes_json = json.dumps(sizes).replace(" (function(){{ + const palette=['#1b9e77','#d95f02','#7570b3','#e7298a','#07004D','#E6AB02','#FE5F55','#54494B','#006E90','#BC96E6','#52050A','#1F271B','#BE7C4D','#63768D','#08A045','#320A28','#82FF9E','#2176FF','#D1603D','#585123']; + const aucHost=document.getElementById('auc'); + if(aucHost&&typeof SUM!=='undefined'){{ + const showGroup=SUM.length>1; + aucHost.innerHTML=''+(showGroup?'':'')+''+SUM.map((r,i)=>{{ + const value=Number(r.AUC), valid=Number.isFinite(value), label=valid?value.toFixed(2):' ', width=valid?Math.max(0,Math.min(100,value*100)):0; + const group=showGroup?'':''; + return ''+group+''; + }}).join('')+'
ModelAUROC
'+r.Model+'
'+label+'
'; + }} const host=document.getElementById('prev'); if(!host||typeof SUM==='undefined')return; const sizes={sizes_json}; const prevalenceRows={prevalence_rows}; host.innerHTML=''; prevalenceRows.forEach((r,i)=>{{ From 4de62068f7c42bd9499d14256d939f9f4195a484 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 13:36:51 +0300 Subject: [PATCH 117/153] Test R-style AUROC summary widget --- tests/test_summary_report_prevalence.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_summary_report_prevalence.py b/tests/test_summary_report_prevalence.py index 5c48fdfd..2471da96 100644 --- a/tests/test_summary_report_prevalence.py +++ b/tests/test_summary_report_prevalence.py @@ -18,6 +18,24 @@ def test_shared_outcomes_render_one_prevalence_population(tmp_path): assert "prevalenceRows.forEach" in html +def test_summary_report_uses_r_style_auc_widget(tmp_path): + probs = { + "Model A": np.array([0.1, 0.3, 0.6, 0.8]), + "Model B": np.array([0.2, 0.4, 0.5, 0.7]), + } + reals = np.array([0, 0, 1, 1]) + output = tmp_path / "report.html" + + create_summary_report(probs, reals, output_file=output, by=0.1) + + html = output.read_text(encoding="utf-8") + assert 'class="summary-table auc-table"' in html + assert "AUROC" in html + assert "value.toFixed(2)" in html + assert "background:green" in html + assert "model-badge" in html + + def test_summary_report_uses_paginated_modular_performance_table(tmp_path): probs = {"Model A": np.linspace(0.01, 0.99, 20)} reals = np.array([0, 1] * 10) From 9e669b51dc16d4924420e90a90995630ce67590e Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 13:37:51 +0300 Subject: [PATCH 118/153] Size AUROC widget like R Reactable --- src/rtichoke/summary_report/report_style.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rtichoke/summary_report/report_style.css b/src/rtichoke/summary_report/report_style.css index f6dfaf6d..ed23da6e 100644 --- a/src/rtichoke/summary_report/report_style.css +++ b/src/rtichoke/summary_report/report_style.css @@ -13,11 +13,11 @@ details { margin:0 0 20px; } summary { cursor:pointer; } summary p { display:inl .metric-cheat-sheet { margin:0 0 20px; }.metric-cheat-sheet > summary { cursor:pointer; display:list-item; font-size:14px; }.metric-cheat-sheet > summary::marker { color:#555; }.metric-cheat-sheet table { width:310px; margin-top:18px; table-layout:fixed; }.metric-cheat-sheet th,.metric-cheat-sheet td { text-align:center; border-bottom:0; padding:7px; }.metric-cheat-sheet th:first-child { width:110px; text-align:left; font-weight:400; }.metric-cheat-sheet thead th { font-weight:400; background:#fff; }.metric-cheat-sheet td { width:100px; font-weight:600; }.metric-cheat-sheet .cm-tp,.metric-cheat-sheet .cm-tn,.good { background:lightgreen; font-weight:600; }.metric-cheat-sheet .cm-fp,.metric-cheat-sheet .cm-fn,.bad { background:pink; font-weight:600; } .metric-formulas { margin:28px 0 18px; font-family:"STIXGeneral-Regular","Times New Roman",serif; font-size:16px; }.metric-formulas>div { margin:23px 0; white-space:normal; }.frac { display:inline-flex; vertical-align:middle; flex-direction:column; text-align:center; line-height:1.15; margin:0 .22em; }.frac>span:first-child { border-bottom:1px solid #333; padding:0 .18em .08em; }.frac>span:last-child { padding:.08em .18em 0; } #prev { width:345px; max-width:100%; margin:28px 0 20px; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.prevalence-row { display:grid; grid-template-columns:45px 300px; border-bottom:1px solid #eee; position:relative; }.prevalence-expander { grid-row:1; width:45px; border:0; background:#fff; color:#777; font-size:18px; cursor:pointer; }.prevalence-cell { grid-row:1; padding:7px 10px; }.prevalence-cell>strong { display:block; border-bottom:1px solid #ddd; padding-bottom:7px; margin-bottom:7px; font-weight:600; }.prevalence-value { display:flex; align-items:center; }.prevalence-track { flex:1; margin-left:8px; background:#e1e1e1; height:16px; }.prevalence-track>span { display:block; background:grey; height:16px; }.prevalence-detail { grid-column:1/3; padding:12px 45px; border-top:1px solid #eee; } -.summary-table { min-width:310px; border:1px solid #eee; }.summary-table th,.summary-table td { border-bottom:1px solid #eee; } +.summary-table { min-width:310px; border:1px solid #eee; }.summary-table th,.summary-table td { border-bottom:1px solid #eee; }.auc-table { width:600px; max-width:100%; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.auc-table th,.auc-table td { min-width:300px; text-align:left; }.auc-table .prevalence-track { min-width:180px; } .nav-tabs { display:flex; flex-wrap:wrap; gap:0; list-style:none; padding-left:0; margin:0 0 20px; border-bottom:1px solid var(--rt-border); }.nav-tabs button { position:relative; display:block; padding:10px 15px; margin:0 2px -1px 0; color:#337ab7; background:transparent; border:1px solid transparent; border-radius:4px 4px 0 0; font:inherit; line-height:1.42857143; cursor:pointer; }.nav-tabs button:hover { background:#eee; border-color:#eee #eee var(--rt-border); }.nav-tabs button.active { color:#555; background:#fff; border:1px solid var(--rt-border); border-bottom-color:transparent; cursor:default; }.panel { background:var(--rt-panel); border:0; border-radius:0; box-shadow:none; }.panel:not(.active) { display:none; }.chart,.panel { min-width:0; }.chart { width:550px; max-width:100%; margin:0 auto 20px; } svg { display:block; width:100%; height:auto; } #calibration svg { width:min(100%,550px); margin:0 auto; } #discrimination svg,#utility svg,#utility-decision-curve svg { width:min(100%,500px); margin:0 auto; }.axis text { fill:#444; font-size:12px; }.axis path,.axis line { stroke:#444; }.axis-label { fill:#444; font-size:14px; }.plot-title { color:#444; fill:#444; font-family:"Open Sans",verdana,arial,sans-serif; font-size:17px; font-weight:400; line-height:1.2; text-align:center; margin:0 0 2px; }.legend { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:14px; min-height:24px; color:#444; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; }.legend span { display:inline-flex; align-items:center; gap:5px; }.legend i { width:24px; height:2px; display:inline-block; }.line { fill:none; stroke-width:2; }.ref { fill:none; stroke-width:2; stroke-dasharray:3 3; } .slider-wrap { width:430px; max-width:calc(100% - 80px); margin:-54px auto 28px; }.slider-label { font:16px "Open Sans",verdana,arial,sans-serif; color:#444; }input[type=range] { width:100%; } .tip,.tooltip,#tooltip { position:fixed; pointer-events:none; z-index:9999; padding:8px 10px; background:#333; color:white; opacity:0; border-radius:2px; box-shadow:none; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; line-height:15px; } table { width:100%; border-collapse:collapse; background:#fff; font-size:13px; margin-bottom:20px; }th,td { padding:7px 10px; text-align:center; }thead th { background:#fff; color:#333; font-weight:600; border-bottom:1px solid #ddd; vertical-align:bottom; }tbody td { border-bottom:1px solid #eee; vertical-align:middle; }tbody tr:hover { background:#f5f5f5; } .perf-wrap { overflow:auto; max-height:620px; border:1px solid #ddd; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.perf { width:100%; margin:0; border-collapse:separate; border-spacing:0; font-size:14px; }.perf th { position:sticky; top:0; background:#fff; z-index:2; white-space:nowrap; border-bottom:1px solid #ddd; font-weight:600; text-align:left; padding:8px 10px; }.perf .group-head th { text-align:center; }.perf .column-head th { top:35px; }.perf td { border-bottom:1px solid #eee; text-align:left; padding:8px 10px; white-space:nowrap; }.perf tbody tr.data-row:hover { background:#f5f5f5; }.perf .model { text-align:left; }.model-badge { display:inline-block; margin-right:8px; width:9px; height:9px; border-radius:50%; vertical-align:1px; }.metric-cell { position:relative; isolation:isolate; min-width:72px; }.metric-cell::before { content:""; position:absolute; z-index:-1; left:0; top:0; bottom:0; width:var(--bar,0%); background:var(--bar-color,lightgreen); }.expand { width:30px; cursor:pointer; font-size:18px; color:#777; text-align:center!important; }.detail td { text-align:left; background:#fff; padding:16px; }.cm-title { font-weight:600; margin-bottom:8px; }.cm { display:inline-grid; grid-template-columns:auto auto; gap:3px; margin-left:10px; }.cm span { padding:5px 10px; min-width:72px; text-align:center; }.pos { background:lightgreen; }.neg { background:pink; } -@media(max-width:760px){.main-container{padding-left:15px;padding-right:15px}h1{font-size:30px}h2{font-size:26px}h3{font-size:20px}.nav-tabs button{padding:8px 10px}table{font-size:12px}.metric-formulas{font-size:14px}} +@media(max-width:760px){.main-container{padding-left:15px;padding-right:15px}h1{font-size:30px}h2{font-size:26px}h3{font-size:20px}.nav-tabs button{padding:8px 10px}table{font-size:12px}.metric-formulas{font-size:14px}.auc-table th,.auc-table td{min-width:0}.auc-table .prevalence-track{min-width:80px}} From 20de49e878bd9fdca72d84308380d980c4463442 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 13:39:50 +0300 Subject: [PATCH 119/153] Keep performance table styles consolidated --- .../performance_table_renderer.js | 29 ------------------- src/rtichoke/summary_report/report_style.css | 1 + 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/src/rtichoke/summary_report/performance_table_renderer.js b/src/rtichoke/summary_report/performance_table_renderer.js index ebfc4fa7..1991b047 100644 --- a/src/rtichoke/summary_report/performance_table_renderer.js +++ b/src/rtichoke/summary_report/performance_table_renderer.js @@ -7,34 +7,6 @@ const num = v => typeof v === "number" && isFinite(v) ? v : 0; const esc = v => String(v ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c])); - function injectStyles() { - if (document.getElementById("rtichoke-perf-parity-css")) return; - const style = document.createElement("style"); - style.id = "rtichoke-perf-parity-css"; - style.textContent = ` - .rt-perf-wrap{overflow:auto;border:1px solid #e5e5e5;border-radius:3px;background:#fff} - .rt-perf{width:100%;border-collapse:separate;border-spacing:0;margin:0;font-size:14px} - .rt-perf th,.rt-perf td{padding:8px 10px;text-align:left;border-bottom:1px solid #eee;white-space:nowrap;position:relative} - .rt-perf thead th{background:#fff;font-weight:600;color:#333} - .rt-perf .metric-group{text-align:center;border-bottom:1px solid #ddd} - .rt-perf .model-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:8px;vertical-align:1px} - .rt-perf .expand{width:28px;text-align:center;color:#777;cursor:pointer;font-size:18px;padding-left:6px;padding-right:6px} - .rt-perf .bar-cell{background-repeat:no-repeat;background-position:center;background-size:98% 88%} - .rt-perf .detail td{background:#fafafa;padding:16px} - .rt-conf{display:inline-table;border-collapse:collapse;margin:4px 0 4px 8px;vertical-align:middle} - .rt-conf th,.rt-conf td{padding:6px 10px;border:1px solid #eee;text-align:left;min-width:105px} - .rt-conf th{position:static;background:#fff;font-weight:600} - .rt-conf .outcome{font-weight:600} - .rt-pager{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:8px 0;font-size:13px;color:#555} - .rt-page-controls{display:flex;gap:4px;align-items:center} - .rt-page-controls button{border:1px solid transparent;background:#fff;color:#337ab7;padding:5px 9px;border-radius:3px;font:inherit;cursor:pointer} - .rt-page-controls button:hover:not(:disabled){background:#eee} - .rt-page-controls button.active{background:#337ab7;color:#fff} - .rt-page-controls button:disabled{color:#aaa;cursor:default} - `; - document.head.appendChild(style); - } - function metricBackground(value, maxValue=1, color="lightgreen") { if (!isFinite(+value) || maxValue <= 0) return ""; const width = Math.min(Math.abs(+value) / maxValue, 1) * 100; @@ -110,7 +82,6 @@ } window.addEventListener("load",()=>{ - injectStyles(); if (window.R && R.tables) { render(R.tables.threshold,"#table-threshold",false); render(R.tables.ppcr,"#table-ppcr",true); diff --git a/src/rtichoke/summary_report/report_style.css b/src/rtichoke/summary_report/report_style.css index ed23da6e..d53637ad 100644 --- a/src/rtichoke/summary_report/report_style.css +++ b/src/rtichoke/summary_report/report_style.css @@ -20,4 +20,5 @@ svg { display:block; width:100%; height:auto; } #calibration svg { width:min(100 .tip,.tooltip,#tooltip { position:fixed; pointer-events:none; z-index:9999; padding:8px 10px; background:#333; color:white; opacity:0; border-radius:2px; box-shadow:none; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; line-height:15px; } table { width:100%; border-collapse:collapse; background:#fff; font-size:13px; margin-bottom:20px; }th,td { padding:7px 10px; text-align:center; }thead th { background:#fff; color:#333; font-weight:600; border-bottom:1px solid #ddd; vertical-align:bottom; }tbody td { border-bottom:1px solid #eee; vertical-align:middle; }tbody tr:hover { background:#f5f5f5; } .perf-wrap { overflow:auto; max-height:620px; border:1px solid #ddd; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.perf { width:100%; margin:0; border-collapse:separate; border-spacing:0; font-size:14px; }.perf th { position:sticky; top:0; background:#fff; z-index:2; white-space:nowrap; border-bottom:1px solid #ddd; font-weight:600; text-align:left; padding:8px 10px; }.perf .group-head th { text-align:center; }.perf .column-head th { top:35px; }.perf td { border-bottom:1px solid #eee; text-align:left; padding:8px 10px; white-space:nowrap; }.perf tbody tr.data-row:hover { background:#f5f5f5; }.perf .model { text-align:left; }.model-badge { display:inline-block; margin-right:8px; width:9px; height:9px; border-radius:50%; vertical-align:1px; }.metric-cell { position:relative; isolation:isolate; min-width:72px; }.metric-cell::before { content:""; position:absolute; z-index:-1; left:0; top:0; bottom:0; width:var(--bar,0%); background:var(--bar-color,lightgreen); }.expand { width:30px; cursor:pointer; font-size:18px; color:#777; text-align:center!important; }.detail td { text-align:left; background:#fff; padding:16px; }.cm-title { font-weight:600; margin-bottom:8px; }.cm { display:inline-grid; grid-template-columns:auto auto; gap:3px; margin-left:10px; }.cm span { padding:5px 10px; min-width:72px; text-align:center; }.pos { background:lightgreen; }.neg { background:pink; } +.rt-perf-wrap { overflow:auto; border:1px solid #e5e5e5; border-radius:3px; background:#fff; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.rt-perf { width:100%; border-collapse:separate; border-spacing:0; margin:0; font-size:14px; }.rt-perf th,.rt-perf td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; white-space:nowrap; position:relative; }.rt-perf thead th { background:#fff; font-weight:600; color:#333; }.rt-perf .metric-group { text-align:center; border-bottom:1px solid #ddd; }.rt-perf .model-dot { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:8px; vertical-align:1px; }.rt-perf .expand { width:28px; text-align:center; color:#777; cursor:pointer; font-size:18px; padding-left:6px; padding-right:6px; }.rt-perf .bar-cell { background-repeat:no-repeat; background-position:center; background-size:98% 88%; }.rt-perf .detail td { background:#fafafa; padding:16px; }.rt-conf { display:inline-table; border-collapse:collapse; margin:4px 0 4px 8px; vertical-align:middle; }.rt-conf th,.rt-conf td { padding:6px 10px; border:1px solid #eee; text-align:left; min-width:105px; }.rt-conf th { position:static; background:#fff; font-weight:600; }.rt-conf .outcome { font-weight:600; }.rt-pager { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:8px 0; font-size:13px; color:#555; }.rt-page-controls { display:flex; gap:4px; align-items:center; }.rt-page-controls button { border:1px solid transparent; background:#fff; color:#337ab7; padding:5px 9px; border-radius:3px; font:inherit; cursor:pointer; }.rt-page-controls button:hover:not(:disabled) { background:#eee; }.rt-page-controls button.active { background:#337ab7; color:#fff; }.rt-page-controls button:disabled { color:#aaa; cursor:default; } @media(max-width:760px){.main-container{padding-left:15px;padding-right:15px}h1{font-size:30px}h2{font-size:26px}h3{font-size:20px}.nav-tabs button{padding:8px 10px}table{font-size:12px}.metric-formulas{font-size:14px}.auc-table th,.auc-table td{min-width:0}.auc-table .prevalence-track{min-width:80px}} From be3f8427b0108398a535ac38a8e8cea6509a3ba3 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 13:45:10 +0300 Subject: [PATCH 120/153] Match R performance table filters --- .../performance_table_renderer.js | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/rtichoke/summary_report/performance_table_renderer.js b/src/rtichoke/summary_report/performance_table_renderer.js index 1991b047..f531db99 100644 --- a/src/rtichoke/summary_report/performance_table_renderer.js +++ b/src/rtichoke/summary_report/performance_table_renderer.js @@ -42,18 +42,52 @@ const colors=Object.fromEntries(models.map((m,i)=>[m,COLORS[i%COLORS.length]])); const liftMax=Math.max(1e-12,...rows.map(r=>Math.abs(num(r.lift)))); const nbMax=Math.max(1e-12,...rows.map(r=>Math.abs(num(r.net_benefit)))); - const sorted=[...rows].sort((a,b)=> isPpcr ? num(a.ppcr)-num(b.ppcr) : num(a.chosen_cutoff)-num(b.chosen_cutoff)); + const valueOf=r=>isPpcr?num(r.ppcr):num(r.chosen_cutoff); + const sorted=[...rows].sort((a,b)=>valueOf(a)-valueOf(b)); + const values=sorted.map(valueOf).filter(Number.isFinite); + const minValue=values.length?Math.min(...values):0, maxValue=values.length?Math.max(...values):1; + const step=Math.max(0.000001, ...values.slice(1).map((v,i)=>v-values[i]).filter(v=>v>0).slice(0,1), 0.01); + const selected=new Set(); + let lower=minValue, upper=maxValue, page=0; + + const filters=document.createElement("div"); filters.className="rt-filters"; + const modelFilter=document.createElement("div"); modelFilter.className="rt-filter-models"; + const modelLabel=document.createElement("div"); modelLabel.className="rt-filter-label"; modelLabel.textContent="Model"; modelFilter.appendChild(modelLabel); + models.forEach((model,i)=>{ + const label=document.createElement("label"); label.className="rt-check-inline"; + const input=document.createElement("input"); input.type="checkbox"; input.value=model; input.style.setProperty("--rt-check-color",colors[model]||COLORS[i%COLORS.length]); + const text=document.createElement("span"); text.textContent=model; label.append(input,text); modelFilter.appendChild(label); + input.addEventListener("change",()=>{input.checked?selected.add(model):selected.delete(model);page=0;drawPage();}); + }); + const rangeFilter=document.createElement("div"); rangeFilter.className="rt-filter-range"; + const rangeLabel=document.createElement("div"); rangeLabel.className="rt-filter-label"; rangeLabel.textContent=isPpcr?"Predicted Positives Condition Rate (PPCR)":"Probability Threshold"; + const rangeReadout=document.createElement("span"); rangeReadout.className="rt-range-readout"; + const track=document.createElement("div"); track.className="rt-dual-range"; + const lo=document.createElement("input"), hi=document.createElement("input"); + [lo,hi].forEach(input=>{input.type="range";input.min=minValue;input.max=maxValue;input.step=step;}); lo.value=minValue; hi.value=maxValue; + const sync=()=>{lower=Math.min(+lo.value,+hi.value);upper=Math.max(+lo.value,+hi.value);rangeReadout.textContent=`${fmt(lower)} – ${fmt(upper)}`;page=0;drawPage();}; + lo.addEventListener("input",sync); hi.addEventListener("input",sync); track.append(lo,hi); rangeFilter.append(rangeLabel,rangeReadout,track); filters.append(modelFilter,rangeFilter); host.appendChild(filters); + rangeReadout.textContent=`${fmt(lower)} – ${fmt(upper)}`; + const wrap=document.createElement("div"); wrap.className="rt-perf-wrap"; const table=document.createElement("table"); table.className="rt-perf"; const metricCount=isPpcr?5:6; table.innerHTML=`Model${isPpcr?"":"Probability Threshold"}Predicted PositivesPerformance MetricsSensSpecPPVNPVLift${isPpcr?"":"Net Benefit"}`; const body=document.createElement("tbody"); table.appendChild(body); wrap.appendChild(table); host.appendChild(wrap); const pager=document.createElement("div"); pager.className="rt-pager"; host.appendChild(pager); - let page=0; + + function filteredRows() { + return sorted.filter(r=>{ + const model=String(r.reference_group ?? ""), value=valueOf(r); + return (!selected.size||selected.has(model)) && value>=lower-1e-12 && value<=upper+1e-12; + }); + } function drawPage() { + const filtered=filteredRows(); + const pages=Math.max(1,Math.ceil(filtered.length/PAGE_SIZE)); if(page>=pages) page=pages-1; body.innerHTML=""; - const start=page*PAGE_SIZE, pageRows=sorted.slice(start,start+PAGE_SIZE); + const start=page*PAGE_SIZE, pageRows=filtered.slice(start,start+PAGE_SIZE); pageRows.forEach(r=>{ const tr=document.createElement("tr"); const model=String(r.reference_group ?? ""); @@ -65,12 +99,10 @@ tr.querySelector(".expand").addEventListener("click",e=>{const open=detail.style.display!=="none"; detail.style.display=open?"none":"table-row"; e.currentTarget.textContent=open?"›":"⌄";}); body.appendChild(tr); body.appendChild(detail); }); - - const pages=Math.ceil(sorted.length/PAGE_SIZE); if (pages <= 1) { pager.hidden=true; return; } pager.hidden=false; pager.innerHTML=""; const info=document.createElement("span"); info.className="rt-page-info"; - info.textContent=`${start+1}–${Math.min(start+PAGE_SIZE,sorted.length)} of ${sorted.length} rows`; + info.textContent=filtered.length?`${start+1}–${Math.min(start+PAGE_SIZE,filtered.length)} of ${filtered.length} rows`:`0 rows`; const controls=document.createElement("span"); controls.className="rt-page-controls"; const button=(label,target,disabled,current=false)=>{const b=document.createElement("button");b.textContent=label;b.disabled=disabled;b.className=current?"active":"";b.addEventListener("click",()=>{page=target;drawPage();});return b;}; controls.appendChild(button("Previous",Math.max(0,page-1),page===0)); From 0386d13c6e5939e9f05f9a5d068522d342e9a1af Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 13:45:44 +0300 Subject: [PATCH 121/153] Style performance table filters like R --- src/rtichoke/summary_report/report_style.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/rtichoke/summary_report/report_style.css b/src/rtichoke/summary_report/report_style.css index d53637ad..045e4809 100644 --- a/src/rtichoke/summary_report/report_style.css +++ b/src/rtichoke/summary_report/report_style.css @@ -20,5 +20,6 @@ svg { display:block; width:100%; height:auto; } #calibration svg { width:min(100 .tip,.tooltip,#tooltip { position:fixed; pointer-events:none; z-index:9999; padding:8px 10px; background:#333; color:white; opacity:0; border-radius:2px; box-shadow:none; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; line-height:15px; } table { width:100%; border-collapse:collapse; background:#fff; font-size:13px; margin-bottom:20px; }th,td { padding:7px 10px; text-align:center; }thead th { background:#fff; color:#333; font-weight:600; border-bottom:1px solid #ddd; vertical-align:bottom; }tbody td { border-bottom:1px solid #eee; vertical-align:middle; }tbody tr:hover { background:#f5f5f5; } .perf-wrap { overflow:auto; max-height:620px; border:1px solid #ddd; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.perf { width:100%; margin:0; border-collapse:separate; border-spacing:0; font-size:14px; }.perf th { position:sticky; top:0; background:#fff; z-index:2; white-space:nowrap; border-bottom:1px solid #ddd; font-weight:600; text-align:left; padding:8px 10px; }.perf .group-head th { text-align:center; }.perf .column-head th { top:35px; }.perf td { border-bottom:1px solid #eee; text-align:left; padding:8px 10px; white-space:nowrap; }.perf tbody tr.data-row:hover { background:#f5f5f5; }.perf .model { text-align:left; }.model-badge { display:inline-block; margin-right:8px; width:9px; height:9px; border-radius:50%; vertical-align:1px; }.metric-cell { position:relative; isolation:isolate; min-width:72px; }.metric-cell::before { content:""; position:absolute; z-index:-1; left:0; top:0; bottom:0; width:var(--bar,0%); background:var(--bar-color,lightgreen); }.expand { width:30px; cursor:pointer; font-size:18px; color:#777; text-align:center!important; }.detail td { text-align:left; background:#fff; padding:16px; }.cm-title { font-weight:600; margin-bottom:8px; }.cm { display:inline-grid; grid-template-columns:auto auto; gap:3px; margin-left:10px; }.cm span { padding:5px 10px; min-width:72px; text-align:center; }.pos { background:lightgreen; }.neg { background:pink; } +.rt-filters { display:grid; grid-template-columns:minmax(0,1fr) minmax(300px,1fr); gap:30px; align-items:end; margin:0 0 15px; font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; }.rt-filter-label { display:block; margin-bottom:4px; font-weight:700; }.rt-check-inline { position:relative; display:inline-block; padding-left:20px; margin-right:10px; font-weight:400; vertical-align:middle; cursor:pointer; }.rt-check-inline input { position:absolute; margin:2px 0 0 -20px; accent-color:var(--rt-check-color,#1b9e77); }.rt-filter-range { position:relative; }.rt-range-readout { float:right; margin-top:-24px; color:#555; font-size:12px; }.rt-dual-range { position:relative; height:32px; margin-top:4px; }.rt-dual-range input[type=range] { position:absolute; left:0; top:4px; width:100%; margin:0; background:transparent; pointer-events:none; }.rt-dual-range input[type=range]::-webkit-slider-thumb { pointer-events:auto; }.rt-dual-range input[type=range]::-moz-range-thumb { pointer-events:auto; } .rt-perf-wrap { overflow:auto; border:1px solid #e5e5e5; border-radius:3px; background:#fff; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.rt-perf { width:100%; border-collapse:separate; border-spacing:0; margin:0; font-size:14px; }.rt-perf th,.rt-perf td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; white-space:nowrap; position:relative; }.rt-perf thead th { background:#fff; font-weight:600; color:#333; }.rt-perf .metric-group { text-align:center; border-bottom:1px solid #ddd; }.rt-perf .model-dot { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:8px; vertical-align:1px; }.rt-perf .expand { width:28px; text-align:center; color:#777; cursor:pointer; font-size:18px; padding-left:6px; padding-right:6px; }.rt-perf .bar-cell { background-repeat:no-repeat; background-position:center; background-size:98% 88%; }.rt-perf .detail td { background:#fafafa; padding:16px; }.rt-conf { display:inline-table; border-collapse:collapse; margin:4px 0 4px 8px; vertical-align:middle; }.rt-conf th,.rt-conf td { padding:6px 10px; border:1px solid #eee; text-align:left; min-width:105px; }.rt-conf th { position:static; background:#fff; font-weight:600; }.rt-conf .outcome { font-weight:600; }.rt-pager { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:8px 0; font-size:13px; color:#555; }.rt-page-controls { display:flex; gap:4px; align-items:center; }.rt-page-controls button { border:1px solid transparent; background:#fff; color:#337ab7; padding:5px 9px; border-radius:3px; font:inherit; cursor:pointer; }.rt-page-controls button:hover:not(:disabled) { background:#eee; }.rt-page-controls button.active { background:#337ab7; color:#fff; }.rt-page-controls button:disabled { color:#aaa; cursor:default; } -@media(max-width:760px){.main-container{padding-left:15px;padding-right:15px}h1{font-size:30px}h2{font-size:26px}h3{font-size:20px}.nav-tabs button{padding:8px 10px}table{font-size:12px}.metric-formulas{font-size:14px}.auc-table th,.auc-table td{min-width:0}.auc-table .prevalence-track{min-width:80px}} +@media(max-width:760px){.main-container{padding-left:15px;padding-right:15px}h1{font-size:30px}h2{font-size:26px}h3{font-size:20px}.nav-tabs button{padding:8px 10px}table{font-size:12px}.metric-formulas{font-size:14px}.auc-table th,.auc-table td{min-width:0}.auc-table .prevalence-track{min-width:80px}.rt-filters{grid-template-columns:1fr;gap:12px}} From dac46b0b99528b38448921caa8fe10aa32bac578 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 13:46:00 +0300 Subject: [PATCH 122/153] Test R-style performance filters --- tests/test_summary_report_prevalence.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/test_summary_report_prevalence.py b/tests/test_summary_report_prevalence.py index 2471da96..c804039f 100644 --- a/tests/test_summary_report_prevalence.py +++ b/tests/test_summary_report_prevalence.py @@ -46,10 +46,29 @@ def test_summary_report_uses_paginated_modular_performance_table(tmp_path): html = output.read_text(encoding="utf-8") assert "const PAGE_SIZE = 10;" in html assert "rt-page-info" in html - assert "of ${sorted.length} rows" in html + assert "filtered.length" in html assert "perf(R.tables.threshold,'#table-threshold',false)" not in html +def test_summary_report_has_r_style_performance_filters(tmp_path): + probs = { + "Model A": np.array([0.1, 0.3, 0.6, 0.8]), + "Model B": np.array([0.2, 0.4, 0.5, 0.7]), + } + reals = np.array([0, 0, 1, 1]) + output = tmp_path / "report.html" + + create_summary_report(probs, reals, output_file=output, by=0.1) + + html = output.read_text(encoding="utf-8") + assert 'className="rt-filters"' in html + assert 'textContent="Model"' in html + assert '"Probability Threshold"' in html + assert '"Predicted Positives Condition Rate (PPCR)"' in html + assert 'className="rt-dual-range"' in html + assert "selected.has(model)" in html + + def test_performance_table_recovers_tp_when_legacy_payload_omits_it(tmp_path): probs = {"Model A": np.array([0.1, 0.4, 0.7, 0.9])} reals = np.array([0, 1, 0, 1]) From 2c6a6c12a55ce29c331012f01688df644885bc4e Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 14:03:28 +0300 Subject: [PATCH 123/153] Match R full-width curve layout --- src/rtichoke/summary_report/curve_renderer.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/rtichoke/summary_report/curve_renderer.js b/src/rtichoke/summary_report/curve_renderer.js index 8719cb65..79fa477b 100644 --- a/src/rtichoke/summary_report/curve_renderer.js +++ b/src/rtichoke/summary_report/curve_renderer.js @@ -1,10 +1,11 @@ /* D3 renderer for performance and decision curves in the lightweight report. - * Mirrors rtichoke R create_plotly_curve(): report size=500, transparent plot, - * no grid, dotted references, solid lines+markers, no legend/modebar. + * Mirrors rtichoke R create_plotly_curve(): size=500 controls plot height (550), + * while the htmlwidget itself fills the report column. The R widget has no + * internal plot title: ROC/Lift/etc. are supplied by the tab headings. */ function drawRtichokeCurve(s, sel) { - const card=d3.select(sel); card.selectAll("*").remove(); card.append("div").attr("class","plot-title").text(s.title); - const W=500,H=550,m={top:35,right:30,bottom:65,left:65}; + const card=d3.select(sel); card.selectAll("*").remove(); + const W=1000,H=550,m={top:35,right:30,bottom:65,left:65}; const svg=card.append("svg").attr("viewBox",`0 0 ${W} ${H}`),x=d3.scaleLinear().domain(s.x_range).range([m.left,W-m.right]),y=d3.scaleLinear().domain(s.y_range).range([H-m.bottom,m.top]); const line=d3.line().defined(d=>isFinite(+d.x)&&isFinite(+d.y)).x(d=>x(+d.x)).y(d=>y(+d.y)); const styleAxis=a=>{a.attr("font-family","Open Sans, verdana, arial, sans-serif").attr("font-size",12).attr("color","#444");a.select(".domain").attr("stroke","#444");a.selectAll(".tick line").attr("stroke","#444")}; From cae8d3e870a0c4d9783935cbd2b271e4d96e08ce Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 14:03:53 +0300 Subject: [PATCH 124/153] Let report curves fill the R report column --- src/rtichoke/summary_report/curve_renderer.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/rtichoke/summary_report/curve_renderer.js b/src/rtichoke/summary_report/curve_renderer.js index 79fa477b..f2528e2b 100644 --- a/src/rtichoke/summary_report/curve_renderer.js +++ b/src/rtichoke/summary_report/curve_renderer.js @@ -5,8 +5,9 @@ */ function drawRtichokeCurve(s, sel) { const card=d3.select(sel); card.selectAll("*").remove(); + card.style("width","100%").style("max-width","none").style("margin-left","0").style("margin-right","0"); const W=1000,H=550,m={top:35,right:30,bottom:65,left:65}; - const svg=card.append("svg").attr("viewBox",`0 0 ${W} ${H}`),x=d3.scaleLinear().domain(s.x_range).range([m.left,W-m.right]),y=d3.scaleLinear().domain(s.y_range).range([H-m.bottom,m.top]); + const svg=card.append("svg").style("width","100%").style("max-width","none").style("margin","0").attr("viewBox",`0 0 ${W} ${H}`),x=d3.scaleLinear().domain(s.x_range).range([m.left,W-m.right]),y=d3.scaleLinear().domain(s.y_range).range([H-m.bottom,m.top]); const line=d3.line().defined(d=>isFinite(+d.x)&&isFinite(+d.y)).x(d=>x(+d.x)).y(d=>y(+d.y)); const styleAxis=a=>{a.attr("font-family","Open Sans, verdana, arial, sans-serif").attr("font-size",12).attr("color","#444");a.select(".domain").attr("stroke","#444");a.selectAll(".tick line").attr("stroke","#444")}; const xa=svg.append("g").attr("class","axis").attr("transform",`translate(0,${H-m.bottom})`).call(d3.axisBottom(x).ticks(6)),ya=svg.append("g").attr("class","axis").attr("transform",`translate(${m.left},0)`).call(d3.axisLeft(y).ticks(6)); styleAxis(xa);styleAxis(ya); From ecb52ba89bb66c27975a3d062c6bc3c5e06bf11e Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 14:05:36 +0300 Subject: [PATCH 125/153] Match R curve widget geometry --- src/rtichoke/summary_report/curve_renderer.js | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/rtichoke/summary_report/curve_renderer.js b/src/rtichoke/summary_report/curve_renderer.js index f2528e2b..6d18c2bb 100644 --- a/src/rtichoke/summary_report/curve_renderer.js +++ b/src/rtichoke/summary_report/curve_renderer.js @@ -1,29 +1,23 @@ /* D3 renderer for performance and decision curves in the lightweight report. - * Mirrors rtichoke R create_plotly_curve(): size=500 controls plot height (550), - * while the htmlwidget itself fills the report column. The R widget has no - * internal plot title: ROC/Lift/etc. are supplied by the tab headings. + * Mirrors the R Plotly geometry used by create_summary_report(): a 500px-wide, + * 550px-high widget with no internal title. ROC/Lift/etc. are tab headings. */ function drawRtichokeCurve(s, sel) { const card=d3.select(sel); card.selectAll("*").remove(); - card.style("width","100%").style("max-width","none").style("margin-left","0").style("margin-right","0"); - const W=1000,H=550,m={top:35,right:30,bottom:65,left:65}; - const svg=card.append("svg").style("width","100%").style("max-width","none").style("margin","0").attr("viewBox",`0 0 ${W} ${H}`),x=d3.scaleLinear().domain(s.x_range).range([m.left,W-m.right]),y=d3.scaleLinear().domain(s.y_range).range([H-m.bottom,m.top]); + card.style("width","500px").style("max-width","100%").style("margin-left","0").style("margin-right","0"); + const W=500,H=550,m={top:25,right:10,bottom:40,left:60}; + const svg=card.append("svg").style("width","500px").style("max-width","100%").style("margin","0").attr("viewBox",`0 0 ${W} ${H}`),x=d3.scaleLinear().domain(s.x_range).range([m.left,W-m.right]),y=d3.scaleLinear().domain(s.y_range).range([H-m.bottom,m.top]); const line=d3.line().defined(d=>isFinite(+d.x)&&isFinite(+d.y)).x(d=>x(+d.x)).y(d=>y(+d.y)); const styleAxis=a=>{a.attr("font-family","Open Sans, verdana, arial, sans-serif").attr("font-size",12).attr("color","#444");a.select(".domain").attr("stroke","#444");a.selectAll(".tick line").attr("stroke","#444")}; const xa=svg.append("g").attr("class","axis").attr("transform",`translate(0,${H-m.bottom})`).call(d3.axisBottom(x).ticks(6)),ya=svg.append("g").attr("class","axis").attr("transform",`translate(${m.left},0)`).call(d3.axisLeft(y).ticks(6)); styleAxis(xa);styleAxis(ya); - svg.append("text").attr("class","axis-label").attr("x",(m.left+W-m.right)/2).attr("y",H-18).attr("text-anchor","middle").text(s.x_label); - svg.append("text").attr("class","axis-label").attr("transform","rotate(-90)").attr("x",-(m.top+H-m.bottom)/2).attr("y",20).attr("text-anchor","middle").text(s.y_label); + svg.append("text").attr("class","axis-label").attr("x",(m.left+W-m.right)/2).attr("y",H-8).attr("text-anchor","middle").text(s.x_label); + svg.append("text").attr("class","axis-label").attr("transform","rotate(-90)").attr("x",-(m.top+H-m.bottom)/2).attr("y",18).attr("text-anchor","middle").text(s.y_label); const strategyColor=g=>{const k=String(g||"").toLowerCase();if(k==="treat_none")return "#808080";return s.colors[g]||"#BEBEBE"}; const traces=[]; - // R add_lines(reference_data, line=list(dash="dot")). d3.group(s.references,d=>String(d.reference_group)).forEach((a,g)=>{const color=strategyColor(g);svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("stroke-dasharray","2,4").attr("stroke-linecap","round").attr("d",line);traces.push(...a.map(d=>({...d,_color:color})))}); - // R always uses mode="lines+markers" for the performance trace, including - // decision and interventions-avoided curves. Single-series color is black. const single=s.groups.length===1; s.groups.forEach(g=>{const a=s.data.filter(d=>String(d.reference_group)===g),color=single?"black":(s.colors[g]||"#000");svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("stroke-linejoin","round").attr("stroke-linecap","round").attr("d",line);svg.append("g").selectAll("circle").data(a.filter(d=>isFinite(+d.x)&&isFinite(+d.y))).enter().append("circle").attr("cx",d=>x(+d.x)).attr("cy",d=>y(+d.y)).attr("r",3).attr("fill",color).attr("stroke",color).attr("stroke-width",0);traces.push(...a.map(d=>({...d,_color:color})))}); const contrast=color=>{const h=String(color||"#333").replace("#","");if(!/^[0-9a-f]{6}$/i.test(h))return "white";const r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16);return(.299*r+.587*g+.114*b)>170?"#222":"white"}; const show=(ev,d)=>tip.style("opacity",1).style("left",(ev.clientX+10)+"px").style("top",(ev.clientY+10)+"px").style("background",d._color).style("border","1px solid "+d._color).style("color",contrast(d._color)).style("padding","6px 8px").style("border-radius","2px").style("font-family","Open Sans, verdana, arial, sans-serif").style("font-size","12px").style("line-height","15px").html(String(d.text||"")),hide=()=>tip.style("opacity",0); - // Scatter hover in Plotly is point-oriented; keep capture tight so empty - // plot space does not select a visually remote point. svg.append("rect").attr("x",m.left).attr("y",m.top).attr("width",W-m.left-m.right).attr("height",H-m.top-m.bottom).attr("fill","transparent").on("mousemove",ev=>{const[mx,my]=d3.pointer(ev);let best=null,dist=Infinity;traces.forEach(d=>{if(!isFinite(+d.x)||!isFinite(+d.y))return;const dd=(x(+d.x)-mx)**2+(y(+d.y)-my)**2;if(dd Date: Thu, 20 Aug 2026 14:06:55 +0300 Subject: [PATCH 126/153] Pass curve strata to D3 renderer --- src/rtichoke/summary_report/summary_report_v2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report_v2.py b/src/rtichoke/summary_report/summary_report_v2.py index 1f7bce47..987b1897 100644 --- a/src/rtichoke/summary_report/summary_report_v2.py +++ b/src/rtichoke/summary_report/summary_report_v2.py @@ -26,8 +26,8 @@ def _wire_curve_renderer(html: str) -> str: if marker not in html: raise RuntimeError("Could not locate summary-report curve integration point") html = html.replace(marker, renderer + "\n" + marker, 1) - html = html.replace("draw(s,chart,strat)}}));draw(specs[0],chart,strat)", "drawRtichokeCurve(s,chart)}}));drawRtichokeCurve(specs[0],chart)", 1) - html = html.replace("draw(R.decision,'#decision','probability_threshold');", "drawRtichokeCurve(R.decision,'#decision');", 1) + html = html.replace("draw(s,chart,strat)}}));draw(specs[0],chart,strat)", "drawRtichokeCurve(s,chart,strat)}}));drawRtichokeCurve(specs[0],chart,strat)", 1) + html = html.replace("draw(R.decision,'#decision','probability_threshold');", "drawRtichokeCurve(R.decision,'#decision','probability_threshold');", 1) return html From b460395dad47966e5eef0f042c29fed9686bc1b0 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 14:07:32 +0300 Subject: [PATCH 127/153] Restore R-style curve strata sliders --- src/rtichoke/summary_report/curve_renderer.js | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/rtichoke/summary_report/curve_renderer.js b/src/rtichoke/summary_report/curve_renderer.js index 6d18c2bb..39d04b7f 100644 --- a/src/rtichoke/summary_report/curve_renderer.js +++ b/src/rtichoke/summary_report/curve_renderer.js @@ -2,7 +2,7 @@ * Mirrors the R Plotly geometry used by create_summary_report(): a 500px-wide, * 550px-high widget with no internal title. ROC/Lift/etc. are tab headings. */ -function drawRtichokeCurve(s, sel) { +function drawRtichokeCurve(s, sel, strat) { const card=d3.select(sel); card.selectAll("*").remove(); card.style("width","500px").style("max-width","100%").style("margin-left","0").style("margin-right","0"); const W=500,H=550,m={top:25,right:10,bottom:40,left:60}; @@ -17,6 +17,33 @@ function drawRtichokeCurve(s, sel) { d3.group(s.references,d=>String(d.reference_group)).forEach((a,g)=>{const color=strategyColor(g);svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("stroke-dasharray","2,4").attr("stroke-linecap","round").attr("d",line);traces.push(...a.map(d=>({...d,_color:color})))}); const single=s.groups.length===1; s.groups.forEach(g=>{const a=s.data.filter(d=>String(d.reference_group)===g),color=single?"black":(s.colors[g]||"#000");svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("stroke-linejoin","round").attr("stroke-linecap","round").attr("d",line);svg.append("g").selectAll("circle").data(a.filter(d=>isFinite(+d.x)&&isFinite(+d.y))).enter().append("circle").attr("cx",d=>x(+d.x)).attr("cy",d=>y(+d.y)).attr("r",3).attr("fill",color).attr("stroke",color).attr("stroke-width",0);traces.push(...a.map(d=>({...d,_color:color})))}); + + const markerLayer=svg.append("g").attr("class","curve-current-markers"); + const strataField=strat==="ppcr"?"ppcr":"chosen_cutoff"; + const strata=[...new Set(s.data.map(d=>+d[strataField]).filter(Number.isFinite))].sort((a,b)=>a-b); + const drawCurrent=value=>{ + markerLayer.selectAll("*").remove(); + if(!strata.length)return; + const nearest=strata.reduce((a,b)=>Math.abs(b-value){ + const rows=s.data.filter(d=>String(d.reference_group)===g&&Number.isFinite(+d[strataField])); + if(!rows.length)return; + const d=rows.reduce((a,b)=>Math.abs(+b[strataField]-nearest)1){ + const wrap=card.append("div").attr("class","slider-wrap"); + const label=wrap.append("div").attr("class","slider-label"); + const prefix=strat==="ppcr"?"Predicted Positives (Rate):":"Probability Threshold:"; + const input=wrap.append("input").attr("type","range").attr("min",strata[0]).attr("max",strata[strata.length-1]).attr("step",Math.max(1e-6,...strata.slice(1).map((v,i)=>v-strata[i]).filter(v=>v>0).slice(0,1))).node(); + input.value=strata[0]; + const update=()=>{const v=+input.value;label.textContent=`${prefix} ${Number.isFinite(v)?v.toFixed(2):""}`;drawCurrent(v)}; + input.addEventListener("input",update);update(); + } + const contrast=color=>{const h=String(color||"#333").replace("#","");if(!/^[0-9a-f]{6}$/i.test(h))return "white";const r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16);return(.299*r+.587*g+.114*b)>170?"#222":"white"}; const show=(ev,d)=>tip.style("opacity",1).style("left",(ev.clientX+10)+"px").style("top",(ev.clientY+10)+"px").style("background",d._color).style("border","1px solid "+d._color).style("color",contrast(d._color)).style("padding","6px 8px").style("border-radius","2px").style("font-family","Open Sans, verdana, arial, sans-serif").style("font-size","12px").style("line-height","15px").html(String(d.text||"")),hide=()=>tip.style("opacity",0); svg.append("rect").attr("x",m.left).attr("y",m.top).attr("width",W-m.left-m.right).attr("height",H-m.top-m.bottom).attr("fill","transparent").on("mousemove",ev=>{const[mx,my]=d3.pointer(ev);let best=null,dist=Infinity;traces.forEach(d=>{if(!isFinite(+d.x)||!isFinite(+d.y))return;const dd=(x(+d.x)-mx)**2+(y(+d.y)-my)**2;if(dd Date: Thu, 20 Aug 2026 14:07:56 +0300 Subject: [PATCH 128/153] Cover R-style curve geometry and slider --- tests/test_summary_report_prevalence.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_summary_report_prevalence.py b/tests/test_summary_report_prevalence.py index c804039f..d329359d 100644 --- a/tests/test_summary_report_prevalence.py +++ b/tests/test_summary_report_prevalence.py @@ -69,6 +69,26 @@ def test_summary_report_has_r_style_performance_filters(tmp_path): assert "selected.has(model)" in html +def test_summary_report_has_r_style_curve_geometry_and_strata_slider(tmp_path): + probs = { + "Model A": np.array([0.1, 0.3, 0.6, 0.8]), + "Model B": np.array([0.2, 0.4, 0.5, 0.7]), + } + reals = np.array([0, 0, 1, 1]) + output = tmp_path / "report.html" + + create_summary_report(probs, reals, output_file=output, by=0.1) + + html = output.read_text(encoding="utf-8") + assert 'const W=500,H=550,m={top:25,right:10,bottom:40,left:60}' in html + assert 'attr("class","slider-wrap")' in html + assert '"Predicted Positives (Rate):"' in html + assert '"Probability Threshold:"' in html + assert 'drawRtichokeCurve(specs[0],chart,strat)' in html + assert "drawRtichokeCurve(R.decision,'#decision','probability_threshold')" in html + assert 'attr("class","plot-title")' not in html + + def test_performance_table_recovers_tp_when_legacy_payload_omits_it(tmp_path): probs = {"Model A": np.array([0.1, 0.4, 0.7, 0.9])} reals = np.array([0, 1, 0, 1]) From d0b51a9e17de8bbcfbe5447613848a9f340783c1 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 14:08:31 +0300 Subject: [PATCH 129/153] Fix curve parity regression assertions --- tests/test_summary_report_prevalence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_summary_report_prevalence.py b/tests/test_summary_report_prevalence.py index d329359d..afccf7cf 100644 --- a/tests/test_summary_report_prevalence.py +++ b/tests/test_summary_report_prevalence.py @@ -86,7 +86,7 @@ def test_summary_report_has_r_style_curve_geometry_and_strata_slider(tmp_path): assert '"Probability Threshold:"' in html assert 'drawRtichokeCurve(specs[0],chart,strat)' in html assert "drawRtichokeCurve(R.decision,'#decision','probability_threshold')" in html - assert 'attr("class","plot-title")' not in html + assert 'drawRtichokeCurve(s,chart,strat)' in html def test_performance_table_recovers_tp_when_legacy_payload_omits_it(tmp_path): From d4ecd1f18b98ef5382a113c54e5afd9bb99911eb Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 14:10:25 +0300 Subject: [PATCH 130/153] Actually wire parity renderer into discrimination tabs --- .../summary_report/summary_report_v2.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report_v2.py b/src/rtichoke/summary_report/summary_report_v2.py index 987b1897..f0bb6f24 100644 --- a/src/rtichoke/summary_report/summary_report_v2.py +++ b/src/rtichoke/summary_report/summary_report_v2.py @@ -26,8 +26,21 @@ def _wire_curve_renderer(html: str) -> str: if marker not in html: raise RuntimeError("Could not locate summary-report curve integration point") html = html.replace(marker, renderer + "\n" + marker, 1) - html = html.replace("draw(s,chart,strat)}}));draw(specs[0],chart,strat)", "drawRtichokeCurve(s,chart,strat)}}));drawRtichokeCurve(specs[0],chart,strat)", 1) - html = html.replace("draw(R.decision,'#decision','probability_threshold');", "drawRtichokeCurve(R.decision,'#decision','probability_threshold');", 1) + + legacy_tabs = "draw(s,chart,strat)}));draw(specs[0],chart,strat)" + wired_tabs = "drawRtichokeCurve(s,chart,strat)}));drawRtichokeCurve(specs[0],chart,strat)" + if legacy_tabs not in html: + raise RuntimeError("Could not wire summary-report discrimination curves") + html = html.replace(legacy_tabs, wired_tabs, 1) + + legacy_decision = "draw(R.decision,'#decision','probability_threshold');" + if legacy_decision not in html: + raise RuntimeError("Could not wire summary-report decision curve") + html = html.replace( + legacy_decision, + "drawRtichokeCurve(R.decision,'#decision','probability_threshold');", + 1, + ) return html From 67e2e2798a4a3f1ad59aae03837e3ad15a364813 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 14:28:58 +0300 Subject: [PATCH 131/153] Match R Plotly curve widget and slider geometry --- src/rtichoke/summary_report/curve_renderer.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/rtichoke/summary_report/curve_renderer.js b/src/rtichoke/summary_report/curve_renderer.js index 39d04b7f..fd5706f7 100644 --- a/src/rtichoke/summary_report/curve_renderer.js +++ b/src/rtichoke/summary_report/curve_renderer.js @@ -1,16 +1,17 @@ /* D3 renderer for performance and decision curves in the lightweight report. * Mirrors the R Plotly geometry used by create_summary_report(): a 500px-wide, - * 550px-high widget with no internal title. ROC/Lift/etc. are tab headings. + * 550px-high widget with the animation slider inside the widget and no internal + * title. ROC/Lift/etc. are supplied by the tab headings. */ function drawRtichokeCurve(s, sel, strat) { const card=d3.select(sel); card.selectAll("*").remove(); - card.style("width","500px").style("max-width","100%").style("margin-left","0").style("margin-right","0"); - const W=500,H=550,m={top:25,right:10,bottom:40,left:60}; - const svg=card.append("svg").style("width","500px").style("max-width","100%").style("margin","0").attr("viewBox",`0 0 ${W} ${H}`),x=d3.scaleLinear().domain(s.x_range).range([m.left,W-m.right]),y=d3.scaleLinear().domain(s.y_range).range([H-m.bottom,m.top]); + card.style("width","500px").style("height","550px").style("max-width","100%").style("margin-left","0").style("margin-right","0").style("position","relative"); + const W=500,H=550,m={top:25,right:10,bottom:120,left:60}; + const svg=card.append("svg").style("width","500px").style("height","550px").style("max-width","100%").style("margin","0").attr("viewBox",`0 0 ${W} ${H}`),x=d3.scaleLinear().domain(s.x_range).range([m.left,W-m.right]),y=d3.scaleLinear().domain(s.y_range).range([H-m.bottom,m.top]); const line=d3.line().defined(d=>isFinite(+d.x)&&isFinite(+d.y)).x(d=>x(+d.x)).y(d=>y(+d.y)); const styleAxis=a=>{a.attr("font-family","Open Sans, verdana, arial, sans-serif").attr("font-size",12).attr("color","#444");a.select(".domain").attr("stroke","#444");a.selectAll(".tick line").attr("stroke","#444")}; const xa=svg.append("g").attr("class","axis").attr("transform",`translate(0,${H-m.bottom})`).call(d3.axisBottom(x).ticks(6)),ya=svg.append("g").attr("class","axis").attr("transform",`translate(${m.left},0)`).call(d3.axisLeft(y).ticks(6)); styleAxis(xa);styleAxis(ya); - svg.append("text").attr("class","axis-label").attr("x",(m.left+W-m.right)/2).attr("y",H-8).attr("text-anchor","middle").text(s.x_label); + svg.append("text").attr("class","axis-label").attr("x",(m.left+W-m.right)/2).attr("y",H-m.bottom+42).attr("text-anchor","middle").text(s.x_label); svg.append("text").attr("class","axis-label").attr("transform","rotate(-90)").attr("x",-(m.top+H-m.bottom)/2).attr("y",18).attr("text-anchor","middle").text(s.y_label); const strategyColor=g=>{const k=String(g||"").toLowerCase();if(k==="treat_none")return "#808080";return s.colors[g]||"#BEBEBE"}; const traces=[]; @@ -35,10 +36,10 @@ function drawRtichokeCurve(s, sel, strat) { }; if(strata.length>1){ - const wrap=card.append("div").attr("class","slider-wrap"); - const label=wrap.append("div").attr("class","slider-label"); - const prefix=strat==="ppcr"?"Predicted Positives (Rate):":"Probability Threshold:"; - const input=wrap.append("input").attr("type","range").attr("min",strata[0]).attr("max",strata[strata.length-1]).attr("step",Math.max(1e-6,...strata.slice(1).map((v,i)=>v-strata[i]).filter(v=>v>0).slice(0,1))).node(); + const wrap=card.append("div").attr("class","slider-wrap curve-slider-wrap"); + const label=wrap.append("div").attr("class","slider-label curve-slider-label"); + const prefix=strat==="ppcr"?"Predicted Positives (Rate):":"Prob. Threshold:"; + const input=wrap.append("input").attr("class","curve-slider").attr("type","range").attr("min",strata[0]).attr("max",strata[strata.length-1]).attr("step",Math.max(1e-6,...strata.slice(1).map((v,i)=>v-strata[i]).filter(v=>v>0).slice(0,1))).node(); input.value=strata[0]; const update=()=>{const v=+input.value;label.textContent=`${prefix} ${Number.isFinite(v)?v.toFixed(2):""}`;drawCurrent(v)}; input.addEventListener("input",update);update(); From 57c1f996356d9e4da9b2d41ed5c622203cad981d Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 14:29:30 +0300 Subject: [PATCH 132/153] Keep curve slider inside the R-sized widget --- src/rtichoke/summary_report/curve_renderer.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/rtichoke/summary_report/curve_renderer.js b/src/rtichoke/summary_report/curve_renderer.js index fd5706f7..2d2be486 100644 --- a/src/rtichoke/summary_report/curve_renderer.js +++ b/src/rtichoke/summary_report/curve_renderer.js @@ -36,10 +36,10 @@ function drawRtichokeCurve(s, sel, strat) { }; if(strata.length>1){ - const wrap=card.append("div").attr("class","slider-wrap curve-slider-wrap"); - const label=wrap.append("div").attr("class","slider-label curve-slider-label"); + const wrap=card.append("div").attr("class","slider-wrap curve-slider-wrap").style("position","absolute").style("left","60px").style("bottom","12px").style("width","430px").style("max-width","calc(100% - 70px)").style("margin","0"); + const label=wrap.append("div").attr("class","slider-label curve-slider-label").style("font-size","12px").style("line-height","18px").style("color","black").style("margin","0 0 3px"); const prefix=strat==="ppcr"?"Predicted Positives (Rate):":"Prob. Threshold:"; - const input=wrap.append("input").attr("class","curve-slider").attr("type","range").attr("min",strata[0]).attr("max",strata[strata.length-1]).attr("step",Math.max(1e-6,...strata.slice(1).map((v,i)=>v-strata[i]).filter(v=>v>0).slice(0,1))).node(); + const input=wrap.append("input").attr("class","curve-slider").attr("type","range").attr("min",strata[0]).attr("max",strata[strata.length-1]).attr("step",Math.max(1e-6,...strata.slice(1).map((v,i)=>v-strata[i]).filter(v=>v>0).slice(0,1))).style("width","100%").style("margin","0").style("accent-color","#777").node(); input.value=strata[0]; const update=()=>{const v=+input.value;label.textContent=`${prefix} ${Number.isFinite(v)?v.toFixed(2):""}`;drawCurrent(v)}; input.addEventListener("input",update);update(); From ebd2159ff9d478279408f12c8c924d1618b410ec Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 14:29:57 +0300 Subject: [PATCH 133/153] Test in-widget R-style curve slider --- tests/test_summary_report_prevalence.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_summary_report_prevalence.py b/tests/test_summary_report_prevalence.py index afccf7cf..52e97a70 100644 --- a/tests/test_summary_report_prevalence.py +++ b/tests/test_summary_report_prevalence.py @@ -80,10 +80,11 @@ def test_summary_report_has_r_style_curve_geometry_and_strata_slider(tmp_path): create_summary_report(probs, reals, output_file=output, by=0.1) html = output.read_text(encoding="utf-8") - assert 'const W=500,H=550,m={top:25,right:10,bottom:40,left:60}' in html - assert 'attr("class","slider-wrap")' in html + assert 'const W=500,H=550,m={top:25,right:10,bottom:120,left:60}' in html + assert 'curve-slider-wrap' in html + assert '.style("bottom","12px")' in html assert '"Predicted Positives (Rate):"' in html - assert '"Probability Threshold:"' in html + assert '"Prob. Threshold:"' in html assert 'drawRtichokeCurve(specs[0],chart,strat)' in html assert "drawRtichokeCurve(R.decision,'#decision','probability_threshold')" in html assert 'drawRtichokeCurve(s,chart,strat)' in html From 9b72469931433b779096709a11620df8601335be Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 14:30:54 +0300 Subject: [PATCH 134/153] Match R Crosstalk filter row layout --- .../summary_report/performance_table_renderer.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/rtichoke/summary_report/performance_table_renderer.js b/src/rtichoke/summary_report/performance_table_renderer.js index f531db99..7ae3125d 100644 --- a/src/rtichoke/summary_report/performance_table_renderer.js +++ b/src/rtichoke/summary_report/performance_table_renderer.js @@ -50,8 +50,10 @@ const selected=new Set(); let lower=minValue, upper=maxValue, page=0; - const filters=document.createElement("div"); filters.className="rt-filters"; - const modelFilter=document.createElement("div"); modelFilter.className="rt-filter-models"; + // R crosstalk::bscols(widths = c(12, 6, 12)): group selector on a + // full row, slider on a half-width row, then the full-width Reactable. + const filters=document.createElement("div"); filters.className="rt-filters"; filters.style.display="block"; filters.style.marginBottom="15px"; + const modelFilter=document.createElement("div"); modelFilter.className="rt-filter-models"; modelFilter.style.width="100%"; modelFilter.style.marginBottom="15px"; const modelLabel=document.createElement("div"); modelLabel.className="rt-filter-label"; modelLabel.textContent="Model"; modelFilter.appendChild(modelLabel); models.forEach((model,i)=>{ const label=document.createElement("label"); label.className="rt-check-inline"; @@ -59,14 +61,16 @@ const text=document.createElement("span"); text.textContent=model; label.append(input,text); modelFilter.appendChild(label); input.addEventListener("change",()=>{input.checked?selected.add(model):selected.delete(model);page=0;drawPage();}); }); - const rangeFilter=document.createElement("div"); rangeFilter.className="rt-filter-range"; + const rangeFilter=document.createElement("div"); rangeFilter.className="rt-filter-range"; rangeFilter.style.width="50%"; rangeFilter.style.maxWidth="520px"; rangeFilter.style.minWidth="300px"; const rangeLabel=document.createElement("div"); rangeLabel.className="rt-filter-label"; rangeLabel.textContent=isPpcr?"Predicted Positives Condition Rate (PPCR)":"Probability Threshold"; const rangeReadout=document.createElement("span"); rangeReadout.className="rt-range-readout"; const track=document.createElement("div"); track.className="rt-dual-range"; const lo=document.createElement("input"), hi=document.createElement("input"); [lo,hi].forEach(input=>{input.type="range";input.min=minValue;input.max=maxValue;input.step=step;}); lo.value=minValue; hi.value=maxValue; const sync=()=>{lower=Math.min(+lo.value,+hi.value);upper=Math.max(+lo.value,+hi.value);rangeReadout.textContent=`${fmt(lower)} – ${fmt(upper)}`;page=0;drawPage();}; - lo.addEventListener("input",sync); hi.addEventListener("input",sync); track.append(lo,hi); rangeFilter.append(rangeLabel,rangeReadout,track); filters.append(modelFilter,rangeFilter); host.appendChild(filters); + lo.addEventListener("input",sync); hi.addEventListener("input",sync); track.append(lo,hi); rangeFilter.append(rangeLabel,rangeReadout,track); + if(models.length>1) filters.appendChild(modelFilter); + filters.appendChild(rangeFilter); host.appendChild(filters); rangeReadout.textContent=`${fmt(lower)} – ${fmt(upper)}`; const wrap=document.createElement("div"); wrap.className="rt-perf-wrap"; From 2676152a2a82fb4471534c3d6a4231fbf0e8e11d Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 14:31:55 +0300 Subject: [PATCH 135/153] Match R report TOC hierarchy --- .../summary_report/summary_report_v2.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/rtichoke/summary_report/summary_report_v2.py b/src/rtichoke/summary_report/summary_report_v2.py index f0bb6f24..1e87e324 100644 --- a/src/rtichoke/summary_report/summary_report_v2.py +++ b/src/rtichoke/summary_report/summary_report_v2.py @@ -62,6 +62,38 @@ def _wire_report_style(html: str) -> str: return styled +def _wire_r_toc(html: str) -> str: + """Mirror the nested H1-H3 TOC generated by the R Markdown report.""" + heading = "

Performance Metrics Curves

" + if html.count(heading) >= 2: + html = html.replace(heading, '

Performance Metrics Curves

', 1) + html = html.replace(heading, '

Performance Metrics Curves

', 1) + + toc = """""" + wired, count = re.subn(r'
.*?
', toc, html, count=1, flags=re.DOTALL) + if count != 1: + raise RuntimeError("Could not locate summary-report table of contents") + return wired + + def _wire_r_report_content(html: str, probs: Dict[str, np.ndarray], reals: Union[np.ndarray, Dict[str, np.ndarray]]) -> str: formulas = """
Prevalence = TP + FNTP + FP + TN + FN
@@ -120,6 +152,7 @@ def create_summary_report(probs: Dict[str, np.ndarray], reals: Union[np.ndarray, html = out.read_text(encoding="utf-8") html = _wire_curve_renderer(html) html = _wire_performance_table_renderer(html) + html = _wire_r_toc(html) html = _wire_r_report_content(html, probs, reals) html = _wire_report_style(html) out.write_text(html, encoding="utf-8") From c0c2407f91a848ad2cc3c46870c09c1b19b7505a Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 14:33:13 +0300 Subject: [PATCH 136/153] Share exact preview data with R reference --- examples/summary_report_demo.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/examples/summary_report_demo.py b/examples/summary_report_demo.py index 921a4670..47994bee 100644 --- a/examples/summary_report_demo.py +++ b/examples/summary_report_demo.py @@ -1,5 +1,7 @@ """Generate the summary-report proof of concept used by PR previews.""" +import csv + import numpy as np from rtichoke import create_summary_report @@ -14,4 +16,11 @@ "Model B": np.clip(1 / (1 + np.exp(-(0.6 * signal + rng.normal(0, 0.85, n)))), 0.001, 0.999), } +# The PR preview renders the canonical R report from this exact dataset. Using +# one serialized dataset avoids NumPy/R RNG differences obscuring visual parity. +with open("summary-report-reference-data.csv", "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(["reals", "Model A", "Model B"]) + writer.writerows(zip(reals, probs["Model A"], probs["Model B"], strict=True)) + create_summary_report(probs, reals, output_file="summary-report-demo.html") From 4bc5ed72a1cbb75dbb984e2a7c634d09ca2a6f67 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 14:33:32 +0300 Subject: [PATCH 137/153] Render Python and R preview from identical data --- .github/workflows/docs.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1c20c0eb..49ef660e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -90,6 +90,7 @@ jobs: run: | uv run python examples/summary_report_demo.py test -s summary-report-demo.html + test -s summary-report-reference-data.csv mv summary-report-demo.html great-docs/_site/summary-report-demo.html - name: Set up R for reference report if: github.event.action != 'closed' @@ -111,14 +112,11 @@ jobs: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} shell: Rscript {0} run: | - set.seed(2026) - n <- 800 - signal <- rnorm(n) - p_true <- plogis(signal) - reals <- rbinom(n, 1, p_true) + dat <- read.csv("summary-report-reference-data.csv", check.names = FALSE) + reals <- dat[["reals"]] probs <- list( - "Model A" = pmin(pmax(plogis(0.9 * signal + rnorm(n, 0, 0.55)), 0.001), 0.999), - "Model B" = pmin(pmax(plogis(0.6 * signal + rnorm(n, 0, 0.85)), 0.001), 0.999) + "Model A" = dat[["Model A"]], + "Model B" = dat[["Model B"]] ) rtichoke::create_summary_report( probs = probs, From f78b369af19aa5c5160a2e2a23c7370378efaacc Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 14:36:16 +0300 Subject: [PATCH 138/153] Match R Reactable performance column order --- .../performance_table_renderer.js | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/rtichoke/summary_report/performance_table_renderer.js b/src/rtichoke/summary_report/performance_table_renderer.js index 7ae3125d..8c0771fc 100644 --- a/src/rtichoke/summary_report/performance_table_renderer.js +++ b/src/rtichoke/summary_report/performance_table_renderer.js @@ -75,8 +75,11 @@ const wrap=document.createElement("div"); wrap.className="rt-perf-wrap"; const table=document.createElement("table"); table.className="rt-perf"; - const metricCount=isPpcr?5:6; - table.innerHTML=`Model${isPpcr?"":"Probability Threshold"}Predicted PositivesPerformance MetricsSensSpecPPVNPVLift${isPpcr?"":"Net Benefit"}`; + if(isPpcr) { + table.innerHTML='Predicted PositivesModelPerformance MetricsNet BenefitSensSpecPPVNPVLift'; + } else { + table.innerHTML='Probability ThresholdModelPerformance MetricsPredicted PositivesSensSpecPPVNPVLiftNet Benefit'; + } const body=document.createElement("tbody"); table.appendChild(body); wrap.appendChild(table); host.appendChild(wrap); const pager=document.createElement("div"); pager.className="rt-pager"; host.appendChild(pager); @@ -92,14 +95,27 @@ const pages=Math.max(1,Math.ceil(filtered.length/PAGE_SIZE)); if(page>=pages) page=pages-1; body.innerHTML=""; const start=page*PAGE_SIZE, pageRows=filtered.slice(start,start+PAGE_SIZE); - pageRows.forEach(r=>{ + let previousThreshold=null; + pageRows.forEach((r,rowIndex)=>{ const tr=document.createElement("tr"); const model=String(r.reference_group ?? ""); const ppcrText=`${fmt(r.predicted_positives)} (${pct(r.ppcr)})`; + const modelCell=`${esc(model)}`; const metrics=[["sensitivity",1],["specificity",1],["ppv",1],["npv",1],["lift",liftMax]]; - tr.innerHTML=`›${esc(model)}${isPpcr?"":`${fmt(r.chosen_cutoff)}`}${ppcrText}${metrics.map(([k,m])=>`${fmt(r[k])}`).join("")}${isPpcr?"":`${fmt(r.net_benefit)}`}`; + const metricCells=metrics.map(([k,m])=>`${fmt(r[k])}`).join(""); + const nbCell=`${fmt(r.net_benefit)}`; + const ppcrCell=`${ppcrText}`; + if(isPpcr) { + tr.innerHTML=`›${ppcrCell}${modelCell}${metricCells}${nbCell}`; + } else { + const threshold=fmt(r.chosen_cutoff); + const repeated=rowIndex>0 && Math.abs(num(r.chosen_cutoff)-previousThreshold)<1e-12; + const thresholdCell=`${threshold}`; + tr.innerHTML=`›${thresholdCell}${modelCell}${metricCells}${nbCell}${ppcrCell}`; + previousThreshold=num(r.chosen_cutoff); + } const detail=document.createElement("tr"); detail.className="detail"; detail.style.display="none"; - const td=document.createElement("td"); td.colSpan=isPpcr?8:10; td.innerHTML=confusionMatrix(r); detail.appendChild(td); + const td=document.createElement("td"); td.colSpan=isPpcr?9:10; td.innerHTML=confusionMatrix(r); detail.appendChild(td); tr.querySelector(".expand").addEventListener("click",e=>{const open=detail.style.display!=="none"; detail.style.display=open?"none":"table-row"; e.currentTarget.textContent=open?"›":"⌄";}); body.appendChild(tr); body.appendChild(detail); }); From 709823432c4e6937e94c042a95a91f654a9d6a03 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 15:38:55 +0300 Subject: [PATCH 139/153] Match R performance controls more closely --- .../performance_table_renderer.js | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/src/rtichoke/summary_report/performance_table_renderer.js b/src/rtichoke/summary_report/performance_table_renderer.js index 8c0771fc..e66b6d37 100644 --- a/src/rtichoke/summary_report/performance_table_renderer.js +++ b/src/rtichoke/summary_report/performance_table_renderer.js @@ -7,6 +7,28 @@ const num = v => typeof v === "number" && isFinite(v) ? v : 0; const esc = v => String(v ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c])); + if (!document.getElementById("rt-perf-control-style")) { + const style=document.createElement("style"); + style.id="rt-perf-control-style"; + style.textContent=` + .rt-check-inline{display:inline-flex!important;align-items:center;padding-left:0!important;margin-right:14px!important;gap:6px} + .rt-check-inline input{position:absolute!important;opacity:0;pointer-events:none;margin:0!important} + .rt-check-box{width:16px;height:16px;border:2px solid #333;border-radius:2px;display:grid;place-content:center;background:#fff;flex:0 0 auto} + .rt-check-box>span{width:10px;height:10px;transform:scale(0);transition:transform .08s linear} + .rt-dual-range{position:relative!important;height:50px!important;margin-top:2px!important} + .rt-range-track{position:absolute;left:8px;right:8px;top:29px;height:6px;background:#e1e1e1;border-radius:3px} + .rt-range-fill{position:absolute;top:0;height:6px;background:#337ab7;border-radius:3px} + .rt-range-bubble{position:absolute;top:0;transform:translateX(-50%);padding:1px 5px;min-width:34px;text-align:center;background:#337ab7;color:#fff;border-radius:3px;font-size:11px;line-height:18px;white-space:nowrap} + .rt-dual-range input[type=range]{-webkit-appearance:none;appearance:none;position:absolute!important;left:0!important;top:20px!important;width:100%!important;height:24px;margin:0!important;background:transparent!important;pointer-events:none!important;outline:none} + .rt-dual-range input[type=range]::-webkit-slider-runnable-track{height:6px;background:transparent;border:0} + .rt-dual-range input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:18px;height:18px;margin-top:-6px;border:1px solid #999;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);pointer-events:auto} + .rt-dual-range input[type=range]::-moz-range-track{height:6px;background:transparent;border:0} + .rt-dual-range input[type=range]::-moz-range-thumb{width:18px;height:18px;border:1px solid #999;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);pointer-events:auto} + .rt-range-readout{display:none!important} + `; + document.head.appendChild(style); + } + function metricBackground(value, maxValue=1, color="lightgreen") { if (!isFinite(+value) || maxValue <= 0) return ""; const width = Math.min(Math.abs(+value) / maxValue, 1) * 100; @@ -57,26 +79,37 @@ const modelLabel=document.createElement("div"); modelLabel.className="rt-filter-label"; modelLabel.textContent="Model"; modelFilter.appendChild(modelLabel); models.forEach((model,i)=>{ const label=document.createElement("label"); label.className="rt-check-inline"; - const input=document.createElement("input"); input.type="checkbox"; input.value=model; input.style.setProperty("--rt-check-color",colors[model]||COLORS[i%COLORS.length]); - const text=document.createElement("span"); text.textContent=model; label.append(input,text); modelFilter.appendChild(label); - input.addEventListener("change",()=>{input.checked?selected.add(model):selected.delete(model);page=0;drawPage();}); + const input=document.createElement("input"); input.type="checkbox"; input.value=model; + const box=document.createElement("span"); box.className="rt-check-box"; + const boxFill=document.createElement("span"); boxFill.style.background=colors[model]||COLORS[i%COLORS.length]; box.appendChild(boxFill); + const text=document.createElement("span"); text.textContent=model; label.append(input,box,text); modelFilter.appendChild(label); + input.addEventListener("change",()=>{input.checked?selected.add(model):selected.delete(model);boxFill.style.transform=input.checked?"scale(1)":"scale(0)";page=0;drawPage();}); }); const rangeFilter=document.createElement("div"); rangeFilter.className="rt-filter-range"; rangeFilter.style.width="50%"; rangeFilter.style.maxWidth="520px"; rangeFilter.style.minWidth="300px"; const rangeLabel=document.createElement("div"); rangeLabel.className="rt-filter-label"; rangeLabel.textContent=isPpcr?"Predicted Positives Condition Rate (PPCR)":"Probability Threshold"; const rangeReadout=document.createElement("span"); rangeReadout.className="rt-range-readout"; const track=document.createElement("div"); track.className="rt-dual-range"; + const rail=document.createElement("div"); rail.className="rt-range-track"; + const fill=document.createElement("div"); fill.className="rt-range-fill"; rail.appendChild(fill); + const loBubble=document.createElement("span"), hiBubble=document.createElement("span"); loBubble.className=hiBubble.className="rt-range-bubble"; const lo=document.createElement("input"), hi=document.createElement("input"); [lo,hi].forEach(input=>{input.type="range";input.min=minValue;input.max=maxValue;input.step=step;}); lo.value=minValue; hi.value=maxValue; - const sync=()=>{lower=Math.min(+lo.value,+hi.value);upper=Math.max(+lo.value,+hi.value);rangeReadout.textContent=`${fmt(lower)} – ${fmt(upper)}`;page=0;drawPage();}; - lo.addEventListener("input",sync); hi.addEventListener("input",sync); track.append(lo,hi); rangeFilter.append(rangeLabel,rangeReadout,track); + const sync=()=>{ + lower=Math.min(+lo.value,+hi.value);upper=Math.max(+lo.value,+hi.value);rangeReadout.textContent=`${fmt(lower)} – ${fmt(upper)}`; + const span=maxValue-minValue||1, lp=100*(lower-minValue)/span, hp=100*(upper-minValue)/span; + fill.style.left=`${lp}%`;fill.style.width=`${Math.max(0,hp-lp)}%`; + loBubble.textContent=fmt(lower);hiBubble.textContent=fmt(upper);loBubble.style.left=`${lp}%`;hiBubble.style.left=`${hp}%`; + page=0;drawPage(); + }; + lo.addEventListener("input",sync); hi.addEventListener("input",sync); track.append(rail,loBubble,hiBubble,lo,hi); rangeFilter.append(rangeLabel,rangeReadout,track); if(models.length>1) filters.appendChild(modelFilter); filters.appendChild(rangeFilter); host.appendChild(filters); - rangeReadout.textContent=`${fmt(lower)} – ${fmt(upper)}`; + sync(); const wrap=document.createElement("div"); wrap.className="rt-perf-wrap"; const table=document.createElement("table"); table.className="rt-perf"; if(isPpcr) { - table.innerHTML='Predicted PositivesModelPerformance MetricsNet BenefitSensSpecPPVNPVLift'; + table.innerHTML='ModelPredicted PositivesPerformance MetricsNet BenefitSensSpecPPVNPVLift'; } else { table.innerHTML='Probability ThresholdModelPerformance MetricsPredicted PositivesSensSpecPPVNPVLiftNet Benefit'; } @@ -106,7 +139,7 @@ const nbCell=`${fmt(r.net_benefit)}`; const ppcrCell=`${ppcrText}`; if(isPpcr) { - tr.innerHTML=`›${ppcrCell}${modelCell}${metricCells}${nbCell}`; + tr.innerHTML=`›${modelCell}${ppcrCell}${metricCells}${nbCell}`; } else { const threshold=fmt(r.chosen_cutoff); const repeated=rowIndex>0 && Math.abs(num(r.chosen_cutoff)-previousThreshold)<1e-12; From fa3b861c0b5b4b5bf8fd1f2ba8869c215dfa0213 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 15:39:27 +0300 Subject: [PATCH 140/153] Test R-style performance control parity --- tests/test_summary_report_prevalence.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_summary_report_prevalence.py b/tests/test_summary_report_prevalence.py index 52e97a70..def59741 100644 --- a/tests/test_summary_report_prevalence.py +++ b/tests/test_summary_report_prevalence.py @@ -66,7 +66,10 @@ def test_summary_report_has_r_style_performance_filters(tmp_path): assert '"Probability Threshold"' in html assert '"Predicted Positives Condition Rate (PPCR)"' in html assert 'className="rt-dual-range"' in html + assert 'className="rt-check-box"' in html + assert 'className="rt-range-bubble"' in html assert "selected.has(model)" in html + assert 'ModelPredicted Positives' in html def test_summary_report_has_r_style_curve_geometry_and_strata_slider(tmp_path): From 2b4726bd8142ddb790cc8c48a05026abb2a9afdd Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 15:40:44 +0300 Subject: [PATCH 141/153] Fix initial performance slider render --- src/rtichoke/summary_report/performance_table_renderer.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/rtichoke/summary_report/performance_table_renderer.js b/src/rtichoke/summary_report/performance_table_renderer.js index e66b6d37..4ef0fa5c 100644 --- a/src/rtichoke/summary_report/performance_table_renderer.js +++ b/src/rtichoke/summary_report/performance_table_renderer.js @@ -94,17 +94,17 @@ const loBubble=document.createElement("span"), hiBubble=document.createElement("span"); loBubble.className=hiBubble.className="rt-range-bubble"; const lo=document.createElement("input"), hi=document.createElement("input"); [lo,hi].forEach(input=>{input.type="range";input.min=minValue;input.max=maxValue;input.step=step;}); lo.value=minValue; hi.value=maxValue; - const sync=()=>{ + const sync=(redraw=true)=>{ lower=Math.min(+lo.value,+hi.value);upper=Math.max(+lo.value,+hi.value);rangeReadout.textContent=`${fmt(lower)} – ${fmt(upper)}`; const span=maxValue-minValue||1, lp=100*(lower-minValue)/span, hp=100*(upper-minValue)/span; fill.style.left=`${lp}%`;fill.style.width=`${Math.max(0,hp-lp)}%`; loBubble.textContent=fmt(lower);hiBubble.textContent=fmt(upper);loBubble.style.left=`${lp}%`;hiBubble.style.left=`${hp}%`; - page=0;drawPage(); + if(redraw){page=0;drawPage();} }; lo.addEventListener("input",sync); hi.addEventListener("input",sync); track.append(rail,loBubble,hiBubble,lo,hi); rangeFilter.append(rangeLabel,rangeReadout,track); if(models.length>1) filters.appendChild(modelFilter); filters.appendChild(rangeFilter); host.appendChild(filters); - sync(); + sync(false); const wrap=document.createElement("div"); wrap.className="rt-perf-wrap"; const table=document.createElement("table"); table.className="rt-perf"; From 17d7c8be0f8300b7717305df001baf8645bf6f92 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 15:47:57 +0300 Subject: [PATCH 142/153] Match R Shiny range-slider styling --- src/rtichoke/summary_report/report_style.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/rtichoke/summary_report/report_style.css b/src/rtichoke/summary_report/report_style.css index 045e4809..d1890166 100644 --- a/src/rtichoke/summary_report/report_style.css +++ b/src/rtichoke/summary_report/report_style.css @@ -22,4 +22,11 @@ table { width:100%; border-collapse:collapse; background:#fff; font-size:13px; m .perf-wrap { overflow:auto; max-height:620px; border:1px solid #ddd; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.perf { width:100%; margin:0; border-collapse:separate; border-spacing:0; font-size:14px; }.perf th { position:sticky; top:0; background:#fff; z-index:2; white-space:nowrap; border-bottom:1px solid #ddd; font-weight:600; text-align:left; padding:8px 10px; }.perf .group-head th { text-align:center; }.perf .column-head th { top:35px; }.perf td { border-bottom:1px solid #eee; text-align:left; padding:8px 10px; white-space:nowrap; }.perf tbody tr.data-row:hover { background:#f5f5f5; }.perf .model { text-align:left; }.model-badge { display:inline-block; margin-right:8px; width:9px; height:9px; border-radius:50%; vertical-align:1px; }.metric-cell { position:relative; isolation:isolate; min-width:72px; }.metric-cell::before { content:""; position:absolute; z-index:-1; left:0; top:0; bottom:0; width:var(--bar,0%); background:var(--bar-color,lightgreen); }.expand { width:30px; cursor:pointer; font-size:18px; color:#777; text-align:center!important; }.detail td { text-align:left; background:#fff; padding:16px; }.cm-title { font-weight:600; margin-bottom:8px; }.cm { display:inline-grid; grid-template-columns:auto auto; gap:3px; margin-left:10px; }.cm span { padding:5px 10px; min-width:72px; text-align:center; }.pos { background:lightgreen; }.neg { background:pink; } .rt-filters { display:grid; grid-template-columns:minmax(0,1fr) minmax(300px,1fr); gap:30px; align-items:end; margin:0 0 15px; font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; }.rt-filter-label { display:block; margin-bottom:4px; font-weight:700; }.rt-check-inline { position:relative; display:inline-block; padding-left:20px; margin-right:10px; font-weight:400; vertical-align:middle; cursor:pointer; }.rt-check-inline input { position:absolute; margin:2px 0 0 -20px; accent-color:var(--rt-check-color,#1b9e77); }.rt-filter-range { position:relative; }.rt-range-readout { float:right; margin-top:-24px; color:#555; font-size:12px; }.rt-dual-range { position:relative; height:32px; margin-top:4px; }.rt-dual-range input[type=range] { position:absolute; left:0; top:4px; width:100%; margin:0; background:transparent; pointer-events:none; }.rt-dual-range input[type=range]::-webkit-slider-thumb { pointer-events:auto; }.rt-dual-range input[type=range]::-moz-range-thumb { pointer-events:auto; } .rt-perf-wrap { overflow:auto; border:1px solid #e5e5e5; border-radius:3px; background:#fff; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.rt-perf { width:100%; border-collapse:separate; border-spacing:0; margin:0; font-size:14px; }.rt-perf th,.rt-perf td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; white-space:nowrap; position:relative; }.rt-perf thead th { background:#fff; font-weight:600; color:#333; }.rt-perf .metric-group { text-align:center; border-bottom:1px solid #ddd; }.rt-perf .model-dot { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:8px; vertical-align:1px; }.rt-perf .expand { width:28px; text-align:center; color:#777; cursor:pointer; font-size:18px; padding-left:6px; padding-right:6px; }.rt-perf .bar-cell { background-repeat:no-repeat; background-position:center; background-size:98% 88%; }.rt-perf .detail td { background:#fafafa; padding:16px; }.rt-conf { display:inline-table; border-collapse:collapse; margin:4px 0 4px 8px; vertical-align:middle; }.rt-conf th,.rt-conf td { padding:6px 10px; border:1px solid #eee; text-align:left; min-width:105px; }.rt-conf th { position:static; background:#fff; font-weight:600; }.rt-conf .outcome { font-weight:600; }.rt-pager { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:8px 0; font-size:13px; color:#555; }.rt-page-controls { display:flex; gap:4px; align-items:center; }.rt-page-controls button { border:1px solid transparent; background:#fff; color:#337ab7; padding:5px 9px; border-radius:3px; font:inherit; cursor:pointer; }.rt-page-controls button:hover:not(:disabled) { background:#eee; }.rt-page-controls button.active { background:#337ab7; color:#fff; }.rt-page-controls button:disabled { color:#aaa; cursor:default; } +/* Match the Shiny IonRangeSlider theme embedded by the R report. */ +.rt-range-track { top:25px !important; height:8px !important; background:linear-gradient(to bottom,#dedede -50%,#fff 150%) !important; background-color:#ededed !important; border:1px solid #ccc !important; border-radius:8px !important; } +.rt-range-fill { height:8px !important; background:#428bca !important; border-top:1px solid #428bca !important; border-bottom:1px solid #428bca !important; } +.rt-range-bubble { padding:1px 3px !important; background:#428bca !important; color:#fff !important; } +.rt-dual-range input[type=range] { top:16px !important; } +.rt-dual-range input[type=range]::-webkit-slider-thumb { width:22px !important; height:22px !important; margin-top:-7px !important; border:1px solid #ababab !important; background:#dedede !important; border-radius:22px !important; box-shadow:1px 1px 3px rgba(255,255,255,.3) !important; } +.rt-dual-range input[type=range]::-moz-range-thumb { width:22px !important; height:22px !important; border:1px solid #ababab !important; background:#dedede !important; border-radius:22px !important; box-shadow:1px 1px 3px rgba(255,255,255,.3) !important; } @media(max-width:760px){.main-container{padding-left:15px;padding-right:15px}h1{font-size:30px}h2{font-size:26px}h3{font-size:20px}.nav-tabs button{padding:8px 10px}table{font-size:12px}.metric-formulas{font-size:14px}.auc-table th,.auc-table td{min-width:0}.auc-table .prevalence-track{min-width:80px}.rt-filters{grid-template-columns:1fr;gap:12px}} From 86f89d3a0e3f5110304f7edd2c36986abffaf195 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 15:49:32 +0300 Subject: [PATCH 143/153] Left-align R-sized report plots --- src/rtichoke/summary_report/report_style.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rtichoke/summary_report/report_style.css b/src/rtichoke/summary_report/report_style.css index d1890166..7024a5f0 100644 --- a/src/rtichoke/summary_report/report_style.css +++ b/src/rtichoke/summary_report/report_style.css @@ -14,8 +14,8 @@ details { margin:0 0 20px; } summary { cursor:pointer; } summary p { display:inl .metric-formulas { margin:28px 0 18px; font-family:"STIXGeneral-Regular","Times New Roman",serif; font-size:16px; }.metric-formulas>div { margin:23px 0; white-space:normal; }.frac { display:inline-flex; vertical-align:middle; flex-direction:column; text-align:center; line-height:1.15; margin:0 .22em; }.frac>span:first-child { border-bottom:1px solid #333; padding:0 .18em .08em; }.frac>span:last-child { padding:.08em .18em 0; } #prev { width:345px; max-width:100%; margin:28px 0 20px; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.prevalence-row { display:grid; grid-template-columns:45px 300px; border-bottom:1px solid #eee; position:relative; }.prevalence-expander { grid-row:1; width:45px; border:0; background:#fff; color:#777; font-size:18px; cursor:pointer; }.prevalence-cell { grid-row:1; padding:7px 10px; }.prevalence-cell>strong { display:block; border-bottom:1px solid #ddd; padding-bottom:7px; margin-bottom:7px; font-weight:600; }.prevalence-value { display:flex; align-items:center; }.prevalence-track { flex:1; margin-left:8px; background:#e1e1e1; height:16px; }.prevalence-track>span { display:block; background:grey; height:16px; }.prevalence-detail { grid-column:1/3; padding:12px 45px; border-top:1px solid #eee; } .summary-table { min-width:310px; border:1px solid #eee; }.summary-table th,.summary-table td { border-bottom:1px solid #eee; }.auc-table { width:600px; max-width:100%; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.auc-table th,.auc-table td { min-width:300px; text-align:left; }.auc-table .prevalence-track { min-width:180px; } -.nav-tabs { display:flex; flex-wrap:wrap; gap:0; list-style:none; padding-left:0; margin:0 0 20px; border-bottom:1px solid var(--rt-border); }.nav-tabs button { position:relative; display:block; padding:10px 15px; margin:0 2px -1px 0; color:#337ab7; background:transparent; border:1px solid transparent; border-radius:4px 4px 0 0; font:inherit; line-height:1.42857143; cursor:pointer; }.nav-tabs button:hover { background:#eee; border-color:#eee #eee var(--rt-border); }.nav-tabs button.active { color:#555; background:#fff; border:1px solid var(--rt-border); border-bottom-color:transparent; cursor:default; }.panel { background:var(--rt-panel); border:0; border-radius:0; box-shadow:none; }.panel:not(.active) { display:none; }.chart,.panel { min-width:0; }.chart { width:550px; max-width:100%; margin:0 auto 20px; } -svg { display:block; width:100%; height:auto; } #calibration svg { width:min(100%,550px); margin:0 auto; } #discrimination svg,#utility svg,#utility-decision-curve svg { width:min(100%,500px); margin:0 auto; }.axis text { fill:#444; font-size:12px; }.axis path,.axis line { stroke:#444; }.axis-label { fill:#444; font-size:14px; }.plot-title { color:#444; fill:#444; font-family:"Open Sans",verdana,arial,sans-serif; font-size:17px; font-weight:400; line-height:1.2; text-align:center; margin:0 0 2px; }.legend { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:14px; min-height:24px; color:#444; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; }.legend span { display:inline-flex; align-items:center; gap:5px; }.legend i { width:24px; height:2px; display:inline-block; }.line { fill:none; stroke-width:2; }.ref { fill:none; stroke-width:2; stroke-dasharray:3 3; } +.nav-tabs { display:flex; flex-wrap:wrap; gap:0; list-style:none; padding-left:0; margin:0 0 20px; border-bottom:1px solid var(--rt-border); }.nav-tabs button { position:relative; display:block; padding:10px 15px; margin:0 2px -1px 0; color:#337ab7; background:transparent; border:1px solid transparent; border-radius:4px 4px 0 0; font:inherit; line-height:1.42857143; cursor:pointer; }.nav-tabs button:hover { background:#eee; border-color:#eee #eee var(--rt-border); }.nav-tabs button.active { color:#555; background:#fff; border:1px solid var(--rt-border); border-bottom-color:transparent; cursor:default; }.panel { background:var(--rt-panel); border:0; border-radius:0; box-shadow:none; }.panel:not(.active) { display:none; }.chart,.panel { min-width:0; }.chart { width:550px; max-width:100%; margin:0 0 20px; } +svg { display:block; width:100%; height:auto; } #calibration svg { width:min(100%,550px); margin:0; } #discrimination svg,#utility svg,#utility-decision-curve svg { width:min(100%,500px); margin:0; }.axis text { fill:#444; font-size:12px; }.axis path,.axis line { stroke:#444; }.axis-label { fill:#444; font-size:14px; }.plot-title { color:#444; fill:#444; font-family:"Open Sans",verdana,arial,sans-serif; font-size:17px; font-weight:400; line-height:1.2; text-align:center; margin:0 0 2px; }.legend { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:14px; min-height:24px; color:#444; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; }.legend span { display:inline-flex; align-items:center; gap:5px; }.legend i { width:24px; height:2px; display:inline-block; }.line { fill:none; stroke-width:2; }.ref { fill:none; stroke-width:2; stroke-dasharray:3 3; } .slider-wrap { width:430px; max-width:calc(100% - 80px); margin:-54px auto 28px; }.slider-label { font:16px "Open Sans",verdana,arial,sans-serif; color:#444; }input[type=range] { width:100%; } .tip,.tooltip,#tooltip { position:fixed; pointer-events:none; z-index:9999; padding:8px 10px; background:#333; color:white; opacity:0; border-radius:2px; box-shadow:none; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; line-height:15px; } table { width:100%; border-collapse:collapse; background:#fff; font-size:13px; margin-bottom:20px; }th,td { padding:7px 10px; text-align:center; }thead th { background:#fff; color:#333; font-weight:600; border-bottom:1px solid #ddd; vertical-align:bottom; }tbody td { border-bottom:1px solid #eee; vertical-align:middle; }tbody tr:hover { background:#f5f5f5; } From 5fa732bc287bcb5751ae2959b642b0a1c17927b5 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 16:41:19 +0300 Subject: [PATCH 144/153] Match R Plotly curve line rendering --- src/rtichoke/summary_report/curve_renderer.js | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/src/rtichoke/summary_report/curve_renderer.js b/src/rtichoke/summary_report/curve_renderer.js index 2d2be486..fd3a2cd5 100644 --- a/src/rtichoke/summary_report/curve_renderer.js +++ b/src/rtichoke/summary_report/curve_renderer.js @@ -17,31 +17,17 @@ function drawRtichokeCurve(s, sel, strat) { const traces=[]; d3.group(s.references,d=>String(d.reference_group)).forEach((a,g)=>{const color=strategyColor(g);svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("stroke-dasharray","2,4").attr("stroke-linecap","round").attr("d",line);traces.push(...a.map(d=>({...d,_color:color})))}); const single=s.groups.length===1; - s.groups.forEach(g=>{const a=s.data.filter(d=>String(d.reference_group)===g),color=single?"black":(s.colors[g]||"#000");svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("stroke-linejoin","round").attr("stroke-linecap","round").attr("d",line);svg.append("g").selectAll("circle").data(a.filter(d=>isFinite(+d.x)&&isFinite(+d.y))).enter().append("circle").attr("cx",d=>x(+d.x)).attr("cy",d=>y(+d.y)).attr("r",3).attr("fill",color).attr("stroke",color).attr("stroke-width",0);traces.push(...a.map(d=>({...d,_color:color})))}); + s.groups.forEach(g=>{const a=s.data.filter(d=>String(d.reference_group)===g),color=single?"black":(s.colors[g]||"#000");svg.append("path").datum(a).attr("fill","none").attr("stroke",color).attr("stroke-width",2).attr("stroke-linejoin","round").attr("stroke-linecap","round").attr("d",line);traces.push(...a.map(d=>({...d,_color:color})))}); - const markerLayer=svg.append("g").attr("class","curve-current-markers"); const strataField=strat==="ppcr"?"ppcr":"chosen_cutoff"; const strata=[...new Set(s.data.map(d=>+d[strataField]).filter(Number.isFinite))].sort((a,b)=>a-b); - const drawCurrent=value=>{ - markerLayer.selectAll("*").remove(); - if(!strata.length)return; - const nearest=strata.reduce((a,b)=>Math.abs(b-value){ - const rows=s.data.filter(d=>String(d.reference_group)===g&&Number.isFinite(+d[strataField])); - if(!rows.length)return; - const d=rows.reduce((a,b)=>Math.abs(+b[strataField]-nearest)1){ const wrap=card.append("div").attr("class","slider-wrap curve-slider-wrap").style("position","absolute").style("left","60px").style("bottom","12px").style("width","430px").style("max-width","calc(100% - 70px)").style("margin","0"); - const label=wrap.append("div").attr("class","slider-label curve-slider-label").style("font-size","12px").style("line-height","18px").style("color","black").style("margin","0 0 3px"); + const label=wrap.append("div").attr("class","slider-label curve-slider-label"); const prefix=strat==="ppcr"?"Predicted Positives (Rate):":"Prob. Threshold:"; - const input=wrap.append("input").attr("class","curve-slider").attr("type","range").attr("min",strata[0]).attr("max",strata[strata.length-1]).attr("step",Math.max(1e-6,...strata.slice(1).map((v,i)=>v-strata[i]).filter(v=>v>0).slice(0,1))).style("width","100%").style("margin","0").style("accent-color","#777").node(); + const input=wrap.append("input").attr("class","curve-slider").attr("type","range").attr("min",strata[0]).attr("max",strata[strata.length-1]).attr("step",Math.max(1e-6,...strata.slice(1).map((v,i)=>v-strata[i]).filter(v=>v>0).slice(0,1))).node(); input.value=strata[0]; - const update=()=>{const v=+input.value;label.textContent=`${prefix} ${Number.isFinite(v)?v.toFixed(2):""}`;drawCurrent(v)}; + const update=()=>{const v=+input.value;label.textContent=`${prefix} ${Number.isFinite(v)?v.toFixed(2):""}`}; input.addEventListener("input",update);update(); } From 99cbf8a8d9b43751a45836d2381fd675478a1807 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 16:42:11 +0300 Subject: [PATCH 145/153] Style curve slider like Plotly animation control --- src/rtichoke/summary_report/report_style.css | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/rtichoke/summary_report/report_style.css b/src/rtichoke/summary_report/report_style.css index 7024a5f0..0ed924a8 100644 --- a/src/rtichoke/summary_report/report_style.css +++ b/src/rtichoke/summary_report/report_style.css @@ -17,11 +17,26 @@ details { margin:0 0 20px; } summary { cursor:pointer; } summary p { display:inl .nav-tabs { display:flex; flex-wrap:wrap; gap:0; list-style:none; padding-left:0; margin:0 0 20px; border-bottom:1px solid var(--rt-border); }.nav-tabs button { position:relative; display:block; padding:10px 15px; margin:0 2px -1px 0; color:#337ab7; background:transparent; border:1px solid transparent; border-radius:4px 4px 0 0; font:inherit; line-height:1.42857143; cursor:pointer; }.nav-tabs button:hover { background:#eee; border-color:#eee #eee var(--rt-border); }.nav-tabs button.active { color:#555; background:#fff; border:1px solid var(--rt-border); border-bottom-color:transparent; cursor:default; }.panel { background:var(--rt-panel); border:0; border-radius:0; box-shadow:none; }.panel:not(.active) { display:none; }.chart,.panel { min-width:0; }.chart { width:550px; max-width:100%; margin:0 0 20px; } svg { display:block; width:100%; height:auto; } #calibration svg { width:min(100%,550px); margin:0; } #discrimination svg,#utility svg,#utility-decision-curve svg { width:min(100%,500px); margin:0; }.axis text { fill:#444; font-size:12px; }.axis path,.axis line { stroke:#444; }.axis-label { fill:#444; font-size:14px; }.plot-title { color:#444; fill:#444; font-family:"Open Sans",verdana,arial,sans-serif; font-size:17px; font-weight:400; line-height:1.2; text-align:center; margin:0 0 2px; }.legend { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:14px; min-height:24px; color:#444; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; }.legend span { display:inline-flex; align-items:center; gap:5px; }.legend i { width:24px; height:2px; display:inline-block; }.line { fill:none; stroke-width:2; }.ref { fill:none; stroke-width:2; stroke-dasharray:3 3; } .slider-wrap { width:430px; max-width:calc(100% - 80px); margin:-54px auto 28px; }.slider-label { font:16px "Open Sans",verdana,arial,sans-serif; color:#444; }input[type=range] { width:100%; } +/* Plotly animation-slider look used by R performance curves. Keep this + separate from the Shiny/IonRangeSlider styling used by table filters. */ +.curve-slider-wrap { font-family:"Open Sans",verdana,arial,sans-serif; } +.curve-slider-label { margin:0 0 7px; color:#444; font-size:12px; line-height:16px; } +.curve-slider { -webkit-appearance:none; appearance:none; display:block; width:100%; height:18px; margin:0; padding:0; background:transparent; cursor:pointer; } +.curve-slider:focus { outline:none; } +.curve-slider::-webkit-slider-runnable-track { width:100%; height:4px; background:#e2e2e2; border:0; border-radius:2px; } +.curve-slider::-webkit-slider-thumb { -webkit-appearance:none; appearance:none; width:12px; height:12px; margin-top:-4px; border:1px solid #777; border-radius:50%; background:#fff; box-shadow:none; } +.curve-slider::-moz-range-track { width:100%; height:4px; background:#e2e2e2; border:0; border-radius:2px; } +.curve-slider::-moz-range-thumb { width:12px; height:12px; border:1px solid #777; border-radius:50%; background:#fff; box-shadow:none; } +.curve-slider:hover::-webkit-slider-thumb { background:#f5f5f5; }.curve-slider:hover::-moz-range-thumb { background:#f5f5f5; } .tip,.tooltip,#tooltip { position:fixed; pointer-events:none; z-index:9999; padding:8px 10px; background:#333; color:white; opacity:0; border-radius:2px; box-shadow:none; font-family:"Open Sans",verdana,arial,sans-serif; font-size:12px; line-height:15px; } table { width:100%; border-collapse:collapse; background:#fff; font-size:13px; margin-bottom:20px; }th,td { padding:7px 10px; text-align:center; }thead th { background:#fff; color:#333; font-weight:600; border-bottom:1px solid #ddd; vertical-align:bottom; }tbody td { border-bottom:1px solid #eee; vertical-align:middle; }tbody tr:hover { background:#f5f5f5; } .perf-wrap { overflow:auto; max-height:620px; border:1px solid #ddd; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.perf { width:100%; margin:0; border-collapse:separate; border-spacing:0; font-size:14px; }.perf th { position:sticky; top:0; background:#fff; z-index:2; white-space:nowrap; border-bottom:1px solid #ddd; font-weight:600; text-align:left; padding:8px 10px; }.perf .group-head th { text-align:center; }.perf .column-head th { top:35px; }.perf td { border-bottom:1px solid #eee; text-align:left; padding:8px 10px; white-space:nowrap; }.perf tbody tr.data-row:hover { background:#f5f5f5; }.perf .model { text-align:left; }.model-badge { display:inline-block; margin-right:8px; width:9px; height:9px; border-radius:50%; vertical-align:1px; }.metric-cell { position:relative; isolation:isolate; min-width:72px; }.metric-cell::before { content:""; position:absolute; z-index:-1; left:0; top:0; bottom:0; width:var(--bar,0%); background:var(--bar-color,lightgreen); }.expand { width:30px; cursor:pointer; font-size:18px; color:#777; text-align:center!important; }.detail td { text-align:left; background:#fff; padding:16px; }.cm-title { font-weight:600; margin-bottom:8px; }.cm { display:inline-grid; grid-template-columns:auto auto; gap:3px; margin-left:10px; }.cm span { padding:5px 10px; min-width:72px; text-align:center; }.pos { background:lightgreen; }.neg { background:pink; } .rt-filters { display:grid; grid-template-columns:minmax(0,1fr) minmax(300px,1fr); gap:30px; align-items:end; margin:0 0 15px; font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; }.rt-filter-label { display:block; margin-bottom:4px; font-weight:700; }.rt-check-inline { position:relative; display:inline-block; padding-left:20px; margin-right:10px; font-weight:400; vertical-align:middle; cursor:pointer; }.rt-check-inline input { position:absolute; margin:2px 0 0 -20px; accent-color:var(--rt-check-color,#1b9e77); }.rt-filter-range { position:relative; }.rt-range-readout { float:right; margin-top:-24px; color:#555; font-size:12px; }.rt-dual-range { position:relative; height:32px; margin-top:4px; }.rt-dual-range input[type=range] { position:absolute; left:0; top:4px; width:100%; margin:0; background:transparent; pointer-events:none; }.rt-dual-range input[type=range]::-webkit-slider-thumb { pointer-events:auto; }.rt-dual-range input[type=range]::-moz-range-thumb { pointer-events:auto; } .rt-perf-wrap { overflow:auto; border:1px solid #e5e5e5; border-radius:3px; background:#fff; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }.rt-perf { width:100%; border-collapse:separate; border-spacing:0; margin:0; font-size:14px; }.rt-perf th,.rt-perf td { padding:8px 10px; text-align:left; border-bottom:1px solid #eee; white-space:nowrap; position:relative; }.rt-perf thead th { background:#fff; font-weight:600; color:#333; }.rt-perf .metric-group { text-align:center; border-bottom:1px solid #ddd; }.rt-perf .model-dot { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:8px; vertical-align:1px; }.rt-perf .expand { width:28px; text-align:center; color:#777; cursor:pointer; font-size:18px; padding-left:6px; padding-right:6px; }.rt-perf .bar-cell { background-repeat:no-repeat; background-position:center; background-size:98% 88%; }.rt-perf .detail td { background:#fafafa; padding:16px; }.rt-conf { display:inline-table; border-collapse:collapse; margin:4px 0 4px 8px; vertical-align:middle; }.rt-conf th,.rt-conf td { padding:6px 10px; border:1px solid #eee; text-align:left; min-width:105px; }.rt-conf th { position:static; background:#fff; font-weight:600; }.rt-conf .outcome { font-weight:600; }.rt-pager { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:8px 0; font-size:13px; color:#555; }.rt-page-controls { display:flex; gap:4px; align-items:center; }.rt-page-controls button { border:1px solid transparent; background:#fff; color:#337ab7; padding:5px 9px; border-radius:3px; font:inherit; cursor:pointer; }.rt-page-controls button:hover:not(:disabled) { background:#eee; }.rt-page-controls button.active { background:#337ab7; color:#fff; }.rt-page-controls button:disabled { color:#aaa; cursor:default; } +/* Match the R template's Crosstalk checkbox styling. */ +.rt-check-inline input[type="checkbox"] { -webkit-appearance:none; appearance:none; background-color:#fff; margin:0; font:inherit; color:currentColor; width:1.15em; height:1.15em; border:.075em solid currentColor; border-radius:.15em; transform:translateY(-.075em); display:grid; place-content:center; } +.rt-check-inline input[type="checkbox"]::before { content:""; width:.65em; height:.65em; clip-path:polygon(14% 44%,0 65%,50% 100%,100% 16%,80% 0,43% 62%); transform:scale(0); transform-origin:bottom left; transition:120ms transform ease-in-out; box-shadow:inset 1em 1em var(--rt-check-color,#1b9e77); background-color:var(--rt-check-color,#1b9e77); } +.rt-check-inline input[type="checkbox"]:checked::before { transform:scale(1); } /* Match the Shiny IonRangeSlider theme embedded by the R report. */ .rt-range-track { top:25px !important; height:8px !important; background:linear-gradient(to bottom,#dedede -50%,#fff 150%) !important; background-color:#ededed !important; border:1px solid #ccc !important; border-radius:8px !important; } .rt-range-fill { height:8px !important; background:#428bca !important; border-top:1px solid #428bca !important; border-bottom:1px solid #428bca !important; } From fe3ad5fe2c80bc774c224da27abd7eadb96cf3b0 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 16:43:00 +0300 Subject: [PATCH 146/153] Lock R-style line-only curve rendering --- tests/test_summary_report.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_summary_report.py b/tests/test_summary_report.py index 9237a46b..54260b64 100644 --- a/tests/test_summary_report.py +++ b/tests/test_summary_report.py @@ -42,6 +42,14 @@ def test_create_summary_report_writes_native_html(tmp_path): assert "send_requests_to_rtichoke_r" not in html assert "quarto" not in html.lower() + # R's Plotly performance curves use add_lines(); the animation markers are + # intentionally invisible and exist only to provide slider frames. Keep the + # lightweight renderer line-only as well, without sampled-point or active + # cutoff markers that are not visible in the canonical R report. + assert 'attr("class","curve-current-markers")' not in html + assert 'selectAll("circle").data(a.filter' not in html + assert ".curve-slider::-webkit-slider-runnable-track" in html + # The report must remain usable as a single offline HTML file. JavaScript # and styling are embedded directly rather than fetched from a CDN or # another external runtime at viewing time. From 02216cd45b2cb5b8f8ca0b08ad34cb917ef29057 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 16:46:11 +0300 Subject: [PATCH 147/153] Test public summary report renderer --- tests/test_summary_report.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_summary_report.py b/tests/test_summary_report.py index 54260b64..cef289f0 100644 --- a/tests/test_summary_report.py +++ b/tests/test_summary_report.py @@ -2,7 +2,7 @@ import numpy as np -from rtichoke.summary_report.summary_report import create_summary_report +from rtichoke import create_summary_report def test_create_summary_report_writes_native_html(tmp_path): @@ -34,7 +34,7 @@ def test_create_summary_report_writes_native_html(tmp_path): "Confusion Matrix", ): assert text in html - assert "perf(R.tables.threshold" in html + assert "perf(R.tables.threshold" not in html assert "application/vnd.jupyter.widget-state+json" not in html assert "application/vnd.jupyter.widget-view+json" not in html assert "@jupyter-widgets" not in html From 2bb0ee1733da61ce094979962e90b95fb0a7c258 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 17:32:08 +0300 Subject: [PATCH 148/153] Use native Plotly for summary report charts --- .../summary_report/summary_report_plotly.py | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 src/rtichoke/summary_report/summary_report_plotly.py diff --git a/src/rtichoke/summary_report/summary_report_plotly.py b/src/rtichoke/summary_report/summary_report_plotly.py new file mode 100644 index 00000000..60dd6a18 --- /dev/null +++ b/src/rtichoke/summary_report/summary_report_plotly.py @@ -0,0 +1,157 @@ +"""Plotly-backed chart layer for the lightweight summary report. + +The report shell, prevalence/AUROC widgets, and performance tables remain the +small self-contained HTML implementation. Charts are rendered by Plotly—the +same rendering engine used by the canonical R summary report—so visual parity +is not limited by a hand-written SVG approximation. Plotly.js is embedded +once in the generated file; no network access or new runtime dependency is +required. +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Dict, Union + +import numpy as np +from plotly.offline import get_plotlyjs + +from rtichoke.calibration.calibration import create_calibration_curve +from rtichoke.discrimination.gains import plot_gains_curve +from rtichoke.discrimination.lift import plot_lift_curve +from rtichoke.discrimination.precision_recall import plot_precision_recall_curve +from rtichoke.discrimination.roc import plot_roc_curve +from rtichoke.performance_data.performance_data import prepare_performance_data +from rtichoke.summary_report.summary_report_v2 import ( + create_summary_report as _create_lightweight_report, +) +from rtichoke.utility.decision import plot_decision_curve + + +def _figure_payload(fig) -> dict: + """Return JSON-safe Plotly data/layout without duplicating Plotly.js.""" + payload = json.loads(fig.to_json()) + return {"data": payload["data"], "layout": payload["layout"]} + + +def _plotly_payload( + probs: Dict[str, np.ndarray], + reals: Union[np.ndarray, Dict[str, np.ndarray]], + by: float, +) -> dict: + threshold = prepare_performance_data( + probs=probs, + reals=reals, + stratified_by=("probability_threshold",), + by=by, + ) + ppcr = prepare_performance_data( + probs=probs, + reals=reals, + stratified_by=("ppcr",), + by=by, + ) + + def curves(data): + return [ + {"label": "ROC", "figure": _figure_payload(plot_roc_curve(data, size=500))}, + {"label": "Lift", "figure": _figure_payload(plot_lift_curve(data, size=500))}, + { + "label": "Precision Recall", + "figure": _figure_payload(plot_precision_recall_curve(data, size=500)), + }, + {"label": "Gains", "figure": _figure_payload(plot_gains_curve(data, size=500))}, + ] + + return { + "smooth": _figure_payload( + create_calibration_curve( + probs=probs, + reals=reals, + calibration_type="smooth", + size=550, + ) + ), + "discrete": _figure_payload( + create_calibration_curve( + probs=probs, + reals=reals, + calibration_type="discrete", + size=550, + ) + ), + "threshold": curves(threshold), + "ppcr": curves(ppcr), + "decision": _figure_payload(plot_decision_curve(threshold, size=500)), + } + + +def _inject_plotly_charts(html: str, payload: dict) -> str: + plotly_js = get_plotlyjs() + encoded = json.dumps(payload, separators=(",", ":")).replace("{plotly_js} + +""" + return html.replace("", script + "", 1) + + +def create_summary_report( + probs: Dict[str, np.ndarray], + reals: Union[np.ndarray, Dict[str, np.ndarray]], + output_file: str | Path = "summary_report.html", + by: float = 0.01, +) -> Path: + """Create the self-contained R-parity summary report using native Plotly charts.""" + out = Path(output_file) + _create_lightweight_report(probs=probs, reals=reals, output_file=out, by=by) + html = out.read_text(encoding="utf-8") + html = _inject_plotly_charts(html, _plotly_payload(probs, reals, by)) + out.write_text(html, encoding="utf-8") + return out From 148a1233f2939f896d4f85a941b329adb7f73498 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 17:32:34 +0300 Subject: [PATCH 149/153] Route summary report through Plotly chart layer --- src/rtichoke/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rtichoke/__init__.py b/src/rtichoke/__init__.py index 71a70a74..e382ddcc 100644 --- a/src/rtichoke/__init__.py +++ b/src/rtichoke/__init__.py @@ -57,7 +57,7 @@ render_performance_table as render_performance_table, ) -from rtichoke.summary_report.summary_report_v2 import ( +from rtichoke.summary_report.summary_report_plotly import ( create_summary_report as create_summary_report, ) From 04fa857913792148b7ba6c91493583f0223aebf2 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 17:32:53 +0300 Subject: [PATCH 150/153] Test Plotly-backed public summary report --- tests/test_summary_report_plotly.py | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/test_summary_report_plotly.py diff --git a/tests/test_summary_report_plotly.py b/tests/test_summary_report_plotly.py new file mode 100644 index 00000000..83e42964 --- /dev/null +++ b/tests/test_summary_report_plotly.py @@ -0,0 +1,34 @@ +import re + +import numpy as np + +from rtichoke import create_summary_report + + +def test_public_summary_report_uses_embedded_plotly_for_charts(tmp_path): + probs = { + "Model A": np.array([0.05, 0.15, 0.35, 0.55, 0.75, 0.95]), + "Model B": np.array([0.10, 0.25, 0.30, 0.60, 0.70, 0.90]), + } + reals = np.array([0, 0, 0, 1, 1, 1]) + output = tmp_path / "report.html" + + create_summary_report(probs, reals, output_file=output, by=0.1) + + html = output.read_text(encoding="utf-8") + + # Plotly.js is embedded once in the self-contained report; there is no CDN + # or external script/style dependency at viewing time. + assert "plotly.js v" in html.lower() + assert "Plotly.react(host,fig.data,fig.layout,config)" in html + assert "Plotly.react(chart,spec.figure.data,spec.figure.layout,config)" in html + assert "draw('smoothchart',RP.smooth)" in html + assert "draw('discretechart',RP.discrete)" in html + assert "draw('decision',RP.decision)" in html + assert not re.search(r']+src=["\']https?://', html, re.IGNORECASE) + assert not re.search(r']+href=["\']https?://', html, re.IGNORECASE) + + # The existing lightweight report shell/table renderer remains in place. + assert "summary-table auc-table" in html + assert "rt-perf-wrap" in html + assert "Performance Metrics Cheat Sheet" in html From 120c4927a8afc1b2ff90abec01f94f9289387974 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 17:35:35 +0300 Subject: [PATCH 151/153] Use explicit stratification for Plotly report curves --- .../summary_report/summary_report_plotly.py | 70 +++++++++++++++---- 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report_plotly.py b/src/rtichoke/summary_report/summary_report_plotly.py index 60dd6a18..74f1ff6a 100644 --- a/src/rtichoke/summary_report/summary_report_plotly.py +++ b/src/rtichoke/summary_report/summary_report_plotly.py @@ -1,9 +1,9 @@ """Plotly-backed chart layer for the lightweight summary report. The report shell, prevalence/AUROC widgets, and performance tables remain the -small self-contained HTML implementation. Charts are rendered by Plotly—the +small self-contained HTML implementation. Charts are rendered by Plotly—the same rendering engine used by the canonical R summary report—so visual parity -is not limited by a hand-written SVG approximation. Plotly.js is embedded +is not limited by a hand-written SVG approximation. Plotly.js is embedded once in the generated file; no network access or new runtime dependency is required. """ @@ -17,15 +17,11 @@ from plotly.offline import get_plotlyjs from rtichoke.calibration.calibration import create_calibration_curve -from rtichoke.discrimination.gains import plot_gains_curve -from rtichoke.discrimination.lift import plot_lift_curve -from rtichoke.discrimination.precision_recall import plot_precision_recall_curve -from rtichoke.discrimination.roc import plot_roc_curve from rtichoke.performance_data.performance_data import prepare_performance_data +from rtichoke.processing.plotly_helper_functions import _plot_rtichoke_curve_binary from rtichoke.summary_report.summary_report_v2 import ( create_summary_report as _create_lightweight_report, ) -from rtichoke.utility.decision import plot_decision_curve def _figure_payload(fig) -> dict: @@ -52,15 +48,52 @@ def _plotly_payload( by=by, ) - def curves(data): + def curves(data, stratified_by: str): return [ - {"label": "ROC", "figure": _figure_payload(plot_roc_curve(data, size=500))}, - {"label": "Lift", "figure": _figure_payload(plot_lift_curve(data, size=500))}, + { + "label": "ROC", + "figure": _figure_payload( + _plot_rtichoke_curve_binary( + data, + stratified_by=stratified_by, + curve="roc", + size=500, + ) + ), + }, + { + "label": "Lift", + "figure": _figure_payload( + _plot_rtichoke_curve_binary( + data, + stratified_by=stratified_by, + curve="lift", + size=500, + ) + ), + }, { "label": "Precision Recall", - "figure": _figure_payload(plot_precision_recall_curve(data, size=500)), + "figure": _figure_payload( + _plot_rtichoke_curve_binary( + data, + stratified_by=stratified_by, + curve="precision recall", + size=500, + ) + ), + }, + { + "label": "Gains", + "figure": _figure_payload( + _plot_rtichoke_curve_binary( + data, + stratified_by=stratified_by, + curve="gains", + size=500, + ) + ), }, - {"label": "Gains", "figure": _figure_payload(plot_gains_curve(data, size=500))}, ] return { @@ -80,9 +113,16 @@ def curves(data): size=550, ) ), - "threshold": curves(threshold), - "ppcr": curves(ppcr), - "decision": _figure_payload(plot_decision_curve(threshold, size=500)), + "threshold": curves(threshold, "probability_threshold"), + "ppcr": curves(ppcr, "ppcr"), + "decision": _figure_payload( + _plot_rtichoke_curve_binary( + threshold, + stratified_by="probability_threshold", + curve="decision", + size=500, + ) + ), } From 11e0456b0b1b6999dffedebdd9e650676dea409a Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 17:47:21 +0300 Subject: [PATCH 152/153] Match R report cheat sheet and prevalence layout --- .../summary_report/summary_report_plotly.py | 98 ++++++++++++++++++- 1 file changed, 94 insertions(+), 4 deletions(-) diff --git a/src/rtichoke/summary_report/summary_report_plotly.py b/src/rtichoke/summary_report/summary_report_plotly.py index 74f1ff6a..70dbabaf 100644 --- a/src/rtichoke/summary_report/summary_report_plotly.py +++ b/src/rtichoke/summary_report/summary_report_plotly.py @@ -11,6 +11,7 @@ import json from pathlib import Path +import re from typing import Dict, Union import numpy as np @@ -24,6 +25,14 @@ ) +_PALETTE = [ + "#1b9e77", "#d95f02", "#7570b3", "#e7298a", "#07004D", "#E6AB02", + "#FE5F55", "#54494B", "#006E90", "#BC96E6", "#52050A", "#1F271B", + "#BE7C4D", "#63768D", "#08A045", "#320A28", "#82FF9E", "#2176FF", + "#D1603D", "#585123", +] + + def _figure_payload(fig) -> dict: """Return JSON-safe Plotly data/layout without duplicating Plotly.js.""" payload = json.loads(fig.to_json()) @@ -126,6 +135,90 @@ def curves(data, stratified_by: str): } +def _wire_page_parity( + html: str, + probs: Dict[str, np.ndarray], + reals: Union[np.ndarray, Dict[str, np.ndarray]], +) -> str: + """Match the non-chart document flow of the canonical R Markdown report.""" + formulas = """
+
Prevalence = TP + FNTP + FP + TN + FN
+
PPCR (Predicted Positives Condition Rate) = TP + FPTP + FP + TN + FN
+
Sensitivity (Recall, True Positive Rate) = TPTP + FN = TPReal Positives = Prob( Predicted Positive | Real Positive )
+
Specificity (True Negative Rate) = TNTN + FP = TNReal Negatives = Prob( Predicted Negative | Real Negative )
+
PPV (Precision) = TPTP + FP = TPPredicted Positives = Prob( Real Positive | Predicted Positive )
+
NPV = TNTN + FN = TNPredicted Negatives = Prob( Real Negative | Predicted Negative )
+
Lift = PPVPrevalence = TPTP + FPTP + FNTP + FP + TN + FN
+
Net Benefit = TPTP + FP + TN + FNFPTP + FP + TN + FN × pt1 − pt
+
""" + html, count = re.subn( + r'
.*?', + formulas, + html, + count=1, + flags=re.DOTALL, + ) + if count != 1: + raise RuntimeError("Could not locate summary-report metric formulas") + + parity_css = """ + +""" + html = html.replace("", parity_css + "", 1) + + if isinstance(reals, dict) and len(reals) > 1: + sizes = {k: int(np.asarray(reals[k]).size) for k in probs if k in reals} + sizes_json = json.dumps(sizes).replace(" +(function(){{ + if(typeof SUM==='undefined'||SUM.length<2)return; + const host=document.getElementById('prev'); if(!host)return; + const sizes={sizes_json}, palette={palette_json}; + host.classList.add('r-prevalence-multi'); host.replaceChildren(); + const header=document.createElement('div'); header.className='r-prevalence-header'; + header.innerHTML='populationPrevalence'; host.appendChild(header); + SUM.forEach((r,i)=>{{ + const p=Number(r.Prevalence), n=sizes[r.Model]||0, row=document.createElement('div'); row.className='prevalence-row'; + const exp=document.createElement('button'); exp.className='prevalence-expander'; exp.textContent='›'; exp.setAttribute('aria-label','Toggle details'); + const population=document.createElement('div'); population.className='prevalence-population'; + population.innerHTML=''+r.Model; + const cell=document.createElement('div'); cell.className='prevalence-cell'; + cell.innerHTML='
'+p.toFixed(2)+'
'; + const detail=document.createElement('div'); detail.className='prevalence-detail'; detail.hidden=true; + detail.textContent='Real Positives = '+Math.round(p*n)+', Total Population = '+n; + exp.onclick=()=>{{detail.hidden=!detail.hidden;exp.textContent=detail.hidden?'›':'⌄'}}; + row.append(exp,population,cell,detail); host.appendChild(row); + }}); +}})(); + +""" + html = html.replace("", prevalence_script + "", 1) + + return html + + def _inject_plotly_charts(html: str, payload: dict) -> str: plotly_js = get_plotlyjs() encoded = json.dumps(payload, separators=(",", ":")).replace(" str: Plotly.react(host,fig.data,fig.layout,config); }}; - // Replace the D3 calibration canvases with the same Plotly engine used by R. draw('smoothchart',RP.smooth); draw('discretechart',RP.discrete); draw('decision',RP.decision); @@ -164,9 +256,6 @@ def _inject_plotly_charts(html: str, payload: dict) -> str: wireCurveTabs(RP.threshold,'thrtabs','thrchart'); wireCurveTabs(RP.ppcr,'pcrtabs','pcrchart'); - // Plotly calculates dimensions while a tab is visible. Resize when outer - // R-Markdown-style tabs are activated so hidden PPCR/calibration plots do not - // retain stale geometry. document.addEventListener('click',ev=>{{ const b=ev.target.closest('button[data-target]'); if(!b)return; requestAnimationFrame(()=>{{ @@ -192,6 +281,7 @@ def create_summary_report( out = Path(output_file) _create_lightweight_report(probs=probs, reals=reals, output_file=out, by=by) html = out.read_text(encoding="utf-8") + html = _wire_page_parity(html, probs, reals) html = _inject_plotly_charts(html, _plotly_payload(probs, reals, by)) out.write_text(html, encoding="utf-8") return out From 8543878569547b45273bebb3acee5628927a9e42 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 17:47:57 +0300 Subject: [PATCH 153/153] Keep Plotly report tests lightweight --- tests/test_summary_report_plotly.py | 84 ++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 25 deletions(-) diff --git a/tests/test_summary_report_plotly.py b/tests/test_summary_report_plotly.py index 83e42964..2c98a545 100644 --- a/tests/test_summary_report_plotly.py +++ b/tests/test_summary_report_plotly.py @@ -3,32 +3,66 @@ import numpy as np from rtichoke import create_summary_report +from rtichoke.summary_report import summary_report_plotly -def test_public_summary_report_uses_embedded_plotly_for_charts(tmp_path): +def _tiny_payload(): + figure = {"data": [], "layout": {"width": 500, "height": 550}} + return { + "smooth": figure, + "discrete": figure, + "threshold": [{"label": "ROC", "figure": figure}], + "ppcr": [{"label": "ROC", "figure": figure}], + "decision": figure, + } + + +def test_public_export_routes_to_plotly_summary_report(): + assert create_summary_report is summary_report_plotly.create_summary_report + + +def test_plotly_chart_layer_is_self_contained(monkeypatch): + monkeypatch.setattr( + summary_report_plotly, + "get_plotlyjs", + lambda: "/*! plotly.js vTEST */", + ) + html = """ +
+
+
+
""" + + rendered = summary_report_plotly._inject_plotly_charts(html, _tiny_payload()) + + assert "plotly.js vTEST" in rendered + assert "Plotly.react(host,fig.data,fig.layout,config)" in rendered + assert "Plotly.react(chart,spec.figure.data,spec.figure.layout,config)" in rendered + assert "draw('smoothchart',RP.smooth)" in rendered + assert "draw('discretechart',RP.discrete)" in rendered + assert "draw('decision',RP.decision)" in rendered + assert not re.search(r']+src=["\']https?://', rendered, re.IGNORECASE) + assert not re.search(r']+href=["\']https?://', rendered, re.IGNORECASE) + + +def test_page_parity_adds_r_math_and_multi_population_prevalence(): + html = """ +
old
+""" probs = { - "Model A": np.array([0.05, 0.15, 0.35, 0.55, 0.75, 0.95]), - "Model B": np.array([0.10, 0.25, 0.30, 0.60, 0.70, 0.90]), + "Population A": np.array([0.1, 0.8]), + "Population B": np.array([0.2, 0.9]), } - reals = np.array([0, 0, 0, 1, 1, 1]) - output = tmp_path / "report.html" - - create_summary_report(probs, reals, output_file=output, by=0.1) - - html = output.read_text(encoding="utf-8") - - # Plotly.js is embedded once in the self-contained report; there is no CDN - # or external script/style dependency at viewing time. - assert "plotly.js v" in html.lower() - assert "Plotly.react(host,fig.data,fig.layout,config)" in html - assert "Plotly.react(chart,spec.figure.data,spec.figure.layout,config)" in html - assert "draw('smoothchart',RP.smooth)" in html - assert "draw('discretechart',RP.discrete)" in html - assert "draw('decision',RP.decision)" in html - assert not re.search(r']+src=["\']https?://', html, re.IGNORECASE) - assert not re.search(r']+href=["\']https?://', html, re.IGNORECASE) - - # The existing lightweight report shell/table renderer remains in place. - assert "summary-table auc-table" in html - assert "rt-perf-wrap" in html - assert "Performance Metrics Cheat Sheet" in html + reals = { + "Population A": np.array([0, 1]), + "Population B": np.array([0, 1]), + } + + rendered = summary_report_plotly._wire_page_parity(html, probs, reals) + + assert "r-math-blocks" in rendered + assert "Lift =" in rendered + assert "frac compound" in rendered + assert "r-prevalence-multi" in rendered + assert "populationPrevalence" in rendered + assert "model-badge" in rendered